@gmickel/gno 1.18.0 → 1.19.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 (129) hide show
  1. package/README.md +5 -3
  2. package/assets/skill/SKILL.md +27 -0
  3. package/package.json +2 -1
  4. package/spec/AGENTS.md +83 -0
  5. package/spec/CLAUDE.md +83 -0
  6. package/spec/bench-fixture.schema.json +137 -0
  7. package/spec/cli.md +2894 -0
  8. package/spec/db/schema.sql +442 -0
  9. package/spec/evals-agentic.md +510 -0
  10. package/spec/evals.md +1106 -0
  11. package/spec/mcp.md +2229 -0
  12. package/spec/output-schemas/activation-verification.schema.json +515 -0
  13. package/spec/output-schemas/ask.schema.json +366 -0
  14. package/spec/output-schemas/backlinks.schema.json +131 -0
  15. package/spec/output-schemas/bench-result.schema.json +120 -0
  16. package/spec/output-schemas/capture-receipt.schema.json +143 -0
  17. package/spec/output-schemas/collection-list.schema.json +45 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +691 -0
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1338 -0
  20. package/spec/output-schemas/context-list.schema.json +21 -0
  21. package/spec/output-schemas/doctor.schema.json +313 -0
  22. package/spec/output-schemas/error.schema.json +30 -0
  23. package/spec/output-schemas/expansion.schema.json +37 -0
  24. package/spec/output-schemas/get.schema.json +140 -0
  25. package/spec/output-schemas/graph-query.schema.json +99 -0
  26. package/spec/output-schemas/graph.schema.json +371 -0
  27. package/spec/output-schemas/links-list.schema.json +186 -0
  28. package/spec/output-schemas/mcp-add-collection-result.schema.json +23 -0
  29. package/spec/output-schemas/mcp-capture-result.schema.json +152 -0
  30. package/spec/output-schemas/mcp-http-error.schema.json +30 -0
  31. package/spec/output-schemas/mcp-job-list.schema.json +58 -0
  32. package/spec/output-schemas/mcp-job-status.schema.json +224 -0
  33. package/spec/output-schemas/mcp-remove-result.schema.json +39 -0
  34. package/spec/output-schemas/mcp-sync-result.schema.json +41 -0
  35. package/spec/output-schemas/mcp-tag-result.schema.json +33 -0
  36. package/spec/output-schemas/models-list.schema.json +93 -0
  37. package/spec/output-schemas/multi-get.schema.json +103 -0
  38. package/spec/output-schemas/process-status.schema.json +119 -0
  39. package/spec/output-schemas/query-diagnose.schema.json +123 -0
  40. package/spec/output-schemas/resident-status.schema.json +154 -0
  41. package/spec/output-schemas/retrieval-trace-common.schema.json +492 -0
  42. package/spec/output-schemas/retrieval-trace-delete.schema.json +16 -0
  43. package/spec/output-schemas/retrieval-trace-export.schema.json +61 -0
  44. package/spec/output-schemas/retrieval-trace-filters.schema.json +139 -0
  45. package/spec/output-schemas/retrieval-trace-judgment.schema.json +15 -0
  46. package/spec/output-schemas/retrieval-trace-list.schema.json +18 -0
  47. package/spec/output-schemas/retrieval-trace-payloads.schema.json +178 -0
  48. package/spec/output-schemas/retrieval-trace-purge.schema.json +31 -0
  49. package/spec/output-schemas/retrieval-trace-qrels.schema.json +303 -0
  50. package/spec/output-schemas/retrieval-trace-replay.schema.json +286 -0
  51. package/spec/output-schemas/retrieval-trace-show.schema.json +69 -0
  52. package/spec/output-schemas/retrieval-trace-summary.schema.json +65 -0
  53. package/spec/output-schemas/search-result.schema.json +154 -0
  54. package/spec/output-schemas/search-results.schema.json +338 -0
  55. package/spec/output-schemas/similar.schema.json +84 -0
  56. package/spec/output-schemas/status.schema.json +676 -0
  57. package/spec/output-schemas/tags-list.schema.json +48 -0
  58. package/src/app/context-runtime-types.ts +3 -0
  59. package/src/app/context-runtime.ts +15 -1
  60. package/src/cli/commands/ask.ts +106 -36
  61. package/src/cli/commands/context-build.ts +56 -9
  62. package/src/cli/commands/get.ts +64 -3
  63. package/src/cli/commands/query.ts +62 -23
  64. package/src/cli/commands/replay.ts +140 -0
  65. package/src/cli/commands/search.ts +48 -3
  66. package/src/cli/commands/shared.ts +3 -1
  67. package/src/cli/commands/trace.ts +200 -0
  68. package/src/cli/commands/vsearch.ts +75 -53
  69. package/src/cli/program.ts +255 -0
  70. package/src/config/index.ts +9 -0
  71. package/src/config/retrieval-traces.ts +56 -0
  72. package/src/config/types.ts +4 -0
  73. package/src/core/context-compiler.ts +11 -4
  74. package/src/core/retrieval-qrels.ts +405 -0
  75. package/src/core/retrieval-replay-candidate.ts +368 -0
  76. package/src/core/retrieval-replay-types.ts +109 -0
  77. package/src/core/retrieval-replay-validation.ts +89 -0
  78. package/src/core/retrieval-replay.ts +441 -0
  79. package/src/core/retrieval-trace-evidence-origin.ts +175 -0
  80. package/src/core/retrieval-trace-export.ts +113 -0
  81. package/src/core/retrieval-trace-filter-normalization.ts +27 -0
  82. package/src/core/retrieval-trace-filters.ts +19 -0
  83. package/src/core/retrieval-trace-management-helpers.ts +247 -0
  84. package/src/core/retrieval-trace-management-types.ts +132 -0
  85. package/src/core/retrieval-trace-management.ts +422 -0
  86. package/src/core/retrieval-trace-request.ts +141 -0
  87. package/src/core/retrieval-trace-session.ts +494 -0
  88. package/src/core/retrieval-trace.ts +472 -0
  89. package/src/mcp/tools/context.ts +59 -8
  90. package/src/mcp/tools/get.ts +35 -1
  91. package/src/mcp/tools/index.ts +74 -0
  92. package/src/mcp/tools/query.ts +95 -64
  93. package/src/mcp/tools/search.ts +36 -13
  94. package/src/mcp/tools/trace.ts +143 -0
  95. package/src/mcp/tools/vsearch.ts +71 -38
  96. package/src/pipeline/answer.ts +167 -26
  97. package/src/pipeline/graph-retrieval.ts +15 -1
  98. package/src/pipeline/hybrid.ts +151 -43
  99. package/src/pipeline/search.ts +36 -3
  100. package/src/pipeline/trace-metadata.ts +47 -0
  101. package/src/pipeline/types.ts +43 -0
  102. package/src/pipeline/vsearch.ts +101 -38
  103. package/src/sdk/client.ts +380 -71
  104. package/src/sdk/documents.ts +48 -1
  105. package/src/sdk/index.ts +17 -0
  106. package/src/sdk/types.ts +28 -0
  107. package/src/serve/context-capsule.ts +67 -8
  108. package/src/serve/public/app.tsx +12 -1
  109. package/src/serve/public/globals.built.css +1 -1
  110. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  111. package/src/serve/public/pages/Dashboard.tsx +10 -0
  112. package/src/serve/public/pages/TraceHistory.tsx +478 -0
  113. package/src/serve/public/pages/trace-history-detail.tsx +224 -0
  114. package/src/serve/retrieval-trace.ts +28 -0
  115. package/src/serve/routes/api.ts +366 -72
  116. package/src/serve/routes/traces.ts +156 -0
  117. package/src/serve/server.ts +87 -2
  118. package/src/store/index.ts +31 -0
  119. package/src/store/migrations/014-retrieval-traces.ts +303 -0
  120. package/src/store/migrations/index.ts +2 -0
  121. package/src/store/retrieval-trace-codec.ts +384 -0
  122. package/src/store/sqlite/adapter.ts +153 -1
  123. package/src/store/sqlite/retrieval-trace-management-store.ts +341 -0
  124. package/src/store/sqlite/retrieval-trace-retention.ts +349 -0
  125. package/src/store/sqlite/retrieval-trace-rows.ts +267 -0
  126. package/src/store/sqlite/retrieval-trace-store.ts +515 -0
  127. package/src/store/types.ts +297 -0
  128. package/src/store/vector/sqlite-vec.ts +76 -1
  129. package/src/store/vector/types.ts +1 -1
@@ -0,0 +1,368 @@
1
+ /** Candidate execution with exact persisted replay scopes. */
2
+
3
+ import type { HybridSearchDeps } from "../pipeline/hybrid";
4
+ import type {
5
+ HybridSearchOptions,
6
+ SearchResult,
7
+ SearchResults,
8
+ SearchResultsTraceMetadata,
9
+ } from "../pipeline/types";
10
+ import type { StoreResult } from "../store/types";
11
+ import type { RetrievalQrelsCase } from "./retrieval-qrels";
12
+ import type { RetrievalReplayCandidate } from "./retrieval-replay-types";
13
+
14
+ import { parseUri } from "../app/constants";
15
+ import { searchHybrid } from "../pipeline/hybrid";
16
+ import { searchBm25 } from "../pipeline/search";
17
+ import { SEARCH_RESULTS_TRACE_METADATA } from "../pipeline/types";
18
+ import { searchVector } from "../pipeline/vsearch";
19
+ import { err, ok } from "../store/types";
20
+
21
+ export interface RetrievalReplayDeps extends HybridSearchDeps {
22
+ indexName?: string;
23
+ modelUris?: string[];
24
+ }
25
+
26
+ export const retrievalReplayLimit = (
27
+ source: RetrievalQrelsCase,
28
+ candidate: RetrievalReplayCandidate
29
+ ): number => {
30
+ const persistedLimit =
31
+ typeof source.query.filters.limit === "number"
32
+ ? source.query.filters.limit
33
+ : undefined;
34
+ return (
35
+ candidate.limit ??
36
+ persistedLimit ??
37
+ (source.baseline.ranked.length > 0 ? source.baseline.ranked.length : 20)
38
+ );
39
+ };
40
+
41
+ export const retrievalReplayTraceMetadata = (
42
+ candidate: RetrievalReplayCandidate,
43
+ result: SearchResults
44
+ ): SearchResultsTraceMetadata => {
45
+ const persisted = result[SEARCH_RESULTS_TRACE_METADATA];
46
+ if (persisted) return persisted;
47
+ if (candidate.type === "bm25") {
48
+ return {
49
+ capabilityOutcomes: [{ capability: "lexical_search", status: "used" }],
50
+ fallbackCodes: [],
51
+ };
52
+ }
53
+ if (candidate.type === "vector") {
54
+ return {
55
+ capabilityOutcomes: [{ capability: "semantic_search", status: "used" }],
56
+ fallbackCodes: [],
57
+ };
58
+ }
59
+ return {
60
+ capabilityOutcomes: [
61
+ { capability: "lexical_search", status: "used" },
62
+ result.meta.vectorsUsed
63
+ ? { capability: "semantic_search", status: "used" }
64
+ : {
65
+ capability: "semantic_search",
66
+ status: "unavailable",
67
+ reasonCode: "vector_unavailable",
68
+ },
69
+ ],
70
+ fallbackCodes: result.meta.vectorsUsed ? [] : ["vector_unavailable"],
71
+ };
72
+ };
73
+
74
+ export const buildRetrievalReplaySearchOptions = (
75
+ source: RetrievalQrelsCase,
76
+ candidate: RetrievalReplayCandidate,
77
+ collectionOverride?: string,
78
+ retrievalScope?: HybridSearchOptions["retrievalScope"]
79
+ ): HybridSearchOptions => {
80
+ const filters = source.query.filters;
81
+ return {
82
+ limit:
83
+ candidate.limit ??
84
+ (typeof filters.limit === "number"
85
+ ? filters.limit
86
+ : source.baseline.ranked.length || undefined),
87
+ minScore:
88
+ typeof filters.minScore === "number" ? filters.minScore : undefined,
89
+ collection:
90
+ collectionOverride ??
91
+ (typeof filters.collection === "string" ? filters.collection : undefined),
92
+ retrievalScope,
93
+ lang: typeof filters.lang === "string" ? filters.lang : undefined,
94
+ full: typeof filters.full === "boolean" ? filters.full : undefined,
95
+ lineNumbers:
96
+ typeof filters.lineNumbers === "boolean"
97
+ ? filters.lineNumbers
98
+ : undefined,
99
+ tagsAll: Array.isArray(filters.tagsAll)
100
+ ? (filters.tagsAll as string[])
101
+ : undefined,
102
+ tagsAny: Array.isArray(filters.tagsAny)
103
+ ? (filters.tagsAny as string[])
104
+ : undefined,
105
+ since: typeof filters.since === "string" ? filters.since : undefined,
106
+ until: typeof filters.until === "string" ? filters.until : undefined,
107
+ categories: Array.isArray(filters.categories)
108
+ ? (filters.categories as string[])
109
+ : undefined,
110
+ author: typeof filters.author === "string" ? filters.author : undefined,
111
+ intent: typeof filters.intent === "string" ? filters.intent : undefined,
112
+ exclude: Array.isArray(filters.exclude)
113
+ ? (filters.exclude as string[])
114
+ : undefined,
115
+ graph: typeof filters.graph === "boolean" ? filters.graph : undefined,
116
+ noGraph: typeof filters.noGraph === "boolean" ? filters.noGraph : undefined,
117
+ explain: typeof filters.explain === "boolean" ? filters.explain : undefined,
118
+ queryLanguageHint:
119
+ typeof filters.queryLanguageHint === "string"
120
+ ? filters.queryLanguageHint
121
+ : undefined,
122
+ candidateLimit:
123
+ candidate.candidateLimit ??
124
+ (typeof filters.candidateLimit === "number"
125
+ ? filters.candidateLimit
126
+ : undefined),
127
+ noExpand:
128
+ candidate.noExpand ??
129
+ (typeof filters.noExpand === "boolean" ? filters.noExpand : undefined),
130
+ noRerank:
131
+ candidate.noRerank ??
132
+ (typeof filters.noRerank === "boolean" ? filters.noRerank : undefined),
133
+ queryModes:
134
+ candidate.queryModes ??
135
+ (Array.isArray(filters.queryModes)
136
+ ? (filters.queryModes as RetrievalReplayCandidate["queryModes"])
137
+ : undefined),
138
+ };
139
+ };
140
+
141
+ const runCandidateOnce = async (
142
+ deps: RetrievalReplayDeps,
143
+ source: RetrievalQrelsCase,
144
+ candidate: RetrievalReplayCandidate,
145
+ options: HybridSearchOptions
146
+ ): Promise<StoreResult<SearchResults>> => {
147
+ if (candidate.type === "bm25") {
148
+ return searchBm25(deps.store, source.query.text, options);
149
+ }
150
+ if (candidate.type === "vector") {
151
+ if (!(deps.vectorIndex && deps.embedPort)) {
152
+ return err("QUERY_FAILED", "Candidate vector pipeline is unavailable");
153
+ }
154
+ return searchVector(
155
+ {
156
+ store: deps.store,
157
+ config: deps.config,
158
+ vectorIndex: deps.vectorIndex,
159
+ embedPort: deps.embedPort,
160
+ },
161
+ source.query.text,
162
+ options
163
+ );
164
+ }
165
+ return searchHybrid(deps, source.query.text, options);
166
+ };
167
+
168
+ interface ReplayScope {
169
+ collections: Array<string | undefined>;
170
+ uriPrefix: ReturnType<typeof parseUri>;
171
+ finalLimit: number;
172
+ fetchLimit: number;
173
+ }
174
+
175
+ const resolveReplayScope = (
176
+ source: RetrievalQrelsCase,
177
+ candidate: RetrievalReplayCandidate
178
+ ): StoreResult<ReplayScope> => {
179
+ const filters = source.query.filters;
180
+ const singular =
181
+ typeof filters.collection === "string" ? filters.collection : undefined;
182
+ const plural = Array.isArray(filters.collections)
183
+ ? filters.collections.filter(
184
+ (value): value is string => typeof value === "string"
185
+ )
186
+ : [];
187
+ const prefixValue =
188
+ typeof filters.uriPrefix === "string" ? filters.uriPrefix : undefined;
189
+ const uriPrefix = prefixValue ? parseUri(prefixValue) : null;
190
+ if (prefixValue && !uriPrefix) {
191
+ return err("INVALID_INPUT", "Persisted replay URI prefix is invalid");
192
+ }
193
+ if (singular && plural.length > 0 && !plural.includes(singular)) {
194
+ return err(
195
+ "INVALID_INPUT",
196
+ "Persisted singular and plural collection filters conflict"
197
+ );
198
+ }
199
+ let collections: Array<string | undefined> =
200
+ plural.length > 0
201
+ ? [...new Set(plural)].sort()
202
+ : singular
203
+ ? [singular]
204
+ : [undefined];
205
+ if (singular) collections = [singular];
206
+ if (uriPrefix) {
207
+ if (
208
+ collections[0] !== undefined &&
209
+ !collections.includes(uriPrefix.collection)
210
+ ) {
211
+ return err(
212
+ "INVALID_INPUT",
213
+ "Persisted URI prefix falls outside the collection scope"
214
+ );
215
+ }
216
+ collections = [uriPrefix.collection];
217
+ }
218
+ const finalLimit = retrievalReplayLimit(source, candidate);
219
+ const persistedCandidateLimit =
220
+ typeof filters.candidateLimit === "number"
221
+ ? filters.candidateLimit
222
+ : undefined;
223
+ const fetchLimit = uriPrefix
224
+ ? Math.max(
225
+ finalLimit,
226
+ candidate.candidateLimit ?? persistedCandidateLimit ?? 100
227
+ )
228
+ : finalLimit;
229
+ return ok({ collections, uriPrefix, finalLimit, fetchLimit });
230
+ };
231
+
232
+ const resultInPrefix = (
233
+ result: SearchResult,
234
+ prefix: NonNullable<ReplayScope["uriPrefix"]>
235
+ ): boolean => {
236
+ const parsed = parseUri(result.uri);
237
+ return Boolean(
238
+ parsed &&
239
+ parsed.collection === prefix.collection &&
240
+ (prefix.path === "" ||
241
+ parsed.path === prefix.path ||
242
+ parsed.path.startsWith(`${prefix.path}/`))
243
+ );
244
+ };
245
+
246
+ const compareReplayResults = (
247
+ left: SearchResult,
248
+ right: SearchResult
249
+ ): number =>
250
+ right.score - left.score ||
251
+ left.uri.localeCompare(right.uri) ||
252
+ (left.line ?? 0) - (right.line ?? 0) ||
253
+ left.docid.localeCompare(right.docid);
254
+
255
+ export const runRetrievalReplayCandidate = async (
256
+ deps: RetrievalReplayDeps,
257
+ source: RetrievalQrelsCase,
258
+ candidate: RetrievalReplayCandidate
259
+ ): Promise<StoreResult<SearchResults>> => {
260
+ const scope = resolveReplayScope(source, candidate);
261
+ if (!scope.ok) return scope;
262
+ let retrievalScope: HybridSearchOptions["retrievalScope"];
263
+ if (scope.value.uriPrefix) {
264
+ const documents = await deps.store.listDocuments(
265
+ scope.value.uriPrefix.collection
266
+ );
267
+ if (!documents.ok) return documents;
268
+ const prefixPath = scope.value.uriPrefix.path;
269
+ retrievalScope = {
270
+ ...(prefixPath === "" ? {} : { relPathPrefix: prefixPath }),
271
+ allowedMirrorHashes: [
272
+ ...new Set(
273
+ documents.value
274
+ .filter(
275
+ (document) =>
276
+ document.active &&
277
+ document.mirrorHash &&
278
+ (prefixPath === "" ||
279
+ document.relPath === prefixPath ||
280
+ document.relPath.startsWith(`${prefixPath}/`))
281
+ )
282
+ .map((document) => document.mirrorHash!)
283
+ ),
284
+ ].sort(),
285
+ };
286
+ }
287
+ const outputs: SearchResults[] = [];
288
+ for (const collection of scope.value.collections) {
289
+ const options = buildRetrievalReplaySearchOptions(
290
+ source,
291
+ candidate,
292
+ collection,
293
+ retrievalScope
294
+ );
295
+ options.limit = scope.value.fetchLimit;
296
+ const result = await runCandidateOnce(deps, source, candidate, options);
297
+ if (!result.ok) return result;
298
+ outputs.push(result.value);
299
+ }
300
+ const unique = new Map<string, SearchResult>();
301
+ for (const output of outputs) {
302
+ for (const result of output.results) {
303
+ if (
304
+ scope.value.uriPrefix &&
305
+ !resultInPrefix(result, scope.value.uriPrefix)
306
+ ) {
307
+ continue;
308
+ }
309
+ const key = `${result.docid}\0${result.conversion?.mirrorHash ?? ""}\0${
310
+ result.snippetRange?.startLine ?? result.line ?? 0
311
+ }\0${result.snippetRange?.endLine ?? result.line ?? 0}`;
312
+ const previous = unique.get(key);
313
+ if (!previous || compareReplayResults(result, previous) < 0) {
314
+ unique.set(key, result);
315
+ }
316
+ }
317
+ }
318
+ const results = [...unique.values()]
319
+ .sort(compareReplayResults)
320
+ .slice(0, scope.value.finalLimit);
321
+ const first = outputs[0];
322
+ if (!first) {
323
+ return err("QUERY_FAILED", "Candidate replay produced no search response");
324
+ }
325
+ const output: SearchResults = {
326
+ results,
327
+ meta: {
328
+ ...first.meta,
329
+ totalResults: results.length,
330
+ collection:
331
+ scope.value.collections.length === 1
332
+ ? scope.value.collections[0]
333
+ : undefined,
334
+ },
335
+ };
336
+ const capabilityOutcomes = outputs
337
+ .flatMap(
338
+ (item) => item[SEARCH_RESULTS_TRACE_METADATA]?.capabilityOutcomes ?? []
339
+ )
340
+ .filter(
341
+ (item, index, all) =>
342
+ all.findIndex(
343
+ (candidateItem) =>
344
+ candidateItem.capability === item.capability &&
345
+ candidateItem.status === item.status &&
346
+ candidateItem.reasonCode === item.reasonCode
347
+ ) === index
348
+ )
349
+ .sort((left, right) =>
350
+ `${left.capability}\0${left.status}\0${left.reasonCode ?? ""}`.localeCompare(
351
+ `${right.capability}\0${right.status}\0${right.reasonCode ?? ""}`
352
+ )
353
+ );
354
+ const fallbackCodes = [
355
+ ...new Set(
356
+ outputs.flatMap(
357
+ (item) => item[SEARCH_RESULTS_TRACE_METADATA]?.fallbackCodes ?? []
358
+ )
359
+ ),
360
+ ].sort();
361
+ if (capabilityOutcomes.length > 0 || fallbackCodes.length > 0) {
362
+ Object.defineProperty(output, SEARCH_RESULTS_TRACE_METADATA, {
363
+ enumerable: false,
364
+ value: { capabilityOutcomes, fallbackCodes },
365
+ });
366
+ }
367
+ return ok(output);
368
+ };
@@ -0,0 +1,109 @@
1
+ /** Public contracts for immutable retrieval replay reports. */
2
+
3
+ import type { RetrievalMetrics } from "../bench/metrics";
4
+ import type { RetrievalTraceFingerprints } from "../store/types";
5
+ import type { RetrievalQrelsCase, RetrievalQrel } from "./retrieval-qrels";
6
+
7
+ export type RetrievalReplayCandidateType = "bm25" | "vector" | "hybrid";
8
+
9
+ export interface RetrievalReplayCandidate {
10
+ id: string;
11
+ type: RetrievalReplayCandidateType;
12
+ limit?: number;
13
+ candidateLimit?: number;
14
+ noExpand?: boolean;
15
+ noRerank?: boolean;
16
+ queryModes?: Array<{
17
+ mode: "term" | "intent" | "hyde";
18
+ text: string;
19
+ }>;
20
+ }
21
+
22
+ export interface ReplayRetrievalTraceInput {
23
+ exportId: string;
24
+ candidate: RetrievalReplayCandidate;
25
+ }
26
+
27
+ export type RetrievalReplaySourceState =
28
+ | "unchanged"
29
+ | "stale"
30
+ | "missing"
31
+ | "inactive"
32
+ | "no_indexed_content";
33
+
34
+ export type RetrievalReplayVerdict =
35
+ | "improved"
36
+ | "unchanged"
37
+ | "regressed"
38
+ | "unreplayable";
39
+
40
+ export type RetrievalReplayReason =
41
+ | "manifest_missing"
42
+ | "manifest_hash_mismatch"
43
+ | "trace_missing"
44
+ | "redaction_incompatible"
45
+ | "query_missing"
46
+ | "filters_incomplete"
47
+ | "no_retrieval_run"
48
+ | "ambiguous_missing_expected_run"
49
+ | "source_stale"
50
+ | "source_missing"
51
+ | "candidate_failed";
52
+
53
+ export interface RetrievalReplayQrelResult {
54
+ qrelId: string;
55
+ label: RetrievalQrel["label"];
56
+ relevance: 0 | 1;
57
+ baselineRank: number | null;
58
+ plannerRank: number | null;
59
+ candidateRank: number | null;
60
+ rankDelta: number | null;
61
+ opened: boolean;
62
+ cited: boolean;
63
+ pinned: boolean;
64
+ sourceState: RetrievalReplaySourceState;
65
+ diagnostic: {
66
+ status: string;
67
+ filterReasons: string[];
68
+ stageDropReasons: string[];
69
+ } | null;
70
+ }
71
+
72
+ export interface RetrievalReplayCaseResult {
73
+ caseId: string;
74
+ traceId: string;
75
+ terminalStatus: RetrievalQrelsCase["terminalStatus"];
76
+ verdict: RetrievalReplayVerdict;
77
+ reason: RetrievalReplayReason | null;
78
+ metrics: {
79
+ baseline: RetrievalMetrics;
80
+ candidate: RetrievalMetrics;
81
+ baselineCoverage: number;
82
+ candidateCoverage: number;
83
+ };
84
+ fingerprints: {
85
+ original: RetrievalTraceFingerprints;
86
+ current: RetrievalTraceFingerprints;
87
+ candidate: RetrievalTraceFingerprints;
88
+ };
89
+ capabilityOutcomes: {
90
+ baseline: RetrievalQrelsCase["baseline"]["capabilityOutcomes"];
91
+ candidate: RetrievalQrelsCase["baseline"]["capabilityOutcomes"];
92
+ };
93
+ fallbackCodes: {
94
+ baseline: string[];
95
+ candidate: string[];
96
+ };
97
+ qrels: RetrievalReplayQrelResult[];
98
+ }
99
+
100
+ export interface ReplayRetrievalTraceResult {
101
+ schemaVersion: "1.0";
102
+ exportId: string;
103
+ candidate: RetrievalReplayCandidate;
104
+ verdict: RetrievalReplayVerdict;
105
+ reason: RetrievalReplayReason | null;
106
+ recommendation: "promote" | "keep_baseline" | "manual_review";
107
+ applied: false;
108
+ cases: RetrievalReplayCaseResult[];
109
+ }
@@ -0,0 +1,89 @@
1
+ /** Closed runtime validation for the shared retrieval replay boundary. */
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { StoreResult } from "../store/types";
6
+ import type { ReplayRetrievalTraceInput } from "./retrieval-replay-types";
7
+
8
+ import { err, ok } from "../store/types";
9
+
10
+ const MAX_REPLAY_RESULTS = 10_000;
11
+ const queryModeTextSchema = z
12
+ .string()
13
+ .trim()
14
+ .min(1)
15
+ .max(8192)
16
+ .refine((value) => !value.includes("\r"), {
17
+ message: "Replay query mode text must not contain carriage returns",
18
+ })
19
+ .transform((value) => value.normalize("NFC"))
20
+ .pipe(z.string().min(1).max(8192));
21
+ const queryModeSchema = z
22
+ .object({
23
+ mode: z.enum(["term", "intent", "hyde"]),
24
+ text: queryModeTextSchema,
25
+ })
26
+ .strict();
27
+ const candidateSchema = z
28
+ .object({
29
+ id: z.string().trim().min(1).max(128),
30
+ type: z.enum(["bm25", "vector", "hybrid"]),
31
+ limit: z
32
+ .number()
33
+ .finite()
34
+ .int()
35
+ .positive()
36
+ .max(MAX_REPLAY_RESULTS)
37
+ .optional(),
38
+ candidateLimit: z
39
+ .number()
40
+ .finite()
41
+ .int()
42
+ .positive()
43
+ .max(MAX_REPLAY_RESULTS)
44
+ .optional(),
45
+ noExpand: z.boolean().optional(),
46
+ noRerank: z.boolean().optional(),
47
+ queryModes: z
48
+ .array(queryModeSchema)
49
+ .max(100)
50
+ .superRefine((modes, context) => {
51
+ const seen = new Set<string>();
52
+ let hydeCount = 0;
53
+ for (const [index, mode] of modes.entries()) {
54
+ const key = `${mode.mode}\0${mode.text}`;
55
+ if (seen.has(key)) {
56
+ context.addIssue({
57
+ code: "custom",
58
+ path: [index],
59
+ message: "Replay queryModes must be unique",
60
+ });
61
+ }
62
+ seen.add(key);
63
+ if (mode.mode === "hyde") hydeCount += 1;
64
+ }
65
+ if (hydeCount > 1) {
66
+ context.addIssue({
67
+ code: "custom",
68
+ message: "Replay queryModes allow at most one hyde mode",
69
+ });
70
+ }
71
+ })
72
+ .optional(),
73
+ })
74
+ .strict();
75
+ const replayInputSchema = z
76
+ .object({
77
+ exportId: z.string().trim().min(1).max(128),
78
+ candidate: candidateSchema,
79
+ })
80
+ .strict();
81
+
82
+ export const parseReplayRetrievalTraceInput = (
83
+ input: unknown
84
+ ): StoreResult<ReplayRetrievalTraceInput> => {
85
+ const parsed = replayInputSchema.safeParse(input);
86
+ return parsed.success
87
+ ? ok(parsed.data)
88
+ : err("INVALID_INPUT", "Invalid retrieval replay input", parsed.error);
89
+ };