@agentskit/doc-bridge 1.7.45 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +488 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15871 -6218
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +95 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
  15. package/dist/index.d.ts +9880 -1062
  16. package/dist/index.js +16143 -6127
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +157 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +34 -15
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +126 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +197 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +485 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. 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
+ }