@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.
Files changed (213) hide show
  1. package/CHANGELOG.md +465 -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 +15861 -6223
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +86 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
  15. package/dist/index.d.ts +9871 -1062
  16. package/dist/index.js +16126 -6125
  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 +147 -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 +25 -6
  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 +117 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +182 -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 +484 -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,350 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { z } from 'zod'
4
+
5
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
6
+ import {
7
+ createControlledStudyLedger,
8
+ createControlledStudyObservation,
9
+ parseControlledStudyLedger,
10
+ persistControlledStudyLedger,
11
+ runControlledCommand,
12
+ upsertControlledStudyObservation,
13
+ type ControlledStudyObservationLedgerV1,
14
+ type ControlledStudyObservationV1,
15
+ type ControlledStudyRunPlanV1,
16
+ type TaskExecutionV1,
17
+ } from './runner.js'
18
+ import { providerForStudyExecution, parseStudyProviderCliConfig, validateStudyProviderCommand, type StudyProviderCliConfigV1 } from './provider-cli.js'
19
+ import { evaluateStudyTask, parseStudyTaskSuite, selectTaskExecutions, type StudyTaskSuiteV1, type StudyTaskV1 } from './task-suite.js'
20
+
21
+ const PROVIDER_RESPONSE_CONTRACT = 'Return one JSON object matching the output schema. Required keys: taskOutcome, evidenceQuality, safetyOutcome, evidenceIds, clarificationRequests, reworkCount, and measurements. Each measurement is {name:string,value:number>=0}. Run every available acceptance check and report observed acceptanceChecksPassed, acceptanceChecksTotal, and acceptanceChecksExecuted; include firstEvidenceLatencyMs only when observed. Use canonical names when observed: tokensToFirstEvidence (tokens consumed before correct grounded evidence was in hand), registryAgentInputTokens, registryAgentOutputTokens, registryAgentCostUsd, registryAgentRuns (the enrichment agent of the assisted arm, reported apart from your own cost), searchHitRate, acceptanceChecksPassed, acceptanceChecksTotal, acceptanceChecksExecuted, entrypointEvidenceCount, ownershipEvidenceCount, architectureRelationCount, documentationClaimEvidenceCount, sourceComparisonEvidenceCount, verificationEvidenceCount, errorRate, documentationFindingCount, documentationExampleRate, documentationFreshnessRate, documentationCorrectnessRate, documentationCompletenessRate, documentationClarityRate, documentationMaintainabilityRate, timeToFirstEvidenceMs, analysisCostUsd, and agentCostUsd. Omit unknown values; never invent. Output no markdown, prose, logs, token counts, or extra keys; stdout must contain only the JSON object.'
22
+
23
+ export const STUDY_REPOSITORY_CONFIG_SCHEMA_VERSION = 1 as const
24
+ export const STUDY_REPOSITORY_CONFIG_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
25
+
26
+ const hash = z.string().regex(/^[a-f0-9]{64}$/)
27
+ const identifier = z.string().regex(/^[a-z][a-z0-9-]{0,127}$/)
28
+ const reference = z.string().regex(/^[a-z0-9][a-z0-9._:-]{0,255}$/)
29
+
30
+ const RepositoryConfigPayloadSchema = z.object({
31
+ type: z.literal('controlled-study-repository-config'),
32
+ schemaVersion: z.literal(STUDY_REPOSITORY_CONFIG_SCHEMA_VERSION),
33
+ configVersion: reference,
34
+ repositories: z.array(z.object({
35
+ id: identifier,
36
+ root: z.string().min(1).max(4_096),
37
+ }).strict()).min(1).max(64),
38
+ }).strict()
39
+
40
+ export const StudyRepositoryConfigV1Schema = RepositoryConfigPayloadSchema.extend({
41
+ contentHash: hash,
42
+ contentHashAlgo: z.literal(STUDY_REPOSITORY_CONFIG_CONTENT_HASH_ALGO),
43
+ }).strict()
44
+
45
+ export type StudyRepositoryConfigV1 = z.infer<typeof StudyRepositoryConfigV1Schema>
46
+
47
+ export type ControlledStudyRunOptions = {
48
+ readonly plan: ControlledStudyRunPlanV1
49
+ readonly suite: StudyTaskSuiteV1
50
+ readonly providers: StudyProviderCliConfigV1
51
+ readonly repositories: StudyRepositoryConfigV1
52
+ readonly ledgerPath: string
53
+ readonly round?: string
54
+ readonly dryRun?: boolean
55
+ }
56
+
57
+ /**
58
+ * Whether the assisted arm can run at all, and why not when it cannot.
59
+ *
60
+ * The `registry-assisted` scenario has been reserved since the first suite and has never
61
+ * executed. It must be possible to run a study without it — a missing Registry is a fact about
62
+ * the environment, not a reason to lose the other two arms — so the arm reports itself
63
+ * unavailable and its executions are recorded as unavailable observations.
64
+ */
65
+ export type AssistedArmStatus = {
66
+ readonly status: 'ready' | 'unavailable'
67
+ readonly reason?: string
68
+ /**
69
+ * Fields the arm ran without: `promptVersion`, `agentBudget`. It still runs — losing the third
70
+ * arm over a missing declaration would be worse than running it — but a run that cannot name
71
+ * the prompt it used, or cost the enrichment agent apart from the model, says so here.
72
+ */
73
+ readonly undeclared?: readonly string[]
74
+ /** Executions recorded as unavailable because the arm could not run. */
75
+ readonly recorded: number
76
+ }
77
+
78
+ export type ControlledStudyRunSummary = {
79
+ readonly status: 'dry-run' | 'completed'
80
+ readonly runId: string
81
+ readonly planned: number
82
+ readonly executed: number
83
+ readonly skipped: number
84
+ readonly ledgerPath?: string
85
+ readonly ledgerHash?: string
86
+ readonly providerConfigHash: string
87
+ readonly repositoryConfigHash: string
88
+ readonly assistedArm: AssistedArmStatus
89
+ }
90
+
91
+ export const createStudyRepositoryConfig = (input: unknown): StudyRepositoryConfigV1 => {
92
+ const payload = RepositoryConfigPayloadSchema.parse(input)
93
+ const hashable = { ...payload, contentHashAlgo: STUDY_REPOSITORY_CONFIG_CONTENT_HASH_ALGO }
94
+ return StudyRepositoryConfigV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
95
+ }
96
+
97
+ export const parseStudyRepositoryConfig = (input: unknown): StudyRepositoryConfigV1 => {
98
+ const config = StudyRepositoryConfigV1Schema.parse(input)
99
+ if (contentHashForArtifactV1(config) !== config.contentHash) throw new Error('Invalid study repository config content hash.')
100
+ if (new Set(config.repositories.map((repository) => repository.id)).size !== config.repositories.length) throw new Error('Study repository config contains duplicate repository ids.')
101
+ return config
102
+ }
103
+
104
+ const emptyLedger = (): ControlledStudyObservationLedgerV1 => createControlledStudyLedger({
105
+ type: 'controlled-study-observation-ledger',
106
+ schemaVersion: 1,
107
+ ledgerVersion: 'v1',
108
+ observations: [],
109
+ })
110
+
111
+ const loadLedger = (path: string): ControlledStudyObservationLedgerV1 => {
112
+ if (!existsSync(path)) return emptyLedger()
113
+ return parseControlledStudyLedger(JSON.parse(readFileSync(path, 'utf8')) as unknown)
114
+ }
115
+
116
+ const executionKey = (execution: { readonly taskId: string; readonly repositoryId: string; readonly scenarioId: string; readonly modelId: string; readonly replicate: number; readonly variantId: string }): string => sha256NormalizedV1({
117
+ taskId: execution.taskId,
118
+ repositoryId: execution.repositoryId,
119
+ scenarioId: execution.scenarioId,
120
+ modelId: execution.modelId,
121
+ replicate: execution.replicate,
122
+ variantId: execution.variantId,
123
+ })
124
+
125
+ export const ASSISTED_SCENARIO = 'registry-assisted' as const
126
+
127
+ /**
128
+ * What the assisted arm needs before it is allowed to run: a provider for every model, and a
129
+ * scenario that declares the agent, its version, its prompt version and its own budget.
130
+ */
131
+ export const assistedArmReadiness = (
132
+ plan: ControlledStudyRunPlanV1,
133
+ providers: StudyProviderCliConfigV1,
134
+ suite: StudyTaskSuiteV1,
135
+ ): Omit<AssistedArmStatus, 'recorded'> => {
136
+ const scenario = plan.scenarios.find((entry) => entry.id === ASSISTED_SCENARIO)
137
+ if (!scenario) return { status: 'unavailable', reason: 'The run plan declares no registry-assisted scenario.' }
138
+ if (!scenario.agentId || !scenario.agentVersion) {
139
+ return { status: 'unavailable', reason: 'The registry-assisted scenario names no agent identity and version, so nothing it produced could be attributed.' }
140
+ }
141
+ const withoutProvider = suite.modelIds.filter((modelId) => !providers.providers.some((provider) => provider.modelId === modelId && provider.scenarioIds.includes(ASSISTED_SCENARIO)))
142
+ if (withoutProvider.length) {
143
+ return { status: 'unavailable', reason: `No provider CLI is configured for the registry-assisted scenario and model(s) ${withoutProvider.join(', ')}.` }
144
+ }
145
+ const undeclared = [...(scenario.promptVersion ? [] : ['promptVersion']), ...(scenario.agentBudget ? [] : ['agentBudget'])]
146
+ return {
147
+ status: 'ready',
148
+ ...(undeclared.length
149
+ ? {
150
+ undeclared,
151
+ reason: `The registry-assisted scenario declares no ${undeclared.join(' or ')}; the arm runs, but it cannot be ${undeclared.includes('promptVersion') ? 'reproduced' : 'costed'} from this ledger alone.`,
152
+ }
153
+ : {}),
154
+ }
155
+ }
156
+
157
+ /**
158
+ * The observation an unavailable arm leaves behind.
159
+ *
160
+ * Recorded rather than skipped: a scenario absent from the ledger is indistinguishable from one
161
+ * that was never planned, and the study's whole purpose is to compare the arms it planned.
162
+ */
163
+ const unavailableObservation = (
164
+ plan: ControlledStudyRunPlanV1,
165
+ execution: TaskExecutionV1,
166
+ reason: string,
167
+ round: string | undefined,
168
+ ): ControlledStudyObservationV1 => createControlledStudyObservation({
169
+ type: 'controlled-study-observation',
170
+ schemaVersion: 1,
171
+ observationVersion: 'v1',
172
+ observedAt: new Date().toISOString(),
173
+ runId: plan.runId,
174
+ planHash: plan.contentHash,
175
+ task: execution,
176
+ model: plan.models.find((model) => model.id === execution.modelId),
177
+ scenario: plan.scenarios.find((scenario) => scenario.id === execution.scenarioId),
178
+ execution: { status: 'unavailable', exitCode: null, signal: null, durationMs: 0, responseBytes: 0, stderrBytes: 0, errorCode: 'registry-unavailable' },
179
+ contextBytes: 0,
180
+ evidenceIds: [],
181
+ ...(round === undefined ? {} : { round }),
182
+ adjudication: { status: 'automated', actor: 'deterministic-rubric-v1', method: 'deterministic-rubric-v1', outcome: 'blocked', reason },
183
+ })
184
+
185
+ export const adjudicateControlledStudyObservation = (task: StudyTaskV1, observation: ControlledStudyObservationV1): ControlledStudyObservationV1 => {
186
+ const passed = observation.measurements?.acceptanceChecksPassed
187
+ const total = observation.measurements?.acceptanceChecksTotal
188
+ const executed = observation.measurements?.acceptanceChecksExecuted
189
+ const acceptanceTotal = task.acceptanceChecks.length
190
+ const blocked = observation.execution.status !== 'completed'
191
+ || passed === undefined
192
+ || total !== acceptanceTotal
193
+ || executed !== acceptanceTotal
194
+ || passed > executed
195
+ const evidenceIds = new Set(observation.evidenceIds)
196
+ const requiredEvidencePresent = task.evidenceRequirements.filter((requirement) => evidenceIds.has(requirement.id)).length
197
+ const evaluation = evaluateStudyTask(task, {
198
+ acceptanceChecksPassed: passed ?? 0,
199
+ evidenceItemsPresent: requiredEvidencePresent,
200
+ blocked,
201
+ })
202
+ const { contentHash: _contentHash, contentHashAlgo: _contentHashAlgo, ...payload } = observation
203
+ return createControlledStudyObservation({
204
+ ...payload,
205
+ adjudication: {
206
+ status: 'automated',
207
+ actor: 'deterministic-rubric-v1',
208
+ method: 'deterministic-rubric-v1',
209
+ outcome: evaluation.status,
210
+ reason: 'Independent bounded evaluation of execution status, acceptance metrics, and exact required evidence coverage.',
211
+ },
212
+ })
213
+ }
214
+
215
+ const assertRunInputs = (options: ControlledStudyRunOptions): Map<string, { readonly id: string; readonly root: string }> => {
216
+ if (options.plan.taskSuiteHash !== options.suite.contentHash) throw new Error('Run plan taskSuiteHash does not match the supplied task suite.')
217
+ if (options.plan.models.map((model) => model.id).sort().join(',') !== [...options.suite.modelIds].sort().join(',')) throw new Error('Run plan model ids do not match the task suite.')
218
+ if (options.plan.scenarios.map((scenario) => scenario.id).sort().join(',') !== [...options.suite.scenarioIds].sort().join(',')) throw new Error('Run plan scenario ids do not match the task suite.')
219
+ if (options.plan.taskIds.slice().sort().join(',') !== options.suite.tasks.map((task) => task.id).sort().join(',')) throw new Error('Run plan task ids do not match the task suite.')
220
+ const repositories = new Map(options.repositories.repositories.map((repository) => [repository.id, repository]))
221
+ if (repositories.size !== options.suite.population.length || options.suite.population.some((repositoryId) => !repositories.has(repositoryId))) {
222
+ throw new Error('Study repository config must contain exactly one root for every task-suite population id.')
223
+ }
224
+ for (const repository of options.repositories.repositories) {
225
+ const root = resolve(repository.root)
226
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`Study repository ${repository.id} is not available at the configured root.`)
227
+ }
228
+ const executions = selectTaskExecutions(options.suite, options.plan.sampling.sampleSize, options.plan.sampling)
229
+ const assisted = assistedArmReadiness(options.plan, options.providers, options.suite)
230
+ for (const execution of executions) {
231
+ const repository = repositories.get(execution.repositoryId)
232
+ if (!repository) throw new Error(`No repository root is configured for ${execution.repositoryId}.`)
233
+ // An unavailable assisted arm is recorded, not validated: the other two arms still run.
234
+ if (execution.scenarioId === ASSISTED_SCENARIO && assisted.status === 'unavailable') continue
235
+ const provider = providerForStudyExecution(options.providers, execution.modelId, execution.scenarioId as 'repository-only' | 'deterministic-doc-bridge' | 'registry-assisted')
236
+ validateStudyProviderCommand(provider, repository.root)
237
+ const task = options.suite.tasks.find((item) => item.id === execution.taskId)
238
+ if (!task) throw new Error(`Task ${execution.taskId} is not present in the task suite.`)
239
+ const input = JSON.stringify({
240
+ protocol: 'doc-bridge.study-provider.v1',
241
+ response: PROVIDER_RESPONSE_CONTRACT,
242
+ task: { ...execution, difficulty: task.difficulty },
243
+ objective: task.objective,
244
+ initialContext: task.initialContext,
245
+ expectedOutcome: task.expectedOutcome,
246
+ evidenceRequirements: task.evidenceRequirements,
247
+ acceptanceChecks: task.acceptanceChecks,
248
+ allowedTools: task.allowedTools,
249
+ forbiddenActions: task.forbiddenActions,
250
+ })
251
+ if (Buffer.byteLength(input, 'utf8') > provider.maxInputBytes) throw new Error(`Provider CLI input limit ${provider.maxInputBytes} bytes exceeded for ${execution.taskId}.`)
252
+ }
253
+ return repositories
254
+ }
255
+
256
+ export const runControlledStudy = async (options: ControlledStudyRunOptions): Promise<ControlledStudyRunSummary> => {
257
+ const plan = options.plan
258
+ const suite = parseStudyTaskSuite(options.suite)
259
+ const providers = parseStudyProviderCliConfig(options.providers)
260
+ const repositories = parseStudyRepositoryConfig(options.repositories)
261
+ const executions = selectTaskExecutions(suite, plan.sampling.sampleSize, plan.sampling)
262
+ const repositoryMap = assertRunInputs({ ...options, plan, suite, providers, repositories })
263
+ const assisted = assistedArmReadiness(plan, providers, suite)
264
+ if (options.dryRun) return {
265
+ status: 'dry-run',
266
+ runId: plan.runId,
267
+ planned: executions.length,
268
+ executed: 0,
269
+ skipped: 0,
270
+ providerConfigHash: providers.contentHash,
271
+ repositoryConfigHash: repositories.contentHash,
272
+ assistedArm: { ...assisted, recorded: 0 },
273
+ }
274
+
275
+ let ledger = loadLedger(resolve(options.ledgerPath))
276
+ if (ledger.observations.some((observation) => observation.runId === plan.runId && observation.planHash !== plan.contentHash)) throw new Error(`Ledger already contains run ${plan.runId} with a different plan hash.`)
277
+ let executed = 0
278
+ let skipped = 0
279
+ let unavailableRecorded = 0
280
+ for (const execution of executions) {
281
+ const existing = ledger.observations.find((observation) => observation.runId === plan.runId && executionKey(observation.task) === executionKey(execution))
282
+ if (existing) { skipped += 1; continue }
283
+ if (execution.scenarioId === ASSISTED_SCENARIO && assisted.status === 'unavailable') {
284
+ const typedUnavailable = { ...execution, difficulty: suite.tasks.find((item) => item.id === execution.taskId)?.difficulty, scenarioId: ASSISTED_SCENARIO } as TaskExecutionV1
285
+ ledger = upsertControlledStudyObservation(ledger, unavailableObservation(plan, typedUnavailable, assisted.reason ?? 'The registry-assisted arm is unavailable.', options.round))
286
+ persistControlledStudyLedger(options.ledgerPath, ledger)
287
+ unavailableRecorded += 1
288
+ continue
289
+ }
290
+ const repository = repositoryMap.get(execution.repositoryId)
291
+ if (!repository) throw new Error(`No repository root is configured for ${execution.repositoryId}.`)
292
+ const provider = providerForStudyExecution(providers, execution.modelId, execution.scenarioId as 'repository-only' | 'deterministic-doc-bridge' | 'registry-assisted')
293
+ const task = suite.tasks.find((item) => item.id === execution.taskId)
294
+ if (!task) throw new Error(`Task ${execution.taskId} is not present in the task suite.`)
295
+ const input = JSON.stringify({
296
+ protocol: 'doc-bridge.study-provider.v1',
297
+ response: PROVIDER_RESPONSE_CONTRACT,
298
+ task: { ...execution, difficulty: task.difficulty },
299
+ objective: task.objective,
300
+ initialContext: task.initialContext,
301
+ expectedOutcome: task.expectedOutcome,
302
+ evidenceRequirements: task.evidenceRequirements,
303
+ acceptanceChecks: task.acceptanceChecks,
304
+ allowedTools: task.allowedTools,
305
+ forbiddenActions: task.forbiddenActions,
306
+ })
307
+ const typedExecution = { ...execution, difficulty: task.difficulty, scenarioId: execution.scenarioId as 'repository-only' | 'deterministic-doc-bridge' | 'registry-assisted' }
308
+ const observation = await runControlledCommand({
309
+ plan,
310
+ execution: typedExecution,
311
+ command: provider.command,
312
+ args: provider.args,
313
+ cwd: repository.root,
314
+ input,
315
+ envAllowlist: provider.envAllowlist,
316
+ maxOutputBytes: Math.min(provider.maxOutputBytes, plan.budget.maxOutputBytes),
317
+ contextBytes: Buffer.byteLength(input, 'utf8'),
318
+ ...(provider.pricing === undefined ? {} : { providerPricing: provider.pricing }),
319
+ ...(options.round === undefined ? {} : { round: options.round }),
320
+ })
321
+ ledger = upsertControlledStudyObservation(ledger, adjudicateControlledStudyObservation(task, observation))
322
+ persistControlledStudyLedger(options.ledgerPath, ledger)
323
+ executed += 1
324
+ }
325
+ return {
326
+ status: 'completed',
327
+ runId: plan.runId,
328
+ planned: executions.length,
329
+ executed,
330
+ skipped,
331
+ ledgerPath: resolve(options.ledgerPath),
332
+ ledgerHash: ledger.contentHash,
333
+ providerConfigHash: providers.contentHash,
334
+ repositoryConfigHash: repositories.contentHash,
335
+ assistedArm: { ...assisted, recorded: unavailableRecorded },
336
+ }
337
+ }
338
+
339
+ export const formatControlledStudyRunText = (summary: ControlledStudyRunSummary): readonly string[] => [
340
+ `Study run: ${summary.runId}`,
341
+ `Status: ${summary.status}`,
342
+ `Planned: ${summary.planned}`,
343
+ `Executed: ${summary.executed}`,
344
+ `Skipped: ${summary.skipped}`,
345
+ ...(summary.ledgerPath === undefined ? [] : [`Ledger: ${summary.ledgerPath}`, `Ledger hash: ${summary.ledgerHash}`]),
346
+ `Provider config hash: ${summary.providerConfigHash}`,
347
+ `Repository config hash: ${summary.repositoryConfigHash}`,
348
+ `Assisted arm: ${summary.assistedArm.status}${summary.assistedArm.recorded ? ` (${summary.assistedArm.recorded} execution(s) recorded as unavailable)` : ''}${summary.assistedArm.undeclared?.length ? ` (undeclared: ${summary.assistedArm.undeclared.join(', ')})` : ''}`,
349
+ ...(summary.assistedArm.reason ? [` ${summary.assistedArm.reason}`] : []),
350
+ ]
@@ -0,0 +1,219 @@
1
+ import { z } from 'zod'
2
+
3
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
4
+ import { EVAL_FORMAT_VERSION, runRetrievalBench, type RetrievalBenchResultV1, type RetrievalSuite } from '../bench/retrieval.js'
5
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
6
+ import { hasRetrievalExpectations, taskRetrievalQueries, type StudyTaskSuiteV1, type StudyTaskV1 } from './task-suite.js'
7
+
8
+ /**
9
+ * The mechanical half of a study task, and why it lives in two files.
10
+ *
11
+ * The last controlled round recorded zero semantic successes in both arms, which means it could
12
+ * not tell a working retrieval layer from a broken one. The cause was structural: a task whose
13
+ * only success criterion is a model's opinion produces no signal. The fix is to state what
14
+ * retrieval is expected to return and check it — with `ak-docs bench retrieval`, deterministically.
15
+ *
16
+ * The expectations are split across two artifacts on purpose. The task suite is
17
+ * publication-bound, so it may only carry opaque references (`primary-entrypoint`), never a path.
18
+ * This file is the local resolution — references to concrete entity ids and document paths for a
19
+ * repository on the operator's disk — and it must never be published. Putting it under
20
+ * `docs/study/` would fail the privacy gate, which is the intended outcome rather than a bug.
21
+ */
22
+
23
+ export const STUDY_EXPECTATIONS_SCHEMA_VERSION = 1 as const
24
+ export const STUDY_EXPECTATIONS_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
25
+
26
+ const hash = z.string().regex(/^[a-f0-9]{64}$/)
27
+ const identifier = z.string().regex(/^[a-z][a-z0-9-]{0,127}$/)
28
+ const reference = z.string().regex(/^[a-z0-9][a-z0-9._:-]{0,255}$/)
29
+ const target = z.string().min(1).max(512)
30
+
31
+ const ExpectationsPayloadSchema = z.object({
32
+ type: z.literal('controlled-study-expectations'),
33
+ schemaVersion: z.literal(STUDY_EXPECTATIONS_SCHEMA_VERSION),
34
+ configVersion: reference,
35
+ /** Self-declared: this artifact resolves references to paths and is never publication-bound. */
36
+ scope: z.literal('local'),
37
+ /** The suite these references belong to. A suite that moved on invalidates the resolution. */
38
+ taskSuiteHash: hash,
39
+ repositories: z.array(z.object({
40
+ id: identifier,
41
+ /** Opaque reference → the entity ids or document paths it stands for in this repository. */
42
+ targets: z.record(reference, z.array(target).min(1).max(32)),
43
+ }).strict()).min(1).max(16),
44
+ }).strict()
45
+
46
+ export const StudyExpectationsV1Schema = ExpectationsPayloadSchema.extend({
47
+ contentHash: hash,
48
+ contentHashAlgo: z.literal(STUDY_EXPECTATIONS_CONTENT_HASH_ALGO),
49
+ }).strict()
50
+
51
+ export type StudyExpectationsV1 = z.infer<typeof StudyExpectationsV1Schema>
52
+
53
+ export const createStudyExpectations = (input: unknown): StudyExpectationsV1 => {
54
+ const payload = ExpectationsPayloadSchema.parse(input)
55
+ const hashable = { ...payload, contentHashAlgo: STUDY_EXPECTATIONS_CONTENT_HASH_ALGO }
56
+ return StudyExpectationsV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
57
+ }
58
+
59
+ export const parseStudyExpectations = (input: unknown): StudyExpectationsV1 => {
60
+ const expectations = StudyExpectationsV1Schema.parse(input)
61
+ if (contentHashForArtifactV1(expectations) !== expectations.contentHash) throw new Error('Invalid study expectations content hash.')
62
+ if (new Set(expectations.repositories.map((entry) => entry.id)).size !== expectations.repositories.length) {
63
+ throw new Error('Study expectations contain duplicate repository ids.')
64
+ }
65
+ return expectations
66
+ }
67
+
68
+ /** A reference a task expects that the local file does not resolve. Reported, never ignored. */
69
+ export type UnresolvedExpectation = {
70
+ readonly taskId: string
71
+ readonly repositoryId: string
72
+ readonly reference: string
73
+ readonly kind: 'entity' | 'document'
74
+ }
75
+
76
+ export type StudyRetrievalSuite = {
77
+ readonly suite: RetrievalSuite
78
+ readonly unresolved: readonly UnresolvedExpectation[]
79
+ /** Task ids that declare no expectations, so nothing about them is checked here. */
80
+ readonly withoutExpectations: readonly string[]
81
+ }
82
+
83
+ export type StudyRetrievalSuiteOptions = {
84
+ readonly taskSuite: StudyTaskSuiteV1
85
+ readonly expectations: StudyExpectationsV1
86
+ /** Check one repository instead of every one the expectations resolve. */
87
+ readonly repositoryId?: string
88
+ }
89
+
90
+ const caseId = (task: StudyTaskV1, index: number, total: number): string => (total === 1 ? task.id : `${task.id}-q${index + 1}`)
91
+
92
+ /**
93
+ * Turn the study's expectations into a retrieval suite.
94
+ *
95
+ * One case per task query, whose expected targets are the resolved references. The result is an
96
+ * ordinary Open Eval Format suite, so the same command, the same ranking and the same metrics
97
+ * that gate this repository's retrieval also answer the study's mechanical questions — rather
98
+ * than a second, study-shaped checker nobody else exercises.
99
+ */
100
+ export const studyRetrievalSuite = (options: StudyRetrievalSuiteOptions): StudyRetrievalSuite => {
101
+ const { taskSuite, expectations } = options
102
+ if (expectations.taskSuiteHash !== taskSuite.contentHash) {
103
+ throw new Error('Study expectations were written for a different task suite; re-resolve the references against this one.')
104
+ }
105
+ const byRepository = new Map(expectations.repositories.map((entry) => [entry.id, entry.targets]))
106
+ const unresolved: UnresolvedExpectation[] = []
107
+ const withoutExpectations: string[] = []
108
+ const cases: RetrievalSuite['cases'] = []
109
+
110
+ for (const task of taskSuite.tasks) {
111
+ if (options.repositoryId !== undefined && task.repositoryId !== options.repositoryId) continue
112
+ if (!hasRetrievalExpectations(task)) {
113
+ withoutExpectations.push(task.id)
114
+ continue
115
+ }
116
+ const targets = byRepository.get(task.repositoryId)
117
+ const resolve = (references: readonly string[], kind: UnresolvedExpectation['kind']): string[] =>
118
+ references.flatMap((item) => {
119
+ const resolved = targets?.[item]
120
+ if (!resolved?.length) {
121
+ unresolved.push({ taskId: task.id, repositoryId: task.repositoryId, reference: item, kind })
122
+ return []
123
+ }
124
+ return resolved
125
+ })
126
+ const expectedTargets = [
127
+ ...new Set([...resolve(task.expectedEntities ?? [], 'entity'), ...resolve(task.expectedDocuments ?? [], 'document')]),
128
+ ].sort()
129
+ if (!expectedTargets.length) continue
130
+ const queries = taskRetrievalQueries(task)
131
+ for (const [index, input] of queries.entries()) {
132
+ cases.push({
133
+ id: caseId(task, index, queries.length),
134
+ input,
135
+ metadata: { expectedTargets, kind: 'question', studyTaskId: task.id, studyRepositoryId: task.repositoryId, studyCategory: task.category },
136
+ })
137
+ }
138
+ }
139
+
140
+ return {
141
+ suite: {
142
+ evalFormatVersion: EVAL_FORMAT_VERSION,
143
+ name: `study-${taskSuite.suiteVersion}`,
144
+ description: 'Mechanical retrieval expectations resolved from the controlled study task suite.',
145
+ cases,
146
+ },
147
+ unresolved: [...unresolved].sort((a, b) => a.taskId.localeCompare(b.taskId) || a.reference.localeCompare(b.reference)),
148
+ withoutExpectations,
149
+ }
150
+ }
151
+
152
+ export type StudyExpectationOutcome = {
153
+ readonly taskId: string
154
+ readonly repositoryId: string
155
+ readonly caseId: string
156
+ readonly hit: boolean
157
+ readonly rank: number | null
158
+ readonly expectedTargets: readonly string[]
159
+ readonly rankedTargets: readonly string[]
160
+ }
161
+
162
+ export type StudyExpectationCheck = {
163
+ /** True when every case hit and every reference resolved: the mechanical verdict, with no model. */
164
+ readonly ok: boolean
165
+ readonly checkedTasks: number
166
+ readonly withoutExpectations: readonly string[]
167
+ readonly unresolved: readonly UnresolvedExpectation[]
168
+ readonly outcomes: readonly StudyExpectationOutcome[]
169
+ readonly result?: RetrievalBenchResultV1
170
+ }
171
+
172
+ export type CheckStudyExpectationsOptions = StudyRetrievalSuiteOptions & {
173
+ readonly index: DocBridgeIndexV1
174
+ readonly limit?: number
175
+ }
176
+
177
+ /**
178
+ * Run the study's mechanical expectations through the retrieval benchmark.
179
+ *
180
+ * A task with no expectations is reported as unchecked rather than counted as a pass, and an
181
+ * unresolved reference fails the check: an expectation nobody resolved is an expectation nobody
182
+ * tested, and calling that a success is exactly the failure this replaces.
183
+ */
184
+ export const checkStudyExpectations = (options: CheckStudyExpectationsOptions): StudyExpectationCheck => {
185
+ const { suite, unresolved, withoutExpectations } = studyRetrievalSuite(options)
186
+ if (!suite.cases.length) {
187
+ return { ok: false, checkedTasks: 0, withoutExpectations, unresolved, outcomes: [] }
188
+ }
189
+ const result = runRetrievalBench({ index: options.index, suite, ...(options.limit === undefined ? {} : { limit: options.limit }) })
190
+ const byCase = new Map(result.cases.map((entry) => [entry.id, entry]))
191
+ const outcomes = suite.cases.map((entry): StudyExpectationOutcome => {
192
+ const outcome = byCase.get(entry.id)
193
+ return {
194
+ taskId: String(entry.metadata.studyTaskId ?? entry.id),
195
+ repositoryId: String(entry.metadata.studyRepositoryId ?? ''),
196
+ caseId: entry.id,
197
+ hit: outcome?.hitAt3 ?? false,
198
+ rank: outcome?.rank ?? null,
199
+ expectedTargets: outcome?.expectedTargets ?? entry.metadata.expectedTargets,
200
+ rankedTargets: outcome?.rankedTargets ?? [],
201
+ }
202
+ })
203
+ return {
204
+ ok: unresolved.length === 0 && outcomes.every((outcome) => outcome.hit),
205
+ checkedTasks: new Set(outcomes.map((outcome) => outcome.taskId)).size,
206
+ withoutExpectations,
207
+ unresolved,
208
+ outcomes,
209
+ result,
210
+ }
211
+ }
212
+
213
+ export const formatStudyExpectationsText = (check: StudyExpectationCheck): readonly string[] => [
214
+ `Study expectations: ${check.ok ? 'pass' : 'fail'}`,
215
+ `Tasks checked: ${check.checkedTasks}${check.withoutExpectations.length ? ` (${check.withoutExpectations.length} without expectations: ${check.withoutExpectations.slice(0, 8).join(', ')}${check.withoutExpectations.length > 8 ? ', …' : ''})` : ''}`,
216
+ ...(check.result ? [`hit@3: ${(check.result.metrics.hitAt3 * 100).toFixed(1)}% over ${check.result.metrics.caseCount} case(s)`] : ['No case could be built: nothing was checked.']),
217
+ ...check.unresolved.map((entry) => ` unresolved ${entry.kind} reference "${entry.reference}" for ${entry.taskId} (${entry.repositoryId})`),
218
+ ...check.outcomes.filter((outcome) => !outcome.hit).map((outcome) => ` miss ${outcome.caseId}: expected ${outcome.expectedTargets.slice(0, 4).join(', ')}`),
219
+ ]