@agentskit/doc-bridge 1.7.45 → 1.9.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/CHANGELOG.md +465 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15861 -6223
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +86 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
- package/dist/index.d.ts +9871 -1062
- package/dist/index.js +16126 -6125
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +12 -1
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +5 -1
- package/docs/agent-corpus/cli.md +16 -0
- package/docs/agent-corpus/conformance.md +12 -0
- package/docs/agent-corpus/doc-bridge.md +47 -0
- package/docs/agent-corpus/doctor.md +8 -0
- package/docs/agent-corpus/gates.md +4 -0
- package/docs/agent-corpus/mcp.md +13 -0
- package/docs/agent-corpus/memory.md +4 -0
- package/docs/agent-corpus/query.md +33 -0
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +38 -3
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +33 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +147 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +14 -0
- package/docs/verification-harness.md +11 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +25 -6
- package/scripts/check-ecosystem-upstream.mjs +34 -6
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/program.ts +684 -74
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/schema.ts +117 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +182 -0
- package/src/discovery/documentation.ts +211 -5
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +484 -101
- package/src/doctor/run-doctor.ts +244 -12
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +2 -4
- package/src/findings/report.ts +103 -0
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index.ts +630 -2
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +101 -10
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +2 -2
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +30 -10
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +14 -1
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +1 -4
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/scripts/verification-harness.mjs +0 -482
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { contentHashForArtifactV1 } from '../index-builder/content-hash.js'
|
|
4
|
+
import { RETRIEVAL_BENCH_SCHEMA_VERSION, type RetrievalBenchResultV1, type RetrievalMetrics } from './retrieval.js'
|
|
5
|
+
|
|
6
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/)
|
|
7
|
+
|
|
8
|
+
const BaselineMetricsSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
caseCount: z.number().int().nonnegative(),
|
|
11
|
+
hitAt1: z.number().min(0).max(1),
|
|
12
|
+
hitAt3: z.number().min(0).max(1),
|
|
13
|
+
meanReciprocalRank: z.number().min(0).max(1),
|
|
14
|
+
meanContextBytes: z.number().int().nonnegative(),
|
|
15
|
+
meanApproxTokens: z.number().int().nonnegative(),
|
|
16
|
+
zeroResultRate: z.number().min(0).max(1),
|
|
17
|
+
tokenMethod: z.literal('approximate'),
|
|
18
|
+
})
|
|
19
|
+
.strict()
|
|
20
|
+
|
|
21
|
+
export const RetrievalBaselineV1Schema = z
|
|
22
|
+
.object({
|
|
23
|
+
type: z.literal('retrieval-benchmark-baseline'),
|
|
24
|
+
schemaVersion: z.literal(RETRIEVAL_BENCH_SCHEMA_VERSION),
|
|
25
|
+
contentHash: hash,
|
|
26
|
+
contentHashAlgo: z.literal('sha256-normalized-v1'),
|
|
27
|
+
suite: z
|
|
28
|
+
.object({
|
|
29
|
+
name: z.string().min(1).max(128),
|
|
30
|
+
caseCount: z.number().int().positive(),
|
|
31
|
+
contentHash: hash,
|
|
32
|
+
})
|
|
33
|
+
.strict(),
|
|
34
|
+
metrics: BaselineMetricsSchema,
|
|
35
|
+
/**
|
|
36
|
+
* A baseline exists only as the result of an explicit human action. A normal
|
|
37
|
+
* `ak-docs bench retrieval` run compares against it and never writes it, so the number
|
|
38
|
+
* a gate defends cannot drift by accident.
|
|
39
|
+
*/
|
|
40
|
+
approval: z
|
|
41
|
+
.object({
|
|
42
|
+
approvedAt: z.string().datetime(),
|
|
43
|
+
approvedBy: z.string().min(1).max(256),
|
|
44
|
+
reason: z.string().min(1).max(1_024).optional(),
|
|
45
|
+
/** Result the approved figures were measured from, for provenance. */
|
|
46
|
+
resultHash: hash,
|
|
47
|
+
/** Index the approved figures were measured on. */
|
|
48
|
+
indexHash: hash,
|
|
49
|
+
})
|
|
50
|
+
.strict(),
|
|
51
|
+
})
|
|
52
|
+
.strict()
|
|
53
|
+
|
|
54
|
+
export type RetrievalBaselineV1 = z.infer<typeof RetrievalBaselineV1Schema>
|
|
55
|
+
|
|
56
|
+
export const parseRetrievalBaseline = (raw: unknown): RetrievalBaselineV1 => {
|
|
57
|
+
const baseline = RetrievalBaselineV1Schema.parse(raw)
|
|
58
|
+
if (contentHashForArtifactV1(baseline) !== baseline.contentHash) {
|
|
59
|
+
throw new Error('Invalid retrieval benchmark baseline content hash.')
|
|
60
|
+
}
|
|
61
|
+
return baseline
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CreateRetrievalBaselineOptions = {
|
|
65
|
+
readonly result: RetrievalBenchResultV1
|
|
66
|
+
readonly approvedBy: string
|
|
67
|
+
readonly reason?: string
|
|
68
|
+
readonly approvedAt?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Record a measured result as the figure future runs are gated against. */
|
|
72
|
+
export const createRetrievalBaseline = (options: CreateRetrievalBaselineOptions): RetrievalBaselineV1 => {
|
|
73
|
+
const approvedBy = options.approvedBy.trim()
|
|
74
|
+
if (!approvedBy) throw new Error('A retrieval baseline requires an approver. Pass --by <name>.')
|
|
75
|
+
const base = {
|
|
76
|
+
type: 'retrieval-benchmark-baseline' as const,
|
|
77
|
+
schemaVersion: RETRIEVAL_BENCH_SCHEMA_VERSION,
|
|
78
|
+
contentHash: '0'.repeat(64),
|
|
79
|
+
contentHashAlgo: 'sha256-normalized-v1' as const,
|
|
80
|
+
suite: options.result.suite,
|
|
81
|
+
metrics: options.result.metrics,
|
|
82
|
+
approval: {
|
|
83
|
+
approvedAt: options.approvedAt ?? new Date().toISOString(),
|
|
84
|
+
approvedBy,
|
|
85
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
86
|
+
resultHash: options.result.contentHash,
|
|
87
|
+
indexHash: options.result.index.contentHash,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
return RetrievalBaselineV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type RetrievalComparisonStatus = 'pass' | 'regressed' | 'suite-changed'
|
|
94
|
+
|
|
95
|
+
export type RetrievalMetricDelta = {
|
|
96
|
+
readonly metric: keyof RetrievalMetrics
|
|
97
|
+
readonly baseline: number
|
|
98
|
+
readonly current: number
|
|
99
|
+
readonly delta: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type RetrievalComparison = {
|
|
103
|
+
readonly status: RetrievalComparisonStatus
|
|
104
|
+
readonly blocking: boolean
|
|
105
|
+
readonly tolerance: number
|
|
106
|
+
readonly suiteChanged: boolean
|
|
107
|
+
/** Reasons the gate fails. Empty when it passes. */
|
|
108
|
+
readonly regressions: readonly string[]
|
|
109
|
+
/** Non-blocking movements worth reading before merging. */
|
|
110
|
+
readonly warnings: readonly string[]
|
|
111
|
+
readonly improvements: readonly string[]
|
|
112
|
+
readonly deltas: readonly RetrievalMetricDelta[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Metrics where a lower value is the improvement. */
|
|
116
|
+
const LOWER_IS_BETTER = new Set<keyof RetrievalMetrics>(['meanContextBytes', 'meanApproxTokens', 'zeroResultRate'])
|
|
117
|
+
/** The one metric whose regression blocks. Everything else is reported, not enforced. */
|
|
118
|
+
const BLOCKING_METRIC: keyof RetrievalMetrics = 'hitAt3'
|
|
119
|
+
const COMPARED: readonly (keyof RetrievalMetrics)[] = [
|
|
120
|
+
'hitAt1',
|
|
121
|
+
'hitAt3',
|
|
122
|
+
'meanReciprocalRank',
|
|
123
|
+
'meanContextBytes',
|
|
124
|
+
'meanApproxTokens',
|
|
125
|
+
'zeroResultRate',
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
const format = (metric: keyof RetrievalMetrics, value: number): string =>
|
|
129
|
+
metric === 'meanContextBytes' || metric === 'meanApproxTokens' ? String(value) : value.toFixed(3)
|
|
130
|
+
|
|
131
|
+
export type CompareRetrievalBaselineOptions = {
|
|
132
|
+
/** Slack allowed on the blocking metric before a drop counts as a regression. Default 0. */
|
|
133
|
+
readonly tolerance?: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compare a fresh result with an approved baseline.
|
|
138
|
+
*
|
|
139
|
+
* A changed suite fails the gate rather than being silently compared: the figures were
|
|
140
|
+
* measured over different questions, so neither a pass nor a regression would mean
|
|
141
|
+
* anything. Resolving it is an explicit baseline update.
|
|
142
|
+
*/
|
|
143
|
+
export const compareRetrievalBaseline = (
|
|
144
|
+
result: RetrievalBenchResultV1,
|
|
145
|
+
baseline: RetrievalBaselineV1,
|
|
146
|
+
options: CompareRetrievalBaselineOptions = {},
|
|
147
|
+
): RetrievalComparison => {
|
|
148
|
+
const tolerance = options.tolerance ?? 0
|
|
149
|
+
if (tolerance < 0) throw new Error('Retrieval baseline tolerance must not be negative.')
|
|
150
|
+
const suiteChanged = baseline.suite.contentHash !== result.suite.contentHash
|
|
151
|
+
|
|
152
|
+
const deltas = COMPARED.map((metric) => ({
|
|
153
|
+
metric,
|
|
154
|
+
baseline: baseline.metrics[metric] as number,
|
|
155
|
+
current: result.metrics[metric] as number,
|
|
156
|
+
delta: Number(((result.metrics[metric] as number) - (baseline.metrics[metric] as number)).toFixed(6)),
|
|
157
|
+
}))
|
|
158
|
+
|
|
159
|
+
const regressions: string[] = []
|
|
160
|
+
const warnings: string[] = []
|
|
161
|
+
const improvements: string[] = []
|
|
162
|
+
|
|
163
|
+
if (suiteChanged) {
|
|
164
|
+
regressions.push(
|
|
165
|
+
`Suite "${result.suite.name}" changed since the baseline was approved (${baseline.suite.caseCount} case(s) at ${baseline.suite.contentHash.slice(0, 12)}, now ${result.suite.caseCount} at ${result.suite.contentHash.slice(0, 12)}). Re-approve the baseline with --update-baseline --by <name>.`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const entry of deltas) {
|
|
170
|
+
const improved = LOWER_IS_BETTER.has(entry.metric) ? entry.delta < 0 : entry.delta > 0
|
|
171
|
+
const worsened = LOWER_IS_BETTER.has(entry.metric) ? entry.delta > 0 : entry.delta < 0
|
|
172
|
+
const message = `${entry.metric} ${format(entry.metric, entry.baseline)} → ${format(entry.metric, entry.current)}`
|
|
173
|
+
if (improved) improvements.push(message)
|
|
174
|
+
if (!worsened) continue
|
|
175
|
+
if (entry.metric === BLOCKING_METRIC && Math.abs(entry.delta) > tolerance) regressions.push(`${message} (tolerance ${tolerance})`)
|
|
176
|
+
else warnings.push(message)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const status: RetrievalComparisonStatus = suiteChanged ? 'suite-changed' : regressions.length ? 'regressed' : 'pass'
|
|
180
|
+
return {
|
|
181
|
+
status,
|
|
182
|
+
blocking: regressions.length > 0,
|
|
183
|
+
tolerance,
|
|
184
|
+
suiteChanged,
|
|
185
|
+
regressions,
|
|
186
|
+
warnings,
|
|
187
|
+
improvements,
|
|
188
|
+
deltas,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const formatRetrievalComparisonText = (comparison: RetrievalComparison): readonly string[] => [
|
|
193
|
+
`Baseline comparison: ${comparison.status}`,
|
|
194
|
+
...comparison.regressions.map((entry) => ` regression: ${entry}`),
|
|
195
|
+
...comparison.warnings.map((entry) => ` warning: ${entry}`),
|
|
196
|
+
...comparison.improvements.map((entry) => ` improved: ${entry}`),
|
|
197
|
+
...(comparison.regressions.length || comparison.warnings.length || comparison.improvements.length ? [] : [' no change']),
|
|
198
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
2
|
+
import { buildDocBridgeIndex } from '../index-builder/build-index.js'
|
|
3
|
+
import type { EnrichmentOverlayV1 } from '../schemas/enrichment.js'
|
|
4
|
+
import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
|
|
5
|
+
import { runRetrievalBench, type RetrievalMetrics, type RetrievalSuite } from './retrieval.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Does the overlay earn its cost?
|
|
9
|
+
*
|
|
10
|
+
* An enrichment stage that nobody measures is a stage nobody can defend. The question is narrow
|
|
11
|
+
* and answerable: run the golden suite twice over the same snapshot — once with the accepted
|
|
12
|
+
* overlay projected, once without it — and report the difference. Everything else about the
|
|
13
|
+
* agent is opinion; this is the number.
|
|
14
|
+
*
|
|
15
|
+
* The rule is asymmetric on purpose. An overlay may leave retrieval unchanged, and it may improve
|
|
16
|
+
* it, but it must not lower hit@3: aliases and summaries an agent proposed are supposed to help
|
|
17
|
+
* an agent find things. A drop is reported as a regression of the overlay, never as a new
|
|
18
|
+
* baseline — it is a finding about the agent, not about the benchmark.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The metrics compared, in the order a reader wants them. */
|
|
22
|
+
export const OVERLAY_DELTA_METRICS = ['hitAt1', 'hitAt3', 'meanReciprocalRank', 'meanContextBytes', 'meanApproxTokens', 'zeroResultRate'] as const
|
|
23
|
+
|
|
24
|
+
/** Metrics where a lower value is the improvement. */
|
|
25
|
+
const LOWER_IS_BETTER = new Set<keyof RetrievalMetrics>(['meanContextBytes', 'meanApproxTokens', 'zeroResultRate'])
|
|
26
|
+
|
|
27
|
+
/** The one metric an overlay is not allowed to lower. */
|
|
28
|
+
export const OVERLAY_BLOCKING_METRIC: keyof RetrievalMetrics = 'hitAt3'
|
|
29
|
+
|
|
30
|
+
export type OverlayMetricDelta = {
|
|
31
|
+
readonly metric: (typeof OVERLAY_DELTA_METRICS)[number]
|
|
32
|
+
readonly withoutOverlay: number
|
|
33
|
+
readonly withOverlay: number
|
|
34
|
+
readonly delta: number
|
|
35
|
+
readonly improved: boolean
|
|
36
|
+
readonly worsened: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type OverlayRetrievalDelta = {
|
|
40
|
+
readonly status: 'improved' | 'unchanged' | 'regressed'
|
|
41
|
+
/** True when the overlay lowered hit@3. The caller fails the run on this. */
|
|
42
|
+
readonly regression: boolean
|
|
43
|
+
readonly overlayHash: string
|
|
44
|
+
readonly suite: { readonly name: string; readonly caseCount: number; readonly contentHash: string }
|
|
45
|
+
readonly withoutOverlay: RetrievalMetrics
|
|
46
|
+
readonly withOverlay: RetrievalMetrics
|
|
47
|
+
readonly deltas: readonly OverlayMetricDelta[]
|
|
48
|
+
/** Case ids the overlay lost, and gained, at hit@3 — what a regression is actually made of. */
|
|
49
|
+
readonly lostCases: readonly string[]
|
|
50
|
+
readonly gainedCases: readonly string[]
|
|
51
|
+
readonly messages: readonly string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type MeasureOverlayRetrievalDeltaOptions = {
|
|
55
|
+
readonly root: string
|
|
56
|
+
readonly config: DocBridgeConfigV1
|
|
57
|
+
readonly snapshot: DiscoverySnapshotV1
|
|
58
|
+
readonly overlay: EnrichmentOverlayV1
|
|
59
|
+
readonly suite: RetrievalSuite
|
|
60
|
+
readonly limit?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const round = (value: number): number => Math.round(value * 1_000_000) / 1_000_000
|
|
64
|
+
|
|
65
|
+
const format = (metric: keyof RetrievalMetrics, value: number): string =>
|
|
66
|
+
metric === 'meanContextBytes' || metric === 'meanApproxTokens' ? String(value) : value.toFixed(3)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Measure the suite with and without the overlay.
|
|
70
|
+
*
|
|
71
|
+
* Both indexes are built from the one snapshot the caller passes, so the only difference between
|
|
72
|
+
* the two runs is the overlay — not a re-scan, not a different revision, not a different
|
|
73
|
+
* configuration.
|
|
74
|
+
*/
|
|
75
|
+
export const measureOverlayRetrievalDelta = (options: MeasureOverlayRetrievalDeltaOptions): OverlayRetrievalDelta => {
|
|
76
|
+
const { root, config, snapshot, overlay, suite } = options
|
|
77
|
+
const limit = options.limit === undefined ? {} : { limit: options.limit }
|
|
78
|
+
const baseline = runRetrievalBench({
|
|
79
|
+
index: buildDocBridgeIndex({ root, config, write: false, snapshot, overlay: 'ignore' }).index,
|
|
80
|
+
suite,
|
|
81
|
+
...limit,
|
|
82
|
+
})
|
|
83
|
+
const enriched = runRetrievalBench({
|
|
84
|
+
index: buildDocBridgeIndex({ root, config, write: false, snapshot, overlay }).index,
|
|
85
|
+
suite,
|
|
86
|
+
...limit,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const deltas = OVERLAY_DELTA_METRICS.map((metric): OverlayMetricDelta => {
|
|
90
|
+
const before = baseline.metrics[metric]
|
|
91
|
+
const after = enriched.metrics[metric]
|
|
92
|
+
const delta = round(after - before)
|
|
93
|
+
return {
|
|
94
|
+
metric,
|
|
95
|
+
withoutOverlay: before,
|
|
96
|
+
withOverlay: after,
|
|
97
|
+
delta,
|
|
98
|
+
improved: LOWER_IS_BETTER.has(metric) ? delta < 0 : delta > 0,
|
|
99
|
+
worsened: LOWER_IS_BETTER.has(metric) ? delta > 0 : delta < 0,
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const hitBefore = new Map(baseline.cases.map((entry) => [entry.id, entry.hitAt3]))
|
|
104
|
+
const lostCases = enriched.cases.filter((entry) => hitBefore.get(entry.id) === true && !entry.hitAt3).map((entry) => entry.id).sort()
|
|
105
|
+
const gainedCases = enriched.cases.filter((entry) => hitBefore.get(entry.id) === false && entry.hitAt3).map((entry) => entry.id).sort()
|
|
106
|
+
|
|
107
|
+
const blocking = deltas.find((entry) => entry.metric === OVERLAY_BLOCKING_METRIC)
|
|
108
|
+
const regression = blocking?.worsened === true
|
|
109
|
+
const status = regression ? 'regressed' : deltas.some((entry) => entry.improved) ? 'improved' : 'unchanged'
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
status,
|
|
113
|
+
regression,
|
|
114
|
+
overlayHash: overlay.contentHash,
|
|
115
|
+
suite: enriched.suite,
|
|
116
|
+
withoutOverlay: baseline.metrics,
|
|
117
|
+
withOverlay: enriched.metrics,
|
|
118
|
+
deltas,
|
|
119
|
+
lostCases,
|
|
120
|
+
gainedCases,
|
|
121
|
+
messages: [
|
|
122
|
+
...(regression
|
|
123
|
+
? [`The overlay lowered ${OVERLAY_BLOCKING_METRIC} from ${format(OVERLAY_BLOCKING_METRIC, blocking?.withoutOverlay ?? 0)} to ${format(OVERLAY_BLOCKING_METRIC, blocking?.withOverlay ?? 0)}. This is a finding about the agent, not a new baseline.`]
|
|
124
|
+
: []),
|
|
125
|
+
...(lostCases.length ? [`Cases the overlay lost: ${lostCases.join(', ')}.`] : []),
|
|
126
|
+
...(gainedCases.length ? [`Cases the overlay gained: ${gainedCases.join(', ')}.`] : []),
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const formatOverlayRetrievalDeltaText = (delta: OverlayRetrievalDelta): readonly string[] => [
|
|
132
|
+
`Overlay retrieval delta: ${delta.status} (overlay ${delta.overlayHash.slice(0, 12)}, suite ${delta.suite.name}, ${delta.suite.caseCount} case(s))`,
|
|
133
|
+
...delta.deltas.map((entry) => {
|
|
134
|
+
const sign = entry.delta > 0 ? '+' : ''
|
|
135
|
+
const mark = entry.improved ? 'improved' : entry.worsened ? 'worse' : 'no change'
|
|
136
|
+
return ` ${entry.metric}: ${format(entry.metric, entry.withoutOverlay)} → ${format(entry.metric, entry.withOverlay)} (${sign}${format(entry.metric, entry.delta)}, ${mark})`
|
|
137
|
+
}),
|
|
138
|
+
...delta.messages.map((message) => ` ${message}`),
|
|
139
|
+
]
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
4
|
+
import { searchIndex, type SearchMatch } from '../query/search.js'
|
|
5
|
+
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
6
|
+
|
|
7
|
+
export const RETRIEVAL_BENCH_SCHEMA_VERSION = 1 as const
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Open Eval Format version this suite conforms to (`@agentskit/core/eval-format`).
|
|
11
|
+
*
|
|
12
|
+
* The format is mirrored here with Zod rather than imported so `ak-docs bench retrieval`
|
|
13
|
+
* stays in the deterministic layer: `@agentskit/core` is an optional peer, and the
|
|
14
|
+
* benchmark must run with no peer installed, no network and no API key. A test
|
|
15
|
+
* cross-validates every committed suite against the real `validateEvalSuite`, so the
|
|
16
|
+
* two definitions cannot drift apart silently.
|
|
17
|
+
*/
|
|
18
|
+
export const EVAL_FORMAT_VERSION = '2026-04' as const
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMIT = 20
|
|
21
|
+
/** Payload depth an agent is assumed to read; also the k in hit@k. */
|
|
22
|
+
const CONTEXT_DEPTH = 3
|
|
23
|
+
/** Matches the existing agent-search estimate in src/query/query.ts. */
|
|
24
|
+
const BYTES_PER_TOKEN = 4
|
|
25
|
+
|
|
26
|
+
const EvalCaseExpectationSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
contains: z.string().min(1).max(2_048).optional(),
|
|
29
|
+
regex: z.object({ body: z.string().min(1).max(2_048), flags: z.string().max(8).optional() }).strict().optional(),
|
|
30
|
+
equalsNormalized: z.string().min(1).max(2_048).optional(),
|
|
31
|
+
semanticSimilarity: z.number().min(0).max(1).optional(),
|
|
32
|
+
})
|
|
33
|
+
.strict()
|
|
34
|
+
.refine(
|
|
35
|
+
(value) => Object.values(value).some((entry) => entry !== undefined),
|
|
36
|
+
'An expectation must declare at least one rule.',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A target matches a ranked result whose `id` **or** `path` equals it, so a case stays
|
|
41
|
+
* valid while entity identity evolves: `docs/mcp.md` keeps working when the index starts
|
|
42
|
+
* carrying `document:docs/mcp.md`, and a module target keeps working once modules are
|
|
43
|
+
* projected into the index.
|
|
44
|
+
*/
|
|
45
|
+
const RetrievalCaseMetadataSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
expectedTargets: z.array(z.string().min(1).max(512)).min(1).max(32),
|
|
48
|
+
lang: z.enum(['en', 'pt']).optional(),
|
|
49
|
+
kind: z.enum(['symbol', 'path', 'question', 'ownership']).optional(),
|
|
50
|
+
agent: z.boolean().optional(),
|
|
51
|
+
})
|
|
52
|
+
// The Open Eval Format allows arbitrary case metadata; keep unknown keys rather than
|
|
53
|
+
// rejecting a suite a generic runner wrote.
|
|
54
|
+
.passthrough()
|
|
55
|
+
|
|
56
|
+
export const RetrievalSuiteCaseSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
id: z.string().min(1).max(128),
|
|
59
|
+
input: z.string().min(1).max(2_048),
|
|
60
|
+
expected: z.union([EvalCaseExpectationSchema, z.string().min(1).max(2_048)]).optional(),
|
|
61
|
+
metadata: RetrievalCaseMetadataSchema,
|
|
62
|
+
})
|
|
63
|
+
.strict()
|
|
64
|
+
|
|
65
|
+
export const RetrievalSuiteSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
evalFormatVersion: z.literal(EVAL_FORMAT_VERSION),
|
|
68
|
+
name: z.string().min(1).max(128),
|
|
69
|
+
description: z.string().max(1_024).optional(),
|
|
70
|
+
tags: z.array(z.string().min(1).max(64)).max(32).optional(),
|
|
71
|
+
cases: z.array(RetrievalSuiteCaseSchema).min(1).max(10_000),
|
|
72
|
+
})
|
|
73
|
+
.strict()
|
|
74
|
+
.superRefine((value, context) => {
|
|
75
|
+
const seen = new Set<string>()
|
|
76
|
+
for (const [index, entry] of value.cases.entries()) {
|
|
77
|
+
if (seen.has(entry.id)) {
|
|
78
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ['cases', index, 'id'], message: `Duplicate case id: ${entry.id}` })
|
|
79
|
+
}
|
|
80
|
+
seen.add(entry.id)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export type RetrievalSuite = z.infer<typeof RetrievalSuiteSchema>
|
|
85
|
+
export type RetrievalSuiteCase = z.infer<typeof RetrievalSuiteCaseSchema>
|
|
86
|
+
|
|
87
|
+
const MetricsSchema = z
|
|
88
|
+
.object({
|
|
89
|
+
caseCount: z.number().int().nonnegative(),
|
|
90
|
+
hitAt1: z.number().min(0).max(1),
|
|
91
|
+
hitAt3: z.number().min(0).max(1),
|
|
92
|
+
meanReciprocalRank: z.number().min(0).max(1),
|
|
93
|
+
meanContextBytes: z.number().int().nonnegative(),
|
|
94
|
+
meanApproxTokens: z.number().int().nonnegative(),
|
|
95
|
+
zeroResultRate: z.number().min(0).max(1),
|
|
96
|
+
tokenMethod: z.literal('approximate'),
|
|
97
|
+
})
|
|
98
|
+
.strict()
|
|
99
|
+
|
|
100
|
+
export type RetrievalMetrics = z.infer<typeof MetricsSchema>
|
|
101
|
+
|
|
102
|
+
const CaseOutcomeSchema = z
|
|
103
|
+
.object({
|
|
104
|
+
id: z.string().min(1).max(128),
|
|
105
|
+
input: z.string().min(1).max(2_048),
|
|
106
|
+
lang: z.enum(['en', 'pt']).optional(),
|
|
107
|
+
kind: z.enum(['symbol', 'path', 'question', 'ownership']).optional(),
|
|
108
|
+
expectedTargets: z.array(z.string().min(1).max(512)).min(1),
|
|
109
|
+
rankedTargets: z.array(z.string().min(1).max(512)).max(64),
|
|
110
|
+
/** 1-based rank of the first expected target, or null when it is absent from the ranking. */
|
|
111
|
+
rank: z.number().int().positive().nullable(),
|
|
112
|
+
hitAt1: z.boolean(),
|
|
113
|
+
hitAt3: z.boolean(),
|
|
114
|
+
reciprocalRank: z.number().min(0).max(1),
|
|
115
|
+
resultCount: z.number().int().nonnegative(),
|
|
116
|
+
contextBytes: z.number().int().nonnegative(),
|
|
117
|
+
approxTokens: z.number().int().nonnegative(),
|
|
118
|
+
/** Result of the portable Open Eval Format expectation, when the case declares one. */
|
|
119
|
+
expectationMatched: z.boolean().nullable(),
|
|
120
|
+
})
|
|
121
|
+
.strict()
|
|
122
|
+
|
|
123
|
+
export type RetrievalCaseOutcome = z.infer<typeof CaseOutcomeSchema>
|
|
124
|
+
|
|
125
|
+
export const RetrievalBenchResultV1Schema = z
|
|
126
|
+
.object({
|
|
127
|
+
type: z.literal('retrieval-benchmark-result'),
|
|
128
|
+
schemaVersion: z.literal(RETRIEVAL_BENCH_SCHEMA_VERSION),
|
|
129
|
+
contentHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
130
|
+
contentHashAlgo: z.literal('sha256-normalized-v1'),
|
|
131
|
+
suite: z
|
|
132
|
+
.object({
|
|
133
|
+
name: z.string().min(1).max(128),
|
|
134
|
+
caseCount: z.number().int().positive(),
|
|
135
|
+
contentHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
136
|
+
})
|
|
137
|
+
.strict(),
|
|
138
|
+
index: z
|
|
139
|
+
.object({
|
|
140
|
+
contentHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
141
|
+
entryCount: z.number().int().nonnegative(),
|
|
142
|
+
})
|
|
143
|
+
.strict(),
|
|
144
|
+
limit: z.number().int().positive().max(1_000),
|
|
145
|
+
metrics: MetricsSchema,
|
|
146
|
+
byLang: z.record(z.string().min(1).max(16), MetricsSchema),
|
|
147
|
+
byKind: z.record(z.string().min(1).max(32), MetricsSchema),
|
|
148
|
+
cases: z.array(CaseOutcomeSchema).max(10_000),
|
|
149
|
+
})
|
|
150
|
+
.strict()
|
|
151
|
+
|
|
152
|
+
export type RetrievalBenchResultV1 = z.infer<typeof RetrievalBenchResultV1Schema>
|
|
153
|
+
|
|
154
|
+
export const parseRetrievalSuite = (raw: unknown): RetrievalSuite => RetrievalSuiteSchema.parse(raw)
|
|
155
|
+
|
|
156
|
+
export const parseRetrievalBenchResult = (raw: unknown): RetrievalBenchResultV1 => {
|
|
157
|
+
const result = RetrievalBenchResultV1Schema.parse(raw)
|
|
158
|
+
if (contentHashForArtifactV1(result) !== result.contentHash) {
|
|
159
|
+
throw new Error('Invalid retrieval benchmark result content hash.')
|
|
160
|
+
}
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Normalize a target or result value so `./docs/a.md`, `docs/a.md` and a Windows path compare equal. */
|
|
165
|
+
const normalizeTarget = (value: string): string => value.replaceAll('\\', '/').replace(/^\.\//, '').trim()
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The identities a ranked result can be addressed by. Both are compared so a suite written
|
|
169
|
+
* against repository paths keeps working once the index carries snapshot entity ids.
|
|
170
|
+
*/
|
|
171
|
+
const matchTargets = (match: SearchMatch): string[] =>
|
|
172
|
+
[...new Set([normalizeTarget(match.id), normalizeTarget(match.path)])].filter(Boolean)
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The bytes an agent would receive for the top results. Mirrors the `matches` entries of
|
|
176
|
+
* the agent-search payload in src/query/query.ts, so the figure tracks a real payload
|
|
177
|
+
* rather than an internal representation.
|
|
178
|
+
*/
|
|
179
|
+
const contextPayload = (matches: readonly SearchMatch[]): unknown =>
|
|
180
|
+
matches.map((match) => ({
|
|
181
|
+
type: match.type,
|
|
182
|
+
id: match.id,
|
|
183
|
+
path: match.path,
|
|
184
|
+
...(match.summary ? { summary: match.summary } : {}),
|
|
185
|
+
}))
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Portable Open Eval Format expectation, evaluated against the newline-joined ranking the
|
|
189
|
+
* deterministic agent function returns. Kept behaviour-compatible with
|
|
190
|
+
* `matchesExpectation` from `@agentskit/core/eval-format`.
|
|
191
|
+
*/
|
|
192
|
+
export const matchesRetrievalExpectation = (output: string, expected: RetrievalSuiteCase['expected']): boolean => {
|
|
193
|
+
if (expected === undefined) return true
|
|
194
|
+
if (typeof expected === 'string') return output.includes(expected)
|
|
195
|
+
if (expected.contains !== undefined && !output.includes(expected.contains)) return false
|
|
196
|
+
if (expected.equalsNormalized !== undefined && output.trim().toLowerCase() !== expected.equalsNormalized.trim().toLowerCase()) return false
|
|
197
|
+
if (expected.regex !== undefined && !new RegExp(expected.regex.body, expected.regex.flags).test(output)) return false
|
|
198
|
+
// semanticSimilarity needs an embedder; the deterministic benchmark never evaluates it.
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The deterministic agent function: a query in, the ranked identities out, one per line. */
|
|
203
|
+
export const rankedOutput = (matches: readonly SearchMatch[]): string =>
|
|
204
|
+
matches.map((match) => matchTargets(match).join(' ')).join('\n')
|
|
205
|
+
|
|
206
|
+
const mean = (values: readonly number[]): number => (values.length ? values.reduce((total, value) => total + value, 0) / values.length : 0)
|
|
207
|
+
const rate = (count: number, total: number): number => (total ? count / total : 0)
|
|
208
|
+
/** Six decimals keep the artifact hash stable across platforms without losing useful precision. */
|
|
209
|
+
const round = (value: number): number => Math.round(value * 1_000_000) / 1_000_000
|
|
210
|
+
|
|
211
|
+
const aggregate = (outcomes: readonly RetrievalCaseOutcome[]): RetrievalMetrics => ({
|
|
212
|
+
caseCount: outcomes.length,
|
|
213
|
+
hitAt1: round(rate(outcomes.filter((outcome) => outcome.hitAt1).length, outcomes.length)),
|
|
214
|
+
hitAt3: round(rate(outcomes.filter((outcome) => outcome.hitAt3).length, outcomes.length)),
|
|
215
|
+
meanReciprocalRank: round(mean(outcomes.map((outcome) => outcome.reciprocalRank))),
|
|
216
|
+
meanContextBytes: Math.round(mean(outcomes.map((outcome) => outcome.contextBytes))),
|
|
217
|
+
meanApproxTokens: Math.round(mean(outcomes.map((outcome) => outcome.approxTokens))),
|
|
218
|
+
zeroResultRate: round(rate(outcomes.filter((outcome) => outcome.resultCount === 0).length, outcomes.length)),
|
|
219
|
+
tokenMethod: 'approximate',
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const groupBy = (
|
|
223
|
+
outcomes: readonly RetrievalCaseOutcome[],
|
|
224
|
+
key: (outcome: RetrievalCaseOutcome) => string | undefined,
|
|
225
|
+
): Record<string, RetrievalMetrics> => {
|
|
226
|
+
const groups = new Map<string, RetrievalCaseOutcome[]>()
|
|
227
|
+
for (const outcome of outcomes) {
|
|
228
|
+
const value = key(outcome)
|
|
229
|
+
if (value === undefined) continue
|
|
230
|
+
const group = groups.get(value) ?? []
|
|
231
|
+
group.push(outcome)
|
|
232
|
+
groups.set(value, group)
|
|
233
|
+
}
|
|
234
|
+
return Object.fromEntries([...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([value, group]) => [value, aggregate(group)]))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type RunRetrievalBenchOptions = {
|
|
238
|
+
readonly index: DocBridgeIndexV1
|
|
239
|
+
readonly suite: RetrievalSuite
|
|
240
|
+
readonly limit?: number
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Measure the deterministic retrieval path against a golden suite.
|
|
245
|
+
*
|
|
246
|
+
* The result carries no timestamp and no latency, so two runs over the same index and
|
|
247
|
+
* suite are byte-identical and their content hashes match. Wall time belongs in the
|
|
248
|
+
* caller's output, never in a comparable artifact.
|
|
249
|
+
*/
|
|
250
|
+
export const runRetrievalBench = (options: RunRetrievalBenchOptions): RetrievalBenchResultV1 => {
|
|
251
|
+
const limit = options.limit ?? DEFAULT_LIMIT
|
|
252
|
+
const outcomes: RetrievalCaseOutcome[] = options.suite.cases.map((entry) => {
|
|
253
|
+
const matches = searchIndex(options.index, entry.input, limit)
|
|
254
|
+
const expectedTargets = entry.metadata.expectedTargets.map(normalizeTarget)
|
|
255
|
+
const expected = new Set(expectedTargets)
|
|
256
|
+
const rankedTargets = matches.map((match) => matchTargets(match).join(' '))
|
|
257
|
+
const position = matches.findIndex((match) => matchTargets(match).some((target) => expected.has(target)))
|
|
258
|
+
const rank = position >= 0 ? position + 1 : null
|
|
259
|
+
const contextBytes = Buffer.byteLength(JSON.stringify(contextPayload(matches.slice(0, CONTEXT_DEPTH))), 'utf8')
|
|
260
|
+
return CaseOutcomeSchema.parse({
|
|
261
|
+
id: entry.id,
|
|
262
|
+
input: entry.input,
|
|
263
|
+
...(entry.metadata.lang ? { lang: entry.metadata.lang } : {}),
|
|
264
|
+
...(entry.metadata.kind ? { kind: entry.metadata.kind } : {}),
|
|
265
|
+
expectedTargets,
|
|
266
|
+
rankedTargets: rankedTargets.slice(0, CONTEXT_DEPTH),
|
|
267
|
+
rank,
|
|
268
|
+
hitAt1: rank === 1,
|
|
269
|
+
hitAt3: rank !== null && rank <= CONTEXT_DEPTH,
|
|
270
|
+
reciprocalRank: round(rank === null ? 0 : 1 / rank),
|
|
271
|
+
resultCount: matches.length,
|
|
272
|
+
contextBytes,
|
|
273
|
+
approxTokens: Math.ceil(contextBytes / BYTES_PER_TOKEN),
|
|
274
|
+
// Evaluated over the same top-three window hit@3 uses, so the portable verdict a
|
|
275
|
+
// generic Open Eval Format runner computes agrees with the metric this repository gates.
|
|
276
|
+
expectationMatched:
|
|
277
|
+
entry.expected === undefined ? null : matchesRetrievalExpectation(rankedOutput(matches.slice(0, CONTEXT_DEPTH)), entry.expected),
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const base = {
|
|
282
|
+
type: 'retrieval-benchmark-result' as const,
|
|
283
|
+
schemaVersion: RETRIEVAL_BENCH_SCHEMA_VERSION,
|
|
284
|
+
contentHash: '0'.repeat(64),
|
|
285
|
+
contentHashAlgo: 'sha256-normalized-v1' as const,
|
|
286
|
+
suite: {
|
|
287
|
+
name: options.suite.name,
|
|
288
|
+
caseCount: options.suite.cases.length,
|
|
289
|
+
contentHash: sha256NormalizedV1(options.suite),
|
|
290
|
+
},
|
|
291
|
+
index: {
|
|
292
|
+
contentHash: options.index.contentHash,
|
|
293
|
+
entryCount: options.index.knowledge.length,
|
|
294
|
+
},
|
|
295
|
+
limit,
|
|
296
|
+
metrics: aggregate(outcomes),
|
|
297
|
+
byLang: groupBy(outcomes, (outcome) => outcome.lang),
|
|
298
|
+
byKind: groupBy(outcomes, (outcome) => outcome.kind),
|
|
299
|
+
cases: outcomes,
|
|
300
|
+
}
|
|
301
|
+
return RetrievalBenchResultV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const percent = (value: number): string => `${(value * 100).toFixed(1)}%`
|
|
305
|
+
|
|
306
|
+
export const formatRetrievalBenchText = (result: RetrievalBenchResultV1): readonly string[] => {
|
|
307
|
+
const misses = result.cases.filter((outcome) => !outcome.hitAt3)
|
|
308
|
+
return [
|
|
309
|
+
`Retrieval benchmark: ${result.suite.name}`,
|
|
310
|
+
`Cases: ${result.metrics.caseCount} | Index entries: ${result.index.entryCount}`,
|
|
311
|
+
`hit@1: ${percent(result.metrics.hitAt1)} | hit@3: ${percent(result.metrics.hitAt3)} | MRR: ${result.metrics.meanReciprocalRank.toFixed(3)}`,
|
|
312
|
+
`Zero results: ${percent(result.metrics.zeroResultRate)} | Mean context: ${result.metrics.meanContextBytes} bytes (~${result.metrics.meanApproxTokens} tokens, approximate)`,
|
|
313
|
+
...Object.entries(result.byLang).map(([lang, metrics]) => ` ${lang}: hit@3 ${percent(metrics.hitAt3)} over ${metrics.caseCount} case(s)`),
|
|
314
|
+
...Object.entries(result.byKind).map(([kind, metrics]) => ` ${kind}: hit@3 ${percent(metrics.hitAt3)} over ${metrics.caseCount} case(s)`),
|
|
315
|
+
...(misses.length
|
|
316
|
+
? [`Missed (${misses.length}):`, ...misses.slice(0, 10).map((outcome) => ` ${outcome.id}: "${outcome.input}" → ${outcome.rankedTargets[0] ?? '(no result)'}`)]
|
|
317
|
+
: ['Missed: none']),
|
|
318
|
+
]
|
|
319
|
+
}
|