@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
package/spec/evals.md ADDED
@@ -0,0 +1,1106 @@
1
+ # Evals Specification (Evalite v1)
2
+
3
+ This document specifies the evaluation harness for GNO using Evalite v1.
4
+
5
+ ## Why Evals?
6
+
7
+ **Evals test stochastic LLM behavior, not deterministic code.**
8
+
9
+ Unit tests verify "given X, always return Y". Evals verify "given X, return something good enough" - because LLM outputs vary. We're measuring:
10
+
11
+ - Does retrieval find relevant docs most of the time?
12
+ - Does answer generation stay grounded in sources?
13
+ - Does quality improve with more compute (fast → thorough)?
14
+ - Does expansion produce valid structure despite LLM variance?
15
+
16
+ **Key insight**: Don't chase 100% determinism. Set thresholds (70%+), measure variance with `trialCount`, and accept that LLM-based features have inherent randomness. The goal is regression detection, not perfect reproducibility.
17
+
18
+ ## Overview
19
+
20
+ GNO uses Evalite for:
21
+
22
+ - **Ranking quality gates**: Validate `vsearch` and `query` return relevant results
23
+ - **Stability checks**: Ensure structured expansion outputs are schema-valid
24
+ - **Multilingual sanity**: Cross-language retrieval works (DE query → EN doc)
25
+
26
+ Agent-level evidence sufficiency, exact citation coverage, stopping behavior,
27
+ and Context Capsule promotion use the separate deterministic
28
+ [Agentic Retrieval Evaluation Contract](evals-agentic.md).
29
+
30
+ ## Dependencies
31
+
32
+ ```json
33
+ {
34
+ "devDependencies": {
35
+ "evalite": "^1.0.0-beta.15",
36
+ "vitest": "^4.0.0",
37
+ "@ai-sdk/openai": "^3.0.0"
38
+ }
39
+ }
40
+ ```
41
+
42
+ Note: `@ai-sdk/openai` is optional - only needed if using LLM-as-judge scorer.
43
+
44
+ ## File Structure
45
+
46
+ ```
47
+ evals/ # Top-level (not test/eval/)
48
+ vsearch.eval.ts # BM25 search ranking
49
+ query.eval.ts # Query pipeline + latency
50
+ expansion.eval.ts # Expansion schema validity
51
+ multilingual.eval.ts # Cross-language (BM25 baseline)
52
+ thoroughness.eval.ts # Fast/balanced/thorough comparison
53
+ ask.eval.ts # Answer quality by preset
54
+ helpers/retrieval-candidate-benchmark.ts # Candidate gen-model benchmark
55
+ helpers/retrieval-candidate-matrix.ts # Candidate matrix + cases
56
+ fixtures/retrieval-candidate-benchmark/ # Snapshot artifacts (json+md)
57
+ scorers/
58
+ ir-metrics.ts # recall@k, nDCG@k, latency scorers
59
+ expansion-validity.ts # AJV schema validation
60
+ answer-quality.ts # LLM-as-judge (requires OPENAI_API_KEY)
61
+ helpers/
62
+ setup-db.ts # Temp DB setup for eval corpus
63
+ fixtures/
64
+ corpus/
65
+ de/ # German test docs
66
+ en/ # English test docs
67
+ fr/ # French test docs
68
+ it/ # Italian test docs
69
+ queries.json # Query-judgment pairs (29 queries)
70
+ ask-cases.json # Ask questions with expected topics
71
+ evalite.config.ts # Global configuration
72
+ ```
73
+
74
+ **Note**: Evals are local-only (part of DoD/release process, not CI). Uses temp SQLite DB per run, isolated from global gno install.
75
+
76
+ ## Configuration
77
+
78
+ ### `evalite.config.ts`
79
+
80
+ ```ts
81
+ import { defineConfig } from "evalite/config";
82
+
83
+ export default defineConfig({
84
+ // In-memory storage (default, fast, ephemeral)
85
+ // For persistent history, see evalite docs
86
+
87
+ // Test execution
88
+ testTimeout: 120_000, // 2 min for embedding + rerank
89
+ maxConcurrency: 5, // Conservative for LLM calls
90
+
91
+ // Quality gate (MVP: 70%)
92
+ scoreThreshold: 70,
93
+
94
+ // Variance measurement (can override per-eval)
95
+ trialCount: 1,
96
+
97
+ // Cache LLM responses for fast iteration
98
+ cache: true,
99
+
100
+ // UI server port
101
+ server: { port: 3006 },
102
+ });
103
+ ```
104
+
105
+ ### `package.json` Scripts
106
+
107
+ ```json
108
+ {
109
+ "scripts": {
110
+ "eval": "bun --bun evalite",
111
+ "eval:watch": "bun --bun evalite watch",
112
+ "eval:agentic": "bun evals/agentic/cli.ts",
113
+ "eval:retrieval-candidates": "bun scripts/retrieval-candidate-benchmark.ts",
114
+ "eval:retrieval-candidates:write": "bun scripts/retrieval-candidate-benchmark.ts --write"
115
+ }
116
+ }
117
+ ```
118
+
119
+ **Note**: `--bun` flag required because evals use `bun:sqlite` via SqliteAdapter.
120
+
121
+ `eval:agentic` is the separate deterministic end-to-end agent benchmark. Its
122
+ default fixture lane runs all 24 tasks through `gno-mcp`, `lexical`, and the
123
+ eval-only `capsule` adapter in cold and warm lifecycles. It is opt-in, local,
124
+ and not an Evalite or release-CI command. See
125
+ [Agentic Retrieval Evaluation Contract](evals-agentic.md) for filters, exit
126
+ codes, report schemas, and baseline policy.
127
+
128
+ **CLI Modes:**
129
+
130
+ - `bun run eval` - Run once, exit
131
+ - `bun run eval:watch` - Auto-rerun on file changes
132
+ - `bun run eval:retrieval-candidates` - Manual full-path candidate benchmark
133
+ - `bun run eval:retrieval-candidates:write` - Same benchmark, plus snapshot artifacts
134
+
135
+ ## Manual Candidate Benchmarks
136
+
137
+ Not every retrieval-quality study should be an Evalite gate. For model-base comparisons, use a reproducible script plus committed artifacts when:
138
+
139
+ - the run is too expensive for the normal local eval loop
140
+ - you need raw per-model outputs, memory/load behavior, or smoke traces
141
+ - the question is comparative research ("should we change bases?"), not release gating
142
+
143
+ Current manual benchmark:
144
+
145
+ - `scripts/retrieval-candidate-benchmark.ts`
146
+ - uses the real hybrid path (`expandQuery` + BM25 + sqlite-vec + rerank)
147
+ - covers baseline, adversarial, multilingual, and ask-style retrieval cases
148
+ - writes artifacts to `evals/fixtures/retrieval-candidate-benchmark/`
149
+
150
+ ### CJK lexical degradation fixtures
151
+
152
+ The dedicated CJK lane uses the versioned fixture contract in
153
+ `evals/fixtures/cjk-lexical-benchmark/`:
154
+
155
+ - 21 original synthetic, MIT-licensed Markdown documents: seven each for
156
+ Chinese, Japanese, and Korean
157
+ - 25 same-language queries with separate graded qrels
158
+ - exact terms, identifiers, mixed scripts, token boundaries, punctuation,
159
+ content-level filenames, and intentional Unicode normalization variants
160
+ - simplified and traditional Chinese, Japanese kana and kanji, and composed
161
+ Hangul
162
+ - opaque corpus paths, SHA-256 provenance records, and query/path leakage
163
+ checks
164
+
165
+ Fixture validation is deterministic, offline, and part of the standard test
166
+ suite:
167
+
168
+ ```bash
169
+ bun test test/bench/cjk*.test.ts
170
+ bun run bench:cjk-lexical
171
+ ```
172
+
173
+ The committed [`2026-07-22.json`](../evals/fixtures/cjk-lexical-benchmark/2026-07-22.json)
174
+ and readable Markdown are immutable baseline evidence. The machine-readable
175
+ [`promotion-gates.json`](../evals/fixtures/cjk-lexical-benchmark/promotion-gates.json)
176
+ binds `fn-109` to these per-language floors:
177
+
178
+ | Language | Baseline Recall@10/nDCG@10 | Minimum candidate | Baseline zero-result | Maximum candidate |
179
+ | -------- | -------------------------: | ----------------: | -------------------: | ----------------: |
180
+ | `zh` | 0.2222 | 0.4722 | 0.7778 | 0.5278 |
181
+ | `ja` | 0.125 | 0.375 | 0.875 | 0.625 |
182
+ | `ko` | 0.5 | 0.75 | 0.5 | 0.25 |
183
+
184
+ The discrete Recall@10 floors require three additional Chinese hits and two
185
+ additional Japanese and Korean hits; the Chinese count rounds up because nine
186
+ queries cannot represent an exact `0.25` lift. MRR and nDCG@10 must
187
+ independently improve by `0.25`, adding a ranking-quality floor.
188
+ The same contract caps Latin/code Recall@10 and nDCG loss at `0.02`, permits
189
+ zero lost exact-identifier cases, and bounds index size/build/warm-query p95 by
190
+ co-run ratios. It does not select an implementation. All positive qrels are
191
+ currently relevance `3`, so nDCG cannot distinguish among positive gain grades.
192
+
193
+ The corpus is a controlled regression fixture, not evidence for broad CJK
194
+ language quality. Production tokenization and normalization remain unchanged.
195
+
196
+ ## Lexical Regression Matrix
197
+
198
+ Not every BM25 search regression belongs in broad hybrid/model benchmarks.
199
+ Protect lexical edge cases with focused deterministic tests.
200
+
201
+ Protected token classes:
202
+
203
+ - hyphen compounds:
204
+ - `real-time`
205
+ - `multi-agent`
206
+ - `gpt-4`
207
+ - digit-hyphen identifiers:
208
+ - `DEC-0054`
209
+ - underscore identifiers:
210
+ - `snake_case`
211
+ - ranking expectations:
212
+ - title vs body
213
+ - filepath vs body
214
+ - collection-filter stability
215
+ - malformed lexical input stability
216
+
217
+ Current homes for this matrix:
218
+
219
+ - `test/store/fts-lexical-regression.test.ts`
220
+ - `test/cli/search-fixtures.test.ts`
221
+
222
+ Rule:
223
+
224
+ - any BM25/parser fix for a lexical edge case should add or tighten a regression in this matrix before behavior changes land
225
+
226
+ ## Custom Scorers
227
+
228
+ Evalite doesn't include IR-specific scorers. Create them in `evals/scorers/ir-metrics.ts`:
229
+
230
+ ### Recall@K
231
+
232
+ ```ts
233
+ import { createScorer } from "evalite";
234
+
235
+ type RecallInput = { query: string; collection?: string };
236
+ type RecallOutput = string[]; // docids
237
+ type RecallExpected = string[]; // relevant docids
238
+
239
+ export const recallAtK = (k: number) =>
240
+ createScorer<RecallInput, RecallOutput, RecallExpected>({
241
+ name: `Recall@${k}`,
242
+ description: `Fraction of relevant docs retrieved in top ${k} results`,
243
+ scorer: ({ output, expected }) => {
244
+ if (!expected || expected.length === 0) {
245
+ return {
246
+ score: 1,
247
+ metadata: { k, hits: 0, total: 0, note: "no relevants" },
248
+ };
249
+ }
250
+ const topK = output.slice(0, k);
251
+ const hits = expected.filter((docid) => topK.includes(docid)).length;
252
+ return {
253
+ score: hits / expected.length,
254
+ metadata: { k, hits, total: expected.length },
255
+ };
256
+ },
257
+ });
258
+ ```
259
+
260
+ ### nDCG@K
261
+
262
+ ```ts
263
+ type NdcgInput = { query: string; collection?: string };
264
+ type NdcgOutput = string[];
265
+ type NdcgExpected = Array<{ docid: string; relevance: number }>;
266
+
267
+ export const ndcgAtK = (k: number) =>
268
+ createScorer<NdcgInput, NdcgOutput, NdcgExpected>({
269
+ name: `nDCG@${k}`,
270
+ description: `Normalized Discounted Cumulative Gain at rank ${k}`,
271
+ scorer: ({ output, expected }) => {
272
+ if (!expected || expected.length === 0) {
273
+ return {
274
+ score: 1,
275
+ metadata: { k, dcg: 0, idcg: 0, note: "no judgments" },
276
+ };
277
+ }
278
+
279
+ const relevanceMap = new Map(expected.map((e) => [e.docid, e.relevance]));
280
+
281
+ // DCG for actual ranking
282
+ const dcg = output.slice(0, k).reduce((sum, docid, i) => {
283
+ const rel = relevanceMap.get(docid) ?? 0;
284
+ return sum + (Math.pow(2, rel) - 1) / Math.log2(i + 2);
285
+ }, 0);
286
+
287
+ // Ideal DCG (sorted by relevance)
288
+ const idcg = [...expected]
289
+ .sort((a, b) => b.relevance - a.relevance)
290
+ .slice(0, k)
291
+ .reduce((sum, e, i) => {
292
+ return sum + (Math.pow(2, e.relevance) - 1) / Math.log2(i + 2);
293
+ }, 0);
294
+
295
+ return {
296
+ score: idcg > 0 ? dcg / idcg : 1,
297
+ metadata: { k, dcg: dcg.toFixed(4), idcg: idcg.toFixed(4) },
298
+ };
299
+ },
300
+ });
301
+ ```
302
+
303
+ ### Expansion Schema Validity
304
+
305
+ ```ts
306
+ import { createScorer } from "evalite";
307
+ import Ajv from "ajv";
308
+ import expansionSchema from "../../spec/output-schemas/expansion.schema.json";
309
+
310
+ const ajv = new Ajv();
311
+ const validate = ajv.compile(expansionSchema);
312
+
313
+ export const expansionSchemaValid = createScorer<string, unknown, undefined>({
314
+ name: "Expansion Schema Valid",
315
+ description: "Checks if expansion output matches JSON schema",
316
+ scorer: ({ output }) => {
317
+ const valid = validate(output);
318
+ return {
319
+ score: valid ? 1 : 0,
320
+ metadata: valid
321
+ ? { valid: true }
322
+ : { valid: false, errors: validate.errors },
323
+ };
324
+ },
325
+ });
326
+ ```
327
+
328
+ ### Latency Budget (Soft Gate)
329
+
330
+ ```ts
331
+ export const latencyBudget = (maxMs: number) =>
332
+ createScorer<unknown, { result: unknown; durationMs: number }, undefined>({
333
+ name: `Latency < ${maxMs}ms`,
334
+ description: `Checks if task completed within ${maxMs}ms budget`,
335
+ scorer: ({ output }) => {
336
+ const withinBudget = output.durationMs <= maxMs;
337
+ return {
338
+ score: withinBudget
339
+ ? 1
340
+ : Math.max(0, 1 - (output.durationMs - maxMs) / maxMs),
341
+ metadata: { durationMs: output.durationMs, maxMs, withinBudget },
342
+ };
343
+ },
344
+ });
345
+ ```
346
+
347
+ ## Test Data Format
348
+
349
+ ### `evals/fixtures/queries.json`
350
+
351
+ ```json
352
+ [
353
+ {
354
+ "id": "q1",
355
+ "query": "termination clause",
356
+ "collection": "contracts",
357
+ "language": "en",
358
+ "relevantDocs": ["#a1b2c3", "#d4e5f6"],
359
+ "judgments": [
360
+ { "docid": "#a1b2c3", "relevance": 3 },
361
+ { "docid": "#d4e5f6", "relevance": 2 },
362
+ { "docid": "#g7h8i9", "relevance": 1 }
363
+ ]
364
+ },
365
+ {
366
+ "id": "q2",
367
+ "query": "Kündigungsklausel",
368
+ "collection": "contracts",
369
+ "language": "de",
370
+ "note": "German query over mixed DE/EN corpus",
371
+ "relevantDocs": ["#a1b2c3"],
372
+ "judgments": [{ "docid": "#a1b2c3", "relevance": 3 }]
373
+ }
374
+ ]
375
+ ```
376
+
377
+ ## Eval Dimensions
378
+
379
+ GNO has two configurable axes that affect search/answer quality:
380
+
381
+ ### Search Thoroughness
382
+
383
+ Controls the search pipeline depth via `--fast` / `--thorough` flags:
384
+
385
+ | Mode | Pipeline | Expected Latency | Quality |
386
+ | -------- | ----------------------------- | ---------------- | -------- |
387
+ | fast | BM25 only, no rerank | < 1s | Baseline |
388
+ | balanced | Hybrid + rerank, no expansion | < 3s | Better |
389
+ | thorough | Full pipeline + expansion | < 8s | Best |
390
+
391
+ **Eval strategy**: Run identical queries at all 3 levels, measure Recall@K and nDCG@K. Verify:
392
+
393
+ - `thorough` >= `balanced` >= `fast` for ranking quality
394
+ - Latency stays within budget per mode
395
+
396
+ ### Model Presets
397
+
398
+ Controls AI model quality for generation (answers) via `gno models use <preset>`:
399
+
400
+ | Preset | Gen Model | Size | Answer Quality |
401
+ | -------- | ---------- | ------ | --------------- |
402
+ | slim | Qwen3-1.7B | ~1GB | Default, fast |
403
+ | balanced | Qwen2.5-3B | ~2GB | Slightly larger |
404
+ | quality | Qwen3-4B | ~2.5GB | Best answers |
405
+
406
+ Note: Embedding and reranking models are identical across presets.
407
+
408
+ **Eval strategy**: Run identical questions through `gno ask` at each preset, use LLM-as-judge to score answer quality. Verify:
409
+
410
+ - `quality` >= `balanced` >= `slim` for answer relevance
411
+ - All presets produce factually grounded answers (no hallucination)
412
+
413
+ ## Eval Files
414
+
415
+ ### Vector Search Eval
416
+
417
+ ```ts
418
+ // evals/vsearch.eval.ts
419
+ import { evalite } from "evalite";
420
+ import { recallAtK, ndcgAtK } from "./scorers/ir-metrics";
421
+ import { vsearch } from "../../src/pipeline/vsearch";
422
+
423
+ interface QueryData {
424
+ id: string;
425
+ query: string;
426
+ collection?: string;
427
+ relevantDocs: string[];
428
+ judgments: Array<{ docid: string; relevance: number }>;
429
+ }
430
+
431
+ evalite("Vector Search Ranking", {
432
+ data: async () => {
433
+ const queries: QueryData[] = await Bun.file(
434
+ "evals/fixtures/queries.json"
435
+ ).json();
436
+ return queries.map((q) => ({
437
+ input: { query: q.query, collection: q.collection },
438
+ expected: {
439
+ relevantDocs: q.relevantDocs,
440
+ judgments: q.judgments,
441
+ },
442
+ }));
443
+ },
444
+
445
+ task: async (input) => {
446
+ const results = await vsearch(input.query, {
447
+ collection: input.collection,
448
+ limit: 10,
449
+ });
450
+ return results.map((r) => r.docid);
451
+ },
452
+
453
+ scorers: [
454
+ {
455
+ name: "Recall@5",
456
+ scorer: ({ output, expected }) =>
457
+ recallAtK(5).scorer({
458
+ input: {},
459
+ output,
460
+ expected: expected.relevantDocs,
461
+ }),
462
+ },
463
+ {
464
+ name: "Recall@10",
465
+ scorer: ({ output, expected }) =>
466
+ recallAtK(10).scorer({
467
+ input: {},
468
+ output,
469
+ expected: expected.relevantDocs,
470
+ }),
471
+ },
472
+ {
473
+ name: "nDCG@10",
474
+ scorer: ({ output, expected }) =>
475
+ ndcgAtK(10).scorer({ input: {}, output, expected: expected.judgments }),
476
+ },
477
+ ],
478
+
479
+ columns: ({ input, output }) => [
480
+ { label: "Query", value: input.query },
481
+ { label: "Top 3", value: output.slice(0, 3).join(", ") },
482
+ ],
483
+ });
484
+ ```
485
+
486
+ ### Hybrid Query Eval
487
+
488
+ ```ts
489
+ // evals/query.eval.ts
490
+ import { evalite } from "evalite";
491
+ import { recallAtK, ndcgAtK, latencyBudget } from "./scorers/ir-metrics";
492
+ import { query } from "../../src/pipeline/query";
493
+
494
+ evalite("Hybrid Query Pipeline", {
495
+ data: async () => {
496
+ const queries = await Bun.file("evals/fixtures/queries.json").json();
497
+ return queries.map((q) => ({
498
+ input: { query: q.query, collection: q.collection },
499
+ expected: {
500
+ relevantDocs: q.relevantDocs,
501
+ judgments: q.judgments,
502
+ },
503
+ }));
504
+ },
505
+
506
+ task: async (input) => {
507
+ const start = performance.now();
508
+ const results = await query(input.query, {
509
+ collection: input.collection,
510
+ limit: 10,
511
+ });
512
+ const durationMs = performance.now() - start;
513
+
514
+ return {
515
+ docids: results.map((r) => r.docid),
516
+ durationMs,
517
+ };
518
+ },
519
+
520
+ scorers: [
521
+ {
522
+ name: "Recall@5",
523
+ scorer: ({ output, expected }) =>
524
+ recallAtK(5).scorer({
525
+ input: {},
526
+ output: output.docids,
527
+ expected: expected.relevantDocs,
528
+ }),
529
+ },
530
+ {
531
+ name: "nDCG@10",
532
+ scorer: ({ output, expected }) =>
533
+ ndcgAtK(10).scorer({
534
+ input: {},
535
+ output: output.docids,
536
+ expected: expected.judgments,
537
+ }),
538
+ },
539
+ {
540
+ name: "Latency < 2s",
541
+ scorer: ({ output }) =>
542
+ latencyBudget(2000).scorer({ input: {}, output, expected: undefined }),
543
+ },
544
+ ],
545
+
546
+ trialCount: 1, // deterministic for same model weights
547
+ });
548
+ ```
549
+
550
+ ### Expansion Stability Eval
551
+
552
+ ```ts
553
+ // evals/expansion.eval.ts
554
+ import { evalite } from "evalite";
555
+ import { expansionSchemaValid } from "./scorers/expansion-validity";
556
+ import { expandQuery } from "../../src/pipeline/expansion";
557
+
558
+ evalite("Structured Expansion Stability", {
559
+ data: async () => {
560
+ const queries = await Bun.file("evals/fixtures/queries.json").json();
561
+ return queries.map((q) => ({
562
+ input: q.query,
563
+ }));
564
+ },
565
+
566
+ task: async (input) => {
567
+ return await expandQuery(input);
568
+ },
569
+
570
+ scorers: [
571
+ {
572
+ name: "Schema Valid",
573
+ scorer: ({ output }) =>
574
+ expansionSchemaValid.scorer({ input: "", output, expected: undefined }),
575
+ },
576
+ {
577
+ name: "Has Lexical Variants",
578
+ scorer: ({ output }) => {
579
+ const hasLexical =
580
+ Array.isArray(output?.lexicalQueries) &&
581
+ output.lexicalQueries.length > 0;
582
+ return {
583
+ score: hasLexical ? 1 : 0,
584
+ metadata: { count: output?.lexicalQueries?.length ?? 0 },
585
+ };
586
+ },
587
+ },
588
+ {
589
+ name: "Has Vector Variants",
590
+ scorer: ({ output }) => {
591
+ const hasVector =
592
+ Array.isArray(output?.vectorQueries) &&
593
+ output.vectorQueries.length > 0;
594
+ return {
595
+ score: hasVector ? 1 : 0,
596
+ metadata: { count: output?.vectorQueries?.length ?? 0 },
597
+ };
598
+ },
599
+ },
600
+ ],
601
+
602
+ // Run 3 times to detect variance in LLM expansion
603
+ trialCount: 3,
604
+ });
605
+ ```
606
+
607
+ ### Multilingual Eval
608
+
609
+ ```ts
610
+ // evals/multilingual.eval.ts
611
+ import { evalite } from "evalite";
612
+ import { recallAtK } from "./scorers/ir-metrics";
613
+ import { query } from "../../src/pipeline/query";
614
+
615
+ // Cross-language test cases: query in one language, relevant docs in another
616
+ const multilingualCases = [
617
+ {
618
+ query: "Kündigungsklausel", // German
619
+ expectedLang: "de",
620
+ relevantDocs: ["#en-termination-1"], // English doc
621
+ note: "DE query should find EN termination clause doc via embeddings",
622
+ },
623
+ {
624
+ query: "termination clause", // English
625
+ expectedLang: "en",
626
+ relevantDocs: ["#de-kuendigung-1"], // German doc
627
+ note: "EN query should find DE Kündigung doc via embeddings",
628
+ },
629
+ ];
630
+
631
+ evalite("Multilingual Cross-Language Retrieval", {
632
+ data: () =>
633
+ multilingualCases.map((c) => ({
634
+ input: { query: c.query },
635
+ expected: c.relevantDocs,
636
+ })),
637
+
638
+ task: async (input) => {
639
+ const results = await query(input.query, { limit: 10 });
640
+ return results.map((r) => r.docid);
641
+ },
642
+
643
+ scorers: [
644
+ {
645
+ name: "Recall@5",
646
+ scorer: ({ output, expected }) =>
647
+ recallAtK(5).scorer({ input: {}, output, expected }),
648
+ },
649
+ ],
650
+
651
+ columns: ({ input, output }) => [
652
+ { label: "Query", value: input.query },
653
+ { label: "Found", value: output.slice(0, 3).join(", ") },
654
+ ],
655
+ });
656
+ ```
657
+
658
+ ### Thoroughness Comparison Eval
659
+
660
+ Tests the same queries at all thoroughness levels to verify quality ordering.
661
+
662
+ ```ts
663
+ // evals/thoroughness.eval.ts
664
+ import { evalite } from "evalite";
665
+ import { recallAtK, ndcgAtK, latencyBudget } from "./scorers/ir-metrics";
666
+ import { searchBm25 } from "../../src/pipeline/search";
667
+ import { searchHybrid } from "../../src/pipeline/query";
668
+
669
+ type ThoroughnessLevel = "fast" | "balanced" | "thorough";
670
+
671
+ const LATENCY_BUDGETS: Record<ThoroughnessLevel, number> = {
672
+ fast: 1000, // 1s
673
+ balanced: 3000, // 3s
674
+ thorough: 8000, // 8s
675
+ };
676
+
677
+ interface QueryCase {
678
+ query: string;
679
+ relevantDocs: string[];
680
+ judgments: Array<{ docid: string; relevance: number }>;
681
+ }
682
+
683
+ // Run each query at each thoroughness level
684
+ async function runAtThoroughness(
685
+ query: string,
686
+ level: ThoroughnessLevel
687
+ ): Promise<{ docids: string[]; durationMs: number }> {
688
+ const start = performance.now();
689
+ let results: Array<{ docid: string }>;
690
+
691
+ if (level === "fast") {
692
+ // BM25 only
693
+ results = await searchBm25(query, { limit: 10 });
694
+ } else {
695
+ // Hybrid with different options
696
+ results = await searchHybrid(query, {
697
+ limit: 10,
698
+ noExpand: level === "balanced", // balanced skips expansion
699
+ noRerank: false,
700
+ });
701
+ }
702
+
703
+ return {
704
+ docids: results.map((r) => r.docid),
705
+ durationMs: performance.now() - start,
706
+ };
707
+ }
708
+
709
+ evalite("Thoroughness Comparison", {
710
+ data: async () => {
711
+ const queries: QueryCase[] = await Bun.file(
712
+ "evals/fixtures/queries.json"
713
+ ).json();
714
+
715
+ // Create test cases for each query x thoroughness combination
716
+ const cases: Array<{
717
+ input: { query: string; level: ThoroughnessLevel };
718
+ expected: { relevantDocs: string[]; judgments: QueryCase["judgments"] };
719
+ }> = [];
720
+
721
+ for (const q of queries) {
722
+ for (const level of [
723
+ "fast",
724
+ "balanced",
725
+ "thorough",
726
+ ] as ThoroughnessLevel[]) {
727
+ cases.push({
728
+ input: { query: q.query, level },
729
+ expected: { relevantDocs: q.relevantDocs, judgments: q.judgments },
730
+ });
731
+ }
732
+ }
733
+ return cases;
734
+ },
735
+
736
+ task: async (input) => runAtThoroughness(input.query, input.level),
737
+
738
+ scorers: [
739
+ {
740
+ name: "Recall@5",
741
+ scorer: ({ output, expected }) =>
742
+ recallAtK(5).scorer({
743
+ input: {},
744
+ output: output.docids,
745
+ expected: expected.relevantDocs,
746
+ }),
747
+ },
748
+ {
749
+ name: "nDCG@10",
750
+ scorer: ({ output, expected }) =>
751
+ ndcgAtK(10).scorer({
752
+ input: {},
753
+ output: output.docids,
754
+ expected: expected.judgments,
755
+ }),
756
+ },
757
+ {
758
+ name: "Latency Budget",
759
+ scorer: ({ input, output }) => {
760
+ const budget = LATENCY_BUDGETS[input.level];
761
+ const withinBudget = output.durationMs <= budget;
762
+ return {
763
+ score: withinBudget
764
+ ? 1
765
+ : Math.max(0, 1 - (output.durationMs - budget) / budget),
766
+ metadata: {
767
+ level: input.level,
768
+ durationMs: output.durationMs,
769
+ budget,
770
+ withinBudget,
771
+ },
772
+ };
773
+ },
774
+ },
775
+ ],
776
+
777
+ columns: ({ input, output }) => [
778
+ { label: "Query", value: input.query.slice(0, 30) },
779
+ { label: "Level", value: input.level },
780
+ { label: "Time", value: `${output.durationMs.toFixed(0)}ms` },
781
+ ],
782
+ });
783
+ ```
784
+
785
+ ### Answer Quality Eval
786
+
787
+ Tests `gno ask` across model presets using LLM-as-judge for answer quality.
788
+
789
+ ```ts
790
+ // evals/scorers/answer-quality.ts
791
+ import { createScorer } from "evalite";
792
+ import { generateText } from "ai";
793
+ import { wrapAISDKModel } from "evalite/ai-sdk";
794
+ import { openai } from "@ai-sdk/openai";
795
+
796
+ // Use GPT-5-mini as judge (fast, cheap, good enough for eval)
797
+ const judge = wrapAISDKModel(openai("gpt-5-mini"));
798
+
799
+ interface AnswerJudgment {
800
+ relevance: number; // 0-1: Does answer address the question?
801
+ groundedness: number; // 0-1: Is answer supported by sources?
802
+ completeness: number; // 0-1: Does answer cover key points?
803
+ }
804
+
805
+ export const answerQuality = createScorer<
806
+ { question: string; sources: string[] },
807
+ { answer: string; citations: string[] },
808
+ { expectedTopics: string[] }
809
+ >({
810
+ name: "Answer Quality (LLM Judge)",
811
+ description:
812
+ "Uses LLM to judge answer relevance, groundedness, and completeness",
813
+ scorer: async ({ input, output, expected }) => {
814
+ const prompt = `You are evaluating an AI-generated answer for quality.
815
+
816
+ Question: ${input.question}
817
+
818
+ Sources provided:
819
+ ${input.sources.map((s, i) => `[${i + 1}] ${s.slice(0, 500)}...`).join("\n\n")}
820
+
821
+ Answer generated:
822
+ ${output.answer}
823
+
824
+ Expected topics to cover: ${expected?.expectedTopics?.join(", ") || "N/A"}
825
+
826
+ Rate the answer on three dimensions (0.0 to 1.0):
827
+ 1. RELEVANCE: Does the answer directly address the question?
828
+ 2. GROUNDEDNESS: Is the answer supported by the provided sources? (no hallucination)
829
+ 3. COMPLETENESS: Does the answer cover the key points?
830
+
831
+ Respond in JSON format:
832
+ {"relevance": 0.X, "groundedness": 0.X, "completeness": 0.X, "reasoning": "..."}`;
833
+
834
+ const result = await generateText({
835
+ model: judge,
836
+ prompt,
837
+ temperature: 0,
838
+ });
839
+
840
+ try {
841
+ const judgment: AnswerJudgment & { reasoning: string } = JSON.parse(
842
+ result.text
843
+ );
844
+ const avgScore =
845
+ (judgment.relevance + judgment.groundedness + judgment.completeness) /
846
+ 3;
847
+
848
+ return {
849
+ score: avgScore,
850
+ metadata: {
851
+ relevance: judgment.relevance,
852
+ groundedness: judgment.groundedness,
853
+ completeness: judgment.completeness,
854
+ reasoning: judgment.reasoning,
855
+ },
856
+ };
857
+ } catch {
858
+ return {
859
+ score: 0,
860
+ metadata: { error: "Failed to parse judge response" },
861
+ };
862
+ }
863
+ },
864
+ });
865
+ ```
866
+
867
+ ```ts
868
+ // evals/ask.eval.ts
869
+ import { evalite } from "evalite";
870
+ import { answerQuality } from "./scorers/answer-quality";
871
+ import { ask } from "../../src/pipeline/ask";
872
+ import { setActivePreset } from "../../src/config/models";
873
+
874
+ type PresetId = "slim" | "balanced" | "quality";
875
+
876
+ interface AskCase {
877
+ question: string;
878
+ expectedTopics: string[]; // Key topics the answer should mention
879
+ }
880
+
881
+ const ASK_CASES: AskCase[] = [
882
+ {
883
+ question: "What is the authentication strategy?",
884
+ expectedTopics: ["OAuth", "JWT", "session", "tokens"],
885
+ },
886
+ {
887
+ question: "How do I deploy to production?",
888
+ expectedTopics: ["build", "deploy", "environment", "CI/CD"],
889
+ },
890
+ // Add more cases from fixtures...
891
+ ];
892
+
893
+ evalite("Answer Quality by Preset", {
894
+ data: () => {
895
+ // Create test cases for each question x preset combination
896
+ const cases: Array<{
897
+ input: { question: string; preset: PresetId };
898
+ expected: { expectedTopics: string[] };
899
+ }> = [];
900
+
901
+ for (const c of ASK_CASES) {
902
+ for (const preset of ["slim", "balanced", "quality"] as PresetId[]) {
903
+ cases.push({
904
+ input: { question: c.question, preset },
905
+ expected: { expectedTopics: c.expectedTopics },
906
+ });
907
+ }
908
+ }
909
+ return cases;
910
+ },
911
+
912
+ task: async (input) => {
913
+ // Switch to the target preset
914
+ await setActivePreset(input.preset);
915
+
916
+ // Run ask pipeline
917
+ const result = await ask(input.question, { limit: 5 });
918
+
919
+ return {
920
+ answer: result.answer,
921
+ citations: result.citations.map((c) => c.docid),
922
+ sources: result.sources.map((s) => s.content),
923
+ };
924
+ },
925
+
926
+ scorers: [
927
+ {
928
+ name: "Answer Quality",
929
+ scorer: async ({ input, output, expected }) =>
930
+ answerQuality.scorer({
931
+ input: { question: input.question, sources: output.sources },
932
+ output: { answer: output.answer, citations: output.citations },
933
+ expected,
934
+ }),
935
+ },
936
+ {
937
+ name: "Has Citations",
938
+ scorer: ({ output }) => ({
939
+ score: output.citations.length > 0 ? 1 : 0,
940
+ metadata: { citationCount: output.citations.length },
941
+ }),
942
+ },
943
+ ],
944
+
945
+ columns: ({ input, output }) => [
946
+ { label: "Question", value: input.question.slice(0, 40) },
947
+ { label: "Preset", value: input.preset },
948
+ { label: "Citations", value: output.citations.length.toString() },
949
+ ],
950
+
951
+ // Run once per case (LLM judge is deterministic at temp=0)
952
+ trialCount: 1,
953
+ });
954
+ ```
955
+
956
+ ## Local Execution
957
+
958
+ Evals are run locally as part of the Definition of Done (DoD) and release process—not in CI. This keeps CI fast and avoids needing local LLM models on CI runners.
959
+
960
+ ### Running Evals
961
+
962
+ ```bash
963
+ # Run all evals once
964
+ bun run eval
965
+
966
+ # Watch mode (re-runs on file changes)
967
+ bun run eval:watch
968
+ ```
969
+
970
+ ### Threshold Strategy
971
+
972
+ | Phase | Threshold | Rationale |
973
+ | ----- | --------- | ------------------------------------ |
974
+ | MVP | 70% | Baseline, allow room for improvement |
975
+ | Beta | 80% | Tighten as quality stabilizes |
976
+ | GA | 90% | Production quality gate |
977
+
978
+ The default threshold is configured in `evalite.config.ts`.
979
+
980
+ ## Tracing
981
+
982
+ ### AI SDK (Automatic)
983
+
984
+ When using the AI SDK for LLM calls, wrap models for automatic tracing:
985
+
986
+ ```ts
987
+ import { openai } from "@ai-sdk/openai";
988
+ import { wrapAISDKModel } from "evalite/ai-sdk";
989
+ import { generateObject } from "ai";
990
+
991
+ const model = wrapAISDKModel(openai("gpt-5-mini"));
992
+
993
+ evalite("LLM Expansion", {
994
+ data: [{ input: "termination clause" }],
995
+ task: async (input) => {
996
+ const result = await generateObject({
997
+ model,
998
+ schema: expansionSchema,
999
+ prompt: `Expand this search query: ${input}`,
1000
+ });
1001
+ return result.object;
1002
+ },
1003
+ // ...
1004
+ });
1005
+ ```
1006
+
1007
+ Benefits:
1008
+
1009
+ - Automatic trace capture (prompts, responses, tokens)
1010
+ - Automatic caching of identical requests
1011
+ - Zero overhead in production (no-op outside Evalite)
1012
+
1013
+ ### Custom Steps (Manual)
1014
+
1015
+ For non-LLM pipeline steps (BM25 search, reranking), use `reportTrace()`:
1016
+
1017
+ ````ts
1018
+ import { reportTrace } from "evalite/traces";
1019
+
1020
+ task: async (input) => {
1021
+ // BM25 search step
1022
+ const start = performance.now();
1023
+ const bm25Results = await searchBm25(input.query, { limit: 50 });
1024
+ reportTrace({
1025
+ input: { query: input.query, limit: 50 },
1026
+ output: { count: bm25Results.length, topScore: bm25Results[0]?.score },
1027
+ start,
1028
+ end: performance.now(),
1029
+ });
1030
+
1031
+ // Reranking step
1032
+ const rerankStart = performance.now();
1033
+ const reranked = await rerank(input.query, bm25Results);
1034
+ reportTrace({
1035
+ input: { query: input.query, candidates: bm25Results.length },
1036
+ output: { reranked: reranked.length },
1037
+ start: rerankStart,
1038
+ end: performance.now(),
1039
+ });
1040
+
1041
+ return reranked.map(r => r.docid);
1042
+ }
1043
+ ```
1044
+
1045
+ ## Storage
1046
+
1047
+ ### Development (In-Memory)
1048
+
1049
+ Default behavior—no configuration needed:
1050
+
1051
+ - Fast iteration
1052
+ - Data lost on process exit
1053
+ - Good for quick experiments
1054
+
1055
+ ### Persistent (SQLite)
1056
+
1057
+ For tracking scores over time, see Evalite docs: https://evalite.dev
1058
+
1059
+ SQLite file would be gitignored; each developer has local history.
1060
+
1061
+ ### JSON Export
1062
+
1063
+ Export results for analysis:
1064
+
1065
+ ```bash
1066
+ bun --bun evalite --outputPath=./eval-results.json
1067
+ ```
1068
+
1069
+ JSON contains:
1070
+
1071
+ - Run metadata
1072
+ - All eval results with scores
1073
+ - Traces for debugging
1074
+
1075
+ ## Graph-Aware Retrieval Coverage
1076
+
1077
+ Opt-in bounded graph expansion in `gno_query` is covered by deterministic unit tests
1078
+ instead of the broad retrieval eval fixtures. The current eval corpus is focused
1079
+ on textual relevance judgments and does not carry stable wiki/markdown graph
1080
+ edges with confidence/audit metadata. Unit coverage asserts linked-neighbor
1081
+ recall, default-disabled fallback, no-embedding fallback, candidate caps, confidence
1082
+ ordering, and rerank interaction without LLM or fixture drift.
1083
+
1084
+ ## Acceptance Criteria
1085
+
1086
+ ### EPIC 11 Complete When:
1087
+
1088
+ 1. **T11.1 Corpus**: `evals/fixtures/` contains:
1089
+ - At least 20 queries with relevance judgments
1090
+ - At least 2 docs each in DE, EN, FR, IT
1091
+ - At least 3 cross-language query-doc pairs
1092
+ - At least 5 ask questions with expected topics
1093
+
1094
+ 2. **T11.2 Harness**: All eval files pass:
1095
+ - `vsearch.eval.ts` with Recall@5, Recall@10, nDCG@10
1096
+ - `query.eval.ts` with ranking + latency metrics
1097
+ - `expansion.eval.ts` with schema validity
1098
+ - `multilingual.eval.ts` with cross-language recall
1099
+ - `thoroughness.eval.ts` with fast/balanced/thorough comparison
1100
+ - `ask.eval.ts` with answer quality across presets (requires OPENAI_API_KEY)
1101
+
1102
+ 3. **T11.3 Documentation**:
1103
+ - CLAUDE.md updated with eval commands
1104
+ - CONTRIBUTING.md updated with DoD eval requirements
1105
+ - This spec (spec/evals.md) matches implementation
1106
+ ````