@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.
- package/README.md +14 -7
- package/assets/skill/SKILL.md +54 -12
- package/assets/skill/mcp-reference.md +7 -2
- package/assets/skill/recipes/citation-and-provenance.md +32 -9
- package/package.json +2 -1
- package/spec/AGENTS.md +83 -0
- package/spec/CLAUDE.md +83 -0
- package/spec/bench-fixture.schema.json +137 -0
- package/spec/cli.md +2919 -0
- package/spec/db/schema.sql +442 -0
- package/spec/evals-agentic.md +592 -0
- package/spec/evals.md +1106 -0
- package/spec/mcp.md +2279 -0
- package/spec/output-schemas/activation-verification.schema.json +515 -0
- package/spec/output-schemas/ask.schema.json +564 -0
- package/spec/output-schemas/backlinks.schema.json +131 -0
- package/spec/output-schemas/bench-result.schema.json +120 -0
- package/spec/output-schemas/capture-receipt.schema.json +143 -0
- package/spec/output-schemas/claim-verification.schema.json +291 -0
- package/spec/output-schemas/collection-list.schema.json +45 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +726 -0
- package/spec/output-schemas/context-capsule-verification.schema.json +1338 -0
- package/spec/output-schemas/context-list.schema.json +21 -0
- package/spec/output-schemas/doctor.schema.json +313 -0
- package/spec/output-schemas/error.schema.json +30 -0
- package/spec/output-schemas/expansion.schema.json +37 -0
- package/spec/output-schemas/get.schema.json +140 -0
- package/spec/output-schemas/graph-query.schema.json +99 -0
- package/spec/output-schemas/graph.schema.json +371 -0
- package/spec/output-schemas/links-list.schema.json +186 -0
- package/spec/output-schemas/mcp-add-collection-result.schema.json +23 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +152 -0
- package/spec/output-schemas/mcp-http-error.schema.json +30 -0
- package/spec/output-schemas/mcp-job-list.schema.json +58 -0
- package/spec/output-schemas/mcp-job-status.schema.json +224 -0
- package/spec/output-schemas/mcp-remove-result.schema.json +39 -0
- package/spec/output-schemas/mcp-sync-result.schema.json +41 -0
- package/spec/output-schemas/mcp-tag-result.schema.json +33 -0
- package/spec/output-schemas/models-list.schema.json +93 -0
- package/spec/output-schemas/multi-get.schema.json +103 -0
- package/spec/output-schemas/process-status.schema.json +119 -0
- package/spec/output-schemas/query-diagnose.schema.json +123 -0
- package/spec/output-schemas/resident-status.schema.json +154 -0
- package/spec/output-schemas/retrieval-trace-common.schema.json +492 -0
- package/spec/output-schemas/retrieval-trace-delete.schema.json +16 -0
- package/spec/output-schemas/retrieval-trace-export.schema.json +61 -0
- package/spec/output-schemas/retrieval-trace-filters.schema.json +139 -0
- package/spec/output-schemas/retrieval-trace-judgment.schema.json +15 -0
- package/spec/output-schemas/retrieval-trace-list.schema.json +18 -0
- package/spec/output-schemas/retrieval-trace-payloads.schema.json +178 -0
- package/spec/output-schemas/retrieval-trace-purge.schema.json +31 -0
- package/spec/output-schemas/retrieval-trace-qrels.schema.json +303 -0
- package/spec/output-schemas/retrieval-trace-replay.schema.json +286 -0
- package/spec/output-schemas/retrieval-trace-show.schema.json +69 -0
- package/spec/output-schemas/retrieval-trace-summary.schema.json +65 -0
- package/spec/output-schemas/search-result.schema.json +154 -0
- package/spec/output-schemas/search-results.schema.json +338 -0
- package/spec/output-schemas/similar.schema.json +84 -0
- package/spec/output-schemas/status.schema.json +676 -0
- package/spec/output-schemas/tags-list.schema.json +48 -0
- package/src/app/context-runtime-contract.ts +10 -5
- package/src/app/context-runtime-input.ts +29 -1
- package/src/app/context-runtime-types.ts +7 -0
- package/src/app/context-runtime.ts +20 -2
- package/src/app/context-surface.ts +4 -0
- package/src/app/verified-ask.ts +291 -0
- package/src/cli/commands/ask-format.ts +255 -0
- package/src/cli/commands/ask.ts +144 -183
- package/src/cli/commands/context-build.ts +56 -9
- package/src/cli/commands/get.ts +64 -3
- package/src/cli/commands/query.ts +62 -23
- package/src/cli/commands/replay.ts +140 -0
- package/src/cli/commands/search.ts +48 -3
- package/src/cli/commands/shared.ts +3 -1
- package/src/cli/commands/trace.ts +200 -0
- package/src/cli/commands/vsearch.ts +75 -53
- package/src/cli/program.ts +287 -1
- package/src/config/index.ts +9 -0
- package/src/config/retrieval-traces.ts +56 -0
- package/src/config/types.ts +4 -0
- package/src/core/context-budget.ts +6 -0
- package/src/core/context-capsule-retrieval-schema.ts +4 -0
- package/src/core/context-capsule-schema.ts +17 -0
- package/src/core/context-capsule-validation.ts +3 -2
- package/src/core/context-capsule.ts +18 -0
- package/src/core/context-compiler.ts +44 -25
- package/src/core/context-evidence.ts +6 -0
- package/src/core/retrieval-qrels.ts +405 -0
- package/src/core/retrieval-replay-candidate.ts +368 -0
- package/src/core/retrieval-replay-types.ts +109 -0
- package/src/core/retrieval-replay-validation.ts +89 -0
- package/src/core/retrieval-replay.ts +441 -0
- package/src/core/retrieval-trace-evidence-origin.ts +178 -0
- package/src/core/retrieval-trace-export.ts +113 -0
- package/src/core/retrieval-trace-filter-normalization.ts +27 -0
- package/src/core/retrieval-trace-filters.ts +19 -0
- package/src/core/retrieval-trace-management-helpers.ts +247 -0
- package/src/core/retrieval-trace-management-types.ts +132 -0
- package/src/core/retrieval-trace-management.ts +422 -0
- package/src/core/retrieval-trace-request.ts +141 -0
- package/src/core/retrieval-trace-session.ts +507 -0
- package/src/core/retrieval-trace.ts +472 -0
- package/src/llm/errors.ts +10 -1
- package/src/llm/httpGeneration.ts +11 -1
- package/src/llm/nodeLlamaCpp/generation.ts +54 -10
- package/src/llm/types.ts +6 -0
- package/src/mcp/tools/ask.ts +228 -0
- package/src/mcp/tools/context.ts +87 -15
- package/src/mcp/tools/get.ts +35 -1
- package/src/mcp/tools/index.ts +83 -0
- package/src/mcp/tools/query.ts +95 -64
- package/src/mcp/tools/search.ts +36 -13
- package/src/mcp/tools/trace.ts +143 -0
- package/src/mcp/tools/vsearch.ts +71 -38
- package/src/pipeline/answer.ts +167 -26
- package/src/pipeline/claim-verification-schema.ts +235 -0
- package/src/pipeline/claim-verification.ts +487 -0
- package/src/pipeline/claim-verifier.ts +474 -0
- package/src/pipeline/graph-retrieval.ts +15 -1
- package/src/pipeline/hybrid.ts +151 -43
- package/src/pipeline/search.ts +36 -3
- package/src/pipeline/trace-metadata.ts +47 -0
- package/src/pipeline/types.ts +68 -0
- package/src/pipeline/vsearch.ts +101 -38
- package/src/sdk/client.ts +415 -73
- package/src/sdk/documents.ts +48 -1
- package/src/sdk/index.ts +17 -0
- package/src/sdk/types.ts +28 -0
- package/src/serve/context-capsule.ts +67 -8
- package/src/serve/public/app.tsx +12 -1
- package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/lib/workspace-tabs.ts +2 -0
- package/src/serve/public/pages/Ask.tsx +42 -4
- package/src/serve/public/pages/Dashboard.tsx +10 -0
- package/src/serve/public/pages/TraceHistory.tsx +478 -0
- package/src/serve/public/pages/trace-history-detail.tsx +224 -0
- package/src/serve/retrieval-trace.ts +28 -0
- package/src/serve/routes/api.ts +508 -68
- package/src/serve/routes/traces.ts +156 -0
- package/src/serve/server.ts +87 -2
- package/src/store/index.ts +31 -0
- package/src/store/migrations/014-retrieval-traces.ts +303 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/retrieval-trace-codec.ts +384 -0
- package/src/store/sqlite/adapter.ts +153 -1
- package/src/store/sqlite/retrieval-trace-management-store.ts +341 -0
- package/src/store/sqlite/retrieval-trace-retention.ts +349 -0
- package/src/store/sqlite/retrieval-trace-rows.ts +267 -0
- package/src/store/sqlite/retrieval-trace-store.ts +515 -0
- package/src/store/types.ts +297 -0
- package/src/store/vector/sqlite-vec.ts +76 -1
- 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
|
+
````
|