@agentskit/doc-bridge 1.7.44 → 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 (223) hide show
  1. package/CHANGELOG.md +471 -0
  2. package/CONTRIBUTING.md +29 -4
  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 +15888 -6061
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +91 -9
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/index-Beor6Yhi.d.ts +792 -0
  15. package/dist/index.d.ts +9979 -3257
  16. package/dist/index.js +15954 -5774
  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/RELEASE.md +22 -8
  22. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  23. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  24. package/docs/adr/0004-controlled-study-runner.md +25 -0
  25. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  26. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  27. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  28. package/docs/adr/0008-study-verification-boundary.md +21 -0
  29. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  30. package/docs/agent-corpus/INDEX.md +14 -3
  31. package/docs/agent-corpus/OVERVIEW.md +25 -0
  32. package/docs/agent-corpus/chat.md +7 -3
  33. package/docs/agent-corpus/cli.md +18 -2
  34. package/docs/agent-corpus/conformance.md +14 -2
  35. package/docs/agent-corpus/doc-bridge.md +48 -1
  36. package/docs/agent-corpus/doctor.md +10 -2
  37. package/docs/agent-corpus/gates.md +6 -2
  38. package/docs/agent-corpus/mcp.md +15 -2
  39. package/docs/agent-corpus/memory.md +6 -2
  40. package/docs/agent-corpus/query.md +35 -2
  41. package/docs/bench/README.md +122 -0
  42. package/docs/bench/retrieval-baseline-v1.json +28 -0
  43. package/docs/bench/retrieval-suite-v1.json +1033 -0
  44. package/docs/chat-and-rag.md +3 -2
  45. package/docs/for-agents.md +9 -1
  46. package/docs/getting-started.md +4 -11
  47. package/docs/guides/gate-ci.md +11 -1
  48. package/docs/guides/install-and-run.md +9 -65
  49. package/docs/index.md +22 -1
  50. package/docs/knowledge-engine-runbook.md +51 -4
  51. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  52. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  53. package/docs/landing/index.html +119 -5
  54. package/docs/loop-workflow.md +117 -0
  55. package/docs/mcp.md +6 -1
  56. package/docs/parity/public-claims-v1.json +145 -0
  57. package/docs/playbook/doc-bridge-pattern.md +1 -1
  58. package/docs/query.md +90 -2
  59. package/docs/recipes/index-pipeline.md +1 -1
  60. package/docs/schemas/agent-handoff-v1.md +15 -0
  61. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  62. package/docs/spec/benchmark-v1.md +39 -1
  63. package/docs/spec/cli.md +30 -10
  64. package/docs/spec/config-v1.md +192 -8
  65. package/docs/spec/documentation-audit-v1.md +61 -0
  66. package/docs/spec/enrichment-overlay-v1.md +241 -0
  67. package/docs/spec/graph-signals-v1.md +92 -0
  68. package/docs/spec/incremental-scan-v1.md +102 -0
  69. package/docs/spec/markdown-analyzer-v1.md +73 -0
  70. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  71. package/docs/spec/measured-enrichment-v1.md +229 -0
  72. package/docs/spec/public-parity-v1.md +119 -0
  73. package/docs/spec/registry-agents.md +6 -0
  74. package/docs/spec/render-v1.md +122 -0
  75. package/docs/spec/retrieval-index-v1.md +164 -0
  76. package/docs/spec/study-metrics-v1.md +58 -0
  77. package/docs/spec/study-protocol-v1.md +46 -0
  78. package/docs/spec/study-provider-cli-v1.md +116 -0
  79. package/docs/spec/study-runner-v1.md +35 -0
  80. package/docs/spec/study-task-suite-v1.md +41 -0
  81. package/docs/spec/study-verification-v1.md +40 -0
  82. package/docs/study/README.md +84 -0
  83. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  86. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  87. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  88. package/docs/study/ab-baseline-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  90. package/docs/study/ab-baseline-result-v1.json +79 -0
  91. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  92. package/docs/study/historical-evidence-v1.json +252 -0
  93. package/docs/study/observation-ledger-v1.json +30632 -0
  94. package/docs/study/phase3-task-coverage-v1.json +34 -0
  95. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  96. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  97. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  98. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  99. package/docs/study/pilot-round-2026-08-31.json +46 -0
  100. package/docs/study/protocol-v1.json +90 -0
  101. package/docs/study/publication-gate-v1.md +45 -0
  102. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  103. package/docs/study/quality-scorecard-v1.json +38 -0
  104. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  105. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  106. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  107. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  108. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  109. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  110. package/docs/study/round-4-confirmation-v1.json +75 -0
  111. package/docs/study/round-4-confirmation-v1.md +55 -0
  112. package/docs/study/run-plan-v1.json +33 -0
  113. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  114. package/docs/study/task-suite-v1.json +96 -0
  115. package/docs/study/token-efficiency-plan-v1.md +337 -0
  116. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  117. package/docs/study/verification-binding-v1.json +27 -0
  118. package/docs/validation-cycle-plan.md +33 -0
  119. package/docs/verification-harness.md +15 -6
  120. package/ecosystem-claims.json +2 -2
  121. package/ecosystem-upstream.json +2 -2
  122. package/ecosystem.json +4 -4
  123. package/mcpb/manifest.json +9 -1
  124. package/package.json +89 -72
  125. package/scripts/check-ecosystem-upstream.mjs +36 -7
  126. package/scripts/report-visual-check.mjs +20 -3
  127. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  128. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  129. package/src/agents/registry-adapter.ts +192 -24
  130. package/src/audit/documentation.ts +513 -0
  131. package/src/bench/baseline.ts +198 -0
  132. package/src/bench/overlay-delta.ts +139 -0
  133. package/src/bench/retrieval.ts +319 -0
  134. package/src/budget/compile.ts +91 -0
  135. package/src/budget/sections.ts +70 -0
  136. package/src/cli/demo.ts +2 -2
  137. package/src/cli/program.ts +699 -79
  138. package/src/cli/usage.ts +71 -0
  139. package/src/config/defaults.ts +1 -0
  140. package/src/config/index.ts +4 -0
  141. package/src/config/load-config.ts +7 -1
  142. package/src/config/schema.ts +121 -4
  143. package/src/conformance/documentation-standard-v1.ts +22 -14
  144. package/src/discovery/areas.ts +182 -0
  145. package/src/discovery/documentation.ts +255 -23
  146. package/src/discovery/identity.ts +24 -0
  147. package/src/discovery/incremental.ts +314 -0
  148. package/src/discovery/inputs.ts +110 -0
  149. package/src/discovery/markdown.ts +481 -0
  150. package/src/discovery/repository.ts +557 -125
  151. package/src/doctor/run-doctor.ts +246 -27
  152. package/src/enrich/approvals.ts +190 -0
  153. package/src/enrich/cache.ts +93 -0
  154. package/src/enrich/context-pack.ts +272 -0
  155. package/src/enrich/overlay.ts +255 -0
  156. package/src/enrich/review.ts +106 -0
  157. package/src/enrich/stage.ts +374 -0
  158. package/src/enrich/stats.ts +100 -0
  159. package/src/enrich/validate.ts +410 -0
  160. package/src/federation/llms.ts +74 -24
  161. package/src/findings/report.ts +103 -0
  162. package/src/fixes/proposals.ts +4 -3
  163. package/src/graph/build.ts +356 -0
  164. package/src/graph/memory.ts +208 -0
  165. package/src/index-builder/build-handoffs.ts +22 -11
  166. package/src/index-builder/build-index.ts +132 -3
  167. package/src/index-builder/human-adapters/fumadocs.ts +1 -1
  168. package/src/index-builder/llms-txt.ts +48 -8
  169. package/src/index-builder/project-corpus.ts +111 -0
  170. package/src/index-builder/watch-index.ts +1 -1
  171. package/src/index.ts +630 -2
  172. package/src/lib/bounded-text.ts +15 -10
  173. package/src/lib/fuzzy-match.ts +235 -0
  174. package/src/mcp/knowledge.ts +554 -0
  175. package/src/mcp/server.ts +113 -18
  176. package/src/metrics/benchmark.ts +21 -0
  177. package/src/parity/check.ts +309 -0
  178. package/src/parity/claims.ts +259 -0
  179. package/src/parity/resolve.ts +160 -0
  180. package/src/query/handoff.ts +326 -0
  181. package/src/query/load-index.ts +53 -1
  182. package/src/query/query.ts +92 -59
  183. package/src/query/search.ts +289 -92
  184. package/src/query/text.ts +155 -0
  185. package/src/reconciliation/reconcile.ts +148 -15
  186. package/src/render/data.ts +356 -0
  187. package/src/render/engine.ts +398 -0
  188. package/src/render/generated.ts +77 -0
  189. package/src/render/render.ts +209 -0
  190. package/src/render/template-source.ts +52 -0
  191. package/src/render/templates.ts +289 -0
  192. package/src/report/html.ts +23 -17
  193. package/src/retrieval/bm25.ts +161 -0
  194. package/src/retrieval/project.ts +495 -0
  195. package/src/retrieval/rank.ts +383 -0
  196. package/src/retrieval/weights.ts +39 -0
  197. package/src/retriever/doc-bridge-retriever.ts +100 -15
  198. package/src/rules/engine.ts +45 -12
  199. package/src/safety/repository.ts +1 -1
  200. package/src/schemas/agent-handoff.ts +56 -0
  201. package/src/schemas/budget.ts +37 -0
  202. package/src/schemas/doc-bridge-index.ts +53 -2
  203. package/src/schemas/enrichment.ts +369 -0
  204. package/src/schemas/json-schemas.ts +39 -2
  205. package/src/schemas/knowledge.ts +19 -3
  206. package/src/schemas/retrieval-index.ts +152 -0
  207. package/src/shims/graphology.d.ts +91 -0
  208. package/src/study/adjudication.ts +196 -0
  209. package/src/study/execution.ts +350 -0
  210. package/src/study/expectations.ts +219 -0
  211. package/src/study/metrics.ts +467 -0
  212. package/src/study/protocol.ts +271 -0
  213. package/src/study/provider-cli.ts +115 -0
  214. package/src/study/provider-telemetry.ts +47 -0
  215. package/src/study/quality-scorecard.ts +164 -0
  216. package/src/study/runner.ts +461 -0
  217. package/src/study/task-suite.ts +321 -0
  218. package/src/study/verification.ts +134 -0
  219. package/src/validate.ts +8 -5
  220. package/src/version.ts +1 -1
  221. package/src/workflow/engine.ts +36 -11
  222. package/dist/index-C2PCQSrB.d.ts +0 -2251
  223. package/scripts/verification-harness.mjs +0 -483
@@ -0,0 +1,467 @@
1
+ import { z } from 'zod'
2
+
3
+ import { contentHashForArtifactV1 } from '../index-builder/content-hash.js'
4
+ import type { ControlledStudyObservationV1 } from './runner.js'
5
+
6
+ export const STUDY_METRICS_SCHEMA_VERSION = 1 as const
7
+
8
+ const RateIntervalSchema = z.object({ low: z.number().min(0).max(1), high: z.number().min(0).max(1) }).strict()
9
+ const StudyMetricSetSchema = z.object({
10
+ observationCount: z.number().int().nonnegative(),
11
+ completedRate: z.number().min(0).max(1),
12
+ completedConfidence95: RateIntervalSchema,
13
+ adjudicatedRate: z.number().min(0).max(1),
14
+ successRate: z.number().min(0).max(1).nullable(),
15
+ successConfidence95: RateIntervalSchema.nullable(),
16
+ evidenceCitationRate: z.number().min(0).max(1),
17
+ evidenceQualityRate: z.number().min(0).max(1).nullable(),
18
+ searchHitRate: z.number().min(0).max(1).nullable(),
19
+ acceptanceCheckRate: z.number().min(0).max(1).nullable(),
20
+ errorRate: z.number().min(0).max(1).nullable(),
21
+ safetyRate: z.number().min(0).max(1).nullable(),
22
+ adjudicatedSuccessRate: z.number().min(0).max(1).nullable(),
23
+ documentationFindingCount: z.number().int().nonnegative().nullable(),
24
+ documentationExampleRate: z.number().min(0).max(1).nullable(),
25
+ documentationFreshnessRate: z.number().min(0).max(1).nullable(),
26
+ documentationCorrectnessRate: z.number().min(0).max(1).nullable(),
27
+ documentationCompletenessRate: z.number().min(0).max(1).nullable(),
28
+ documentationClarityRate: z.number().min(0).max(1).nullable(),
29
+ documentationMaintainabilityRate: z.number().min(0).max(1).nullable(),
30
+ providerTokens: z.number().int().nonnegative().nullable(),
31
+ estimatedTokens: z.number().int().nonnegative().nullable(),
32
+ tokensToCorrectAnswerP95: z.number().int().nonnegative().nullable(),
33
+ /**
34
+ * Tokens spent before the agent had correct grounded evidence in hand.
35
+ *
36
+ * The primary measure of the second problem this product exists for: an agent that reaches the
37
+ * right evidence for fewer tokens than reading the repository costs. It is null when no
38
+ * provider reported it, never zero, because an unmeasured cost is not a free one.
39
+ */
40
+ tokensToFirstEvidenceP95: z.number().int().nonnegative().nullable(),
41
+ latencyP95Ms: z.number().int().nonnegative(),
42
+ timeToCorrectAnswerP95Ms: z.number().int().nonnegative().nullable(),
43
+ contextBytesP95: z.number().int().nonnegative(),
44
+ responseBytesP95: z.number().int().nonnegative(),
45
+ averageToolCalls: z.number().nonnegative().nullable(),
46
+ clarificationRate: z.number().min(0).max(1).nullable(),
47
+ reworkRate: z.number().min(0).max(1).nullable(),
48
+ analysisCostUsd: z.number().nonnegative().nullable(),
49
+ agentCostUsd: z.number().nonnegative().nullable(),
50
+ /**
51
+ * What the Registry agent cost, apart from the model under test.
52
+ *
53
+ * The assisted arm runs two agents, and folding their cost together would make the arm look
54
+ * cheap or expensive for the wrong reason. This is the enrichment side alone.
55
+ */
56
+ registryAgentCostUsd: z.number().nonnegative().nullable(),
57
+ registryAgentRuns: z.number().int().nonnegative().nullable(),
58
+ totalCostUsd: z.number().nonnegative().nullable(),
59
+ providerTokenCostUnits: z.number().int().nonnegative().nullable(),
60
+ missingMetrics: z.array(z.string().min(1).max(128)).max(32),
61
+ }).strict()
62
+
63
+ const MetricDeltaSchema = z.object({
64
+ baseline: z.number().nullable(),
65
+ current: z.number().nullable(),
66
+ absoluteChange: z.number().nullable(),
67
+ relativeChange: z.number().nullable(),
68
+ }).strict()
69
+
70
+ const StudyMetricGroupSchema = z.object({
71
+ scope: z.enum(['task', 'repository', 'category', 'difficulty', 'model', 'scenario', 'replicate', 'aggregate']),
72
+ key: z.string().min(1).max(256),
73
+ round: z.string().min(1).max(128),
74
+ metrics: StudyMetricSetSchema,
75
+ }).strict()
76
+
77
+ const StudyMetricComparisonSchema = z.object({
78
+ scope: StudyMetricGroupSchema.shape.scope,
79
+ key: z.string().min(1).max(256),
80
+ baselineRound: z.string().min(1).max(128),
81
+ currentRound: z.string().min(1).max(128),
82
+ baselineSampleSize: z.number().int().nonnegative(),
83
+ currentSampleSize: z.number().int().nonnegative(),
84
+ metrics: z.object({
85
+ completedRate: MetricDeltaSchema,
86
+ successRate: MetricDeltaSchema,
87
+ evidenceCitationRate: MetricDeltaSchema,
88
+ evidenceQualityRate: MetricDeltaSchema,
89
+ searchHitRate: MetricDeltaSchema,
90
+ acceptanceCheckRate: MetricDeltaSchema,
91
+ errorRate: MetricDeltaSchema,
92
+ safetyRate: MetricDeltaSchema,
93
+ adjudicatedSuccessRate: MetricDeltaSchema,
94
+ documentationFindingCount: MetricDeltaSchema,
95
+ documentationExampleRate: MetricDeltaSchema,
96
+ documentationFreshnessRate: MetricDeltaSchema,
97
+ documentationCorrectnessRate: MetricDeltaSchema,
98
+ documentationCompletenessRate: MetricDeltaSchema,
99
+ documentationClarityRate: MetricDeltaSchema,
100
+ documentationMaintainabilityRate: MetricDeltaSchema,
101
+ providerTokens: MetricDeltaSchema,
102
+ estimatedTokens: MetricDeltaSchema,
103
+ tokensToCorrectAnswerP95: MetricDeltaSchema,
104
+ tokensToFirstEvidenceP95: MetricDeltaSchema,
105
+ registryAgentCostUsd: MetricDeltaSchema,
106
+ latencyP95Ms: MetricDeltaSchema,
107
+ timeToCorrectAnswerP95Ms: MetricDeltaSchema,
108
+ contextBytesP95: MetricDeltaSchema,
109
+ responseBytesP95: MetricDeltaSchema,
110
+ clarificationRate: MetricDeltaSchema,
111
+ reworkRate: MetricDeltaSchema,
112
+ analysisCostUsd: MetricDeltaSchema,
113
+ agentCostUsd: MetricDeltaSchema,
114
+ totalCostUsd: MetricDeltaSchema,
115
+ providerTokenCostUnits: MetricDeltaSchema,
116
+ }).strict(),
117
+ status: z.enum(['improved', 'unchanged', 'regressed', 'inconclusive', 'not-analyzed']),
118
+ regressions: z.array(z.string().min(1).max(256)).max(32),
119
+ limitations: z.array(z.string().min(1).max(512)).max(16),
120
+ }).strict()
121
+
122
+ export const StudyMetricsReportV1Schema = z.object({
123
+ type: z.literal('controlled-study-metrics'),
124
+ schemaVersion: z.literal(STUDY_METRICS_SCHEMA_VERSION),
125
+ metricsVersion: z.string().min(1).max(64),
126
+ contentHash: z.string().regex(/^[a-f0-9]{64}$/),
127
+ contentHashAlgo: z.literal('sha256-normalized-v1'),
128
+ observationCount: z.number().int().nonnegative(),
129
+ rounds: z.array(z.string().min(1).max(128)).max(256),
130
+ baselineRound: z.string().min(1).max(128).nullable(),
131
+ currentRound: z.string().min(1).max(128).nullable(),
132
+ baselineRunId: z.string().min(1).max(256).nullable().optional(),
133
+ currentRunId: z.string().min(1).max(256).nullable().optional(),
134
+ groups: z.array(StudyMetricGroupSchema).max(100_000),
135
+ comparisons: z.array(StudyMetricComparisonSchema).max(100_000),
136
+ limitations: z.array(z.string().min(1).max(1_024)).max(32),
137
+ }).strict()
138
+
139
+ export type StudyMetricSetV1 = z.infer<typeof StudyMetricSetSchema>
140
+ export type StudyMetricGroupV1 = z.infer<typeof StudyMetricGroupSchema>
141
+ export type StudyMetricComparisonV1 = z.infer<typeof StudyMetricComparisonSchema>
142
+ export type StudyMetricsReportV1 = z.infer<typeof StudyMetricsReportV1Schema>
143
+
144
+ const ratio = (value: number, total: number): number => total === 0 ? 0 : value / total
145
+ const percentile95 = (values: readonly number[]): number => {
146
+ if (!values.length) return 0
147
+ const sorted = [...values].sort((a, b) => a - b)
148
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] ?? 0
149
+ }
150
+ const wilson95 = (successes: number, total: number): { readonly low: number; readonly high: number } => {
151
+ if (total === 0) return { low: 0, high: 0 }
152
+ const z = 1.96
153
+ const p = successes / total
154
+ const denominator = 1 + (z * z) / total
155
+ const center = (p + (z * z) / (2 * total)) / denominator
156
+ const margin = (z / denominator) * Math.sqrt((p * (1 - p) / total) + (z * z) / (4 * total * total))
157
+ return { low: Math.max(0, center - margin), high: Math.min(1, center + margin) }
158
+ }
159
+
160
+ type MetricName = keyof StudyMetricSetV1
161
+ type GroupScope = StudyMetricGroupV1['scope']
162
+ type GroupValue = { readonly scope: GroupScope; readonly key: string }
163
+
164
+ const roundOf = (observation: ControlledStudyObservationV1): string => observation.round ?? 'unassigned'
165
+ const groupValues = (observation: ControlledStudyObservationV1): readonly GroupValue[] => [
166
+ { scope: 'task', key: observation.task.taskId },
167
+ { scope: 'repository', key: observation.task.repositoryId },
168
+ { scope: 'category', key: observation.task.category },
169
+ { scope: 'difficulty', key: observation.task.difficulty ?? 'unassigned' },
170
+ { scope: 'model', key: observation.model.id },
171
+ { scope: 'scenario', key: observation.scenario.id },
172
+ { scope: 'replicate', key: String(observation.task.replicate) },
173
+ { scope: 'aggregate', key: 'all' },
174
+ ]
175
+
176
+ const sumKnown = (values: readonly (number | undefined)[]): number | null => {
177
+ const known = values.filter((value): value is number => value !== undefined)
178
+ return known.length ? known.reduce((sum, value) => sum + value, 0) : null
179
+ }
180
+ const rateMetric = (values: readonly (number | undefined)[]): number | null => {
181
+ const known = values.filter((value): value is number => value !== undefined)
182
+ return known.length ? ratio(known.filter((value) => value > 0).length, known.length) : null
183
+ }
184
+
185
+ const measurementValues = (observations: readonly ControlledStudyObservationV1[], id: string): readonly number[] => observations.flatMap((observation) => {
186
+ const value = observation.measurements?.[id]
187
+ return value === undefined ? [] : [value]
188
+ })
189
+ const measurementRate = (observations: readonly ControlledStudyObservationV1[], id: string): number | null => {
190
+ const values = measurementValues(observations, id)
191
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
192
+ }
193
+ const measurementSum = (observations: readonly ControlledStudyObservationV1[], id: string): number | null => {
194
+ const values = measurementValues(observations, id)
195
+ return values.length ? values.reduce((sum, value) => sum + value, 0) : null
196
+ }
197
+ const measurementRatio = (observations: readonly ControlledStudyObservationV1[], numerator: string, denominator: string): number | null => {
198
+ const values = observations.flatMap((observation) => {
199
+ const numeratorValue = observation.measurements?.[numerator]
200
+ const denominatorValue = observation.measurements?.[denominator]
201
+ return numeratorValue !== undefined && denominatorValue !== undefined && denominatorValue > 0 ? [{ numerator: numeratorValue, denominator: denominatorValue }] : []
202
+ })
203
+ if (!values.length) return null
204
+ return values.reduce((sum, value) => sum + value.numerator, 0) / values.reduce((sum, value) => sum + value.denominator, 0)
205
+ }
206
+
207
+ const metricsFor = (observations: readonly ControlledStudyObservationV1[]): StudyMetricSetV1 => {
208
+ const completed = observations.filter((observation) => observation.execution.status === 'completed').length
209
+ const adjudicated = observations.filter((observation) => observation.adjudication.status !== 'pending').length
210
+ const outcomes = observations.filter((observation) => observation.taskOutcome !== undefined)
211
+ const successes = outcomes.filter((observation) => observation.taskOutcome === 'success').length
212
+ const adjudicatedOutcomes = observations.filter((observation) => observation.adjudication.outcome !== undefined)
213
+ const adjudicatedSuccesses = adjudicatedOutcomes.filter((observation) => observation.adjudication.outcome === 'success').length
214
+ const evidenceQuality = observations.filter((observation) => observation.evidenceQuality !== undefined)
215
+ const highQualityEvidence = evidenceQuality.filter((observation) => observation.evidenceQuality === 'high').length
216
+ const providerTokenValues = observations.map((observation) => observation.execution.tokenMethod === 'provider' && observation.execution.inputTokens !== undefined && observation.execution.outputTokens !== undefined ? observation.execution.inputTokens + observation.execution.outputTokens : undefined)
217
+ const estimatedTokenValues = observations.map((observation) => observation.execution.tokenMethod === 'estimate' && observation.execution.inputTokens !== undefined && observation.execution.outputTokens !== undefined ? observation.execution.inputTokens + observation.execution.outputTokens : undefined)
218
+ const providerTokens = sumKnown(providerTokenValues)
219
+ const estimatedTokens = sumKnown(estimatedTokenValues)
220
+ const knownTools = observations.map((observation) => observation.execution.toolCalls)
221
+ const knownToolValues = knownTools.filter((value): value is number => value !== undefined)
222
+ const knownClarifications = observations.map((observation) => observation.clarificationRequests)
223
+ const knownRework = observations.map((observation) => observation.reworkCount)
224
+ const successfulObservations = observations.filter((observation) => observation.taskOutcome === 'success')
225
+ const successfulTokens = successfulObservations.map((observation) => observation.execution.inputTokens !== undefined && observation.execution.outputTokens !== undefined ? observation.execution.inputTokens + observation.execution.outputTokens : undefined).filter((value): value is number => value !== undefined)
226
+ const successfulDurations = successfulObservations.map((observation) => observation.execution.durationMs)
227
+ const safety = observations.map((observation) => observation.safetyOutcome === 'safe' ? 1 : observation.safetyOutcome === 'unsafe' ? 0 : undefined)
228
+ const missingMetrics: string[] = []
229
+ if (providerTokens === null) missingMetrics.push('providerTokens')
230
+ else if (providerTokenValues.some((value) => value === undefined)) missingMetrics.push('providerTokens-partial')
231
+ if (estimatedTokens === null) missingMetrics.push('estimatedTokens')
232
+ else if (estimatedTokenValues.some((value) => value === undefined)) missingMetrics.push('estimatedTokens-partial')
233
+ if (outcomes.length === 0) missingMetrics.push('taskOutcome')
234
+ if (evidenceQuality.length === 0) missingMetrics.push('evidenceQuality')
235
+ if (knownClarifications.every((value) => value === undefined)) missingMetrics.push('clarificationRequests')
236
+ else if (knownClarifications.some((value) => value === undefined)) missingMetrics.push('clarificationRequests-partial')
237
+ if (knownRework.every((value) => value === undefined)) missingMetrics.push('reworkCount')
238
+ else if (knownRework.some((value) => value === undefined)) missingMetrics.push('reworkCount-partial')
239
+ for (const name of ['searchHitRate', 'errorRate', 'documentationExampleRate', 'documentationFreshnessRate', 'documentationCorrectnessRate', 'documentationCompletenessRate', 'documentationClarityRate', 'documentationMaintainabilityRate', 'documentationFindingCount', 'analysisCostUsd', 'agentCostUsd']) if (measurementValues(observations, name).length === 0) missingMetrics.push(name)
240
+ if (measurementRatio(observations, 'acceptanceChecksPassed', 'acceptanceChecksTotal') === null) missingMetrics.push('acceptanceChecks')
241
+ if (safety.every((value) => value === undefined)) missingMetrics.push('safetyOutcome')
242
+ else if (safety.some((value) => value === undefined)) missingMetrics.push('safetyOutcome-partial')
243
+ if (knownTools.every((value) => value === undefined)) missingMetrics.push('toolCalls')
244
+ else if (knownTools.some((value) => value === undefined)) missingMetrics.push('toolCalls-partial')
245
+ /*
246
+ * Reported by the provider as `tokensToFirstEvidence`, rounded up to whole tokens: a study
247
+ * observation is the only place that knows when the agent first held evidence it could cite.
248
+ */
249
+ const tokensToFirstEvidence = measurementValues(observations, 'tokensToFirstEvidence').map((value) => Math.ceil(value))
250
+ if (tokensToFirstEvidence.length === 0) missingMetrics.push('tokensToFirstEvidence')
251
+ else if (tokensToFirstEvidence.length !== observations.length) missingMetrics.push('tokensToFirstEvidence-partial')
252
+ const analysisCostUsd = measurementSum(observations, 'analysisCostUsd')
253
+ const agentCostUsd = measurementSum(observations, 'agentCostUsd')
254
+ const registryAgentCostUsd = measurementSum(observations, 'registryAgentCostUsd')
255
+ const registryAgentRuns = measurementSum(observations, 'registryAgentRuns')
256
+ // Only the assisted arm has a Registry agent; elsewhere the absence is the correct answer.
257
+ if (observations.some((observation) => observation.scenario.id === 'registry-assisted') && registryAgentCostUsd === null) missingMetrics.push('registryAgentCostUsd')
258
+ const providerTokenCostUnits = measurementSum(observations, 'providerTokenCostUnits')
259
+ if (providerTokenCostUnits === null) missingMetrics.push('providerTokenCostUnits')
260
+ else if (observations.some((observation) => observation.execution.tokenMethod === 'provider' && observation.measurements?.providerTokenCostUnits === undefined)) missingMetrics.push('providerTokenCostUnits-partial')
261
+ return {
262
+ observationCount: observations.length,
263
+ completedRate: ratio(completed, observations.length),
264
+ completedConfidence95: wilson95(completed, observations.length),
265
+ adjudicatedRate: ratio(adjudicated, observations.length),
266
+ successRate: outcomes.length ? ratio(successes, outcomes.length) : null,
267
+ successConfidence95: outcomes.length ? wilson95(successes, outcomes.length) : null,
268
+ evidenceCitationRate: ratio(observations.filter((observation) => observation.evidenceIds.length > 0).length, observations.length),
269
+ evidenceQualityRate: evidenceQuality.length ? ratio(highQualityEvidence, evidenceQuality.length) : null,
270
+ searchHitRate: measurementRate(observations, 'searchHitRate'),
271
+ acceptanceCheckRate: measurementRatio(observations, 'acceptanceChecksPassed', 'acceptanceChecksTotal'),
272
+ errorRate: measurementRate(observations, 'errorRate'),
273
+ safetyRate: rateMetric(safety),
274
+ adjudicatedSuccessRate: adjudicatedOutcomes.length ? ratio(adjudicatedSuccesses, adjudicatedOutcomes.length) : null,
275
+ documentationFindingCount: measurementSum(observations, 'documentationFindingCount'),
276
+ documentationExampleRate: measurementRate(observations, 'documentationExampleRate'),
277
+ documentationFreshnessRate: measurementRate(observations, 'documentationFreshnessRate'),
278
+ documentationCorrectnessRate: measurementRate(observations, 'documentationCorrectnessRate'),
279
+ documentationCompletenessRate: measurementRate(observations, 'documentationCompletenessRate'),
280
+ documentationClarityRate: measurementRate(observations, 'documentationClarityRate'),
281
+ documentationMaintainabilityRate: measurementRate(observations, 'documentationMaintainabilityRate'),
282
+ providerTokens,
283
+ estimatedTokens,
284
+ tokensToCorrectAnswerP95: successfulTokens.length ? percentile95(successfulTokens) : null,
285
+ tokensToFirstEvidenceP95: tokensToFirstEvidence.length ? percentile95(tokensToFirstEvidence) : null,
286
+ latencyP95Ms: percentile95(observations.map((observation) => observation.execution.durationMs)),
287
+ timeToCorrectAnswerP95Ms: successfulDurations.length ? percentile95(successfulDurations) : null,
288
+ contextBytesP95: percentile95(observations.map((observation) => observation.contextBytes)),
289
+ responseBytesP95: percentile95(observations.map((observation) => observation.execution.responseBytes)),
290
+ averageToolCalls: knownToolValues.length ? ratio(knownToolValues.reduce((sum, value) => sum + value, 0), knownToolValues.length) : null,
291
+ clarificationRate: rateMetric(knownClarifications.map((value) => value === undefined ? undefined : value > 0 ? 1 : 0)),
292
+ reworkRate: rateMetric(knownRework.map((value) => value === undefined ? undefined : value > 0 ? 1 : 0)),
293
+ analysisCostUsd,
294
+ agentCostUsd,
295
+ registryAgentCostUsd,
296
+ registryAgentRuns: registryAgentRuns === null ? null : Math.round(registryAgentRuns),
297
+ totalCostUsd: analysisCostUsd === null || agentCostUsd === null ? null : analysisCostUsd + agentCostUsd + (registryAgentCostUsd ?? 0),
298
+ providerTokenCostUnits,
299
+ missingMetrics,
300
+ }
301
+ }
302
+
303
+ const delta = (baseline: number | null, current: number | null) => ({
304
+ baseline,
305
+ current,
306
+ absoluteChange: baseline === null || current === null ? null : current - baseline,
307
+ relativeChange: baseline === null || current === null || baseline === 0 ? null : (current - baseline) / baseline,
308
+ })
309
+
310
+ const numeric = (metrics: StudyMetricSetV1, name: MetricName): number | null => {
311
+ if (metrics.missingMetrics.includes(name) || metrics.missingMetrics.includes(`${name}-partial`)) return null
312
+ const value = metrics[name]
313
+ return typeof value === 'number' ? value : null
314
+ }
315
+
316
+ const comparisonFor = (baseline: StudyMetricGroupV1 | undefined, current: StudyMetricGroupV1 | undefined, baselineRound: string, currentRound: string): StudyMetricComparisonV1 => {
317
+ const baselineMetrics = baseline?.metrics
318
+ const currentMetrics = current?.metrics
319
+ const sampleSizes = [baselineMetrics?.observationCount ?? 0, currentMetrics?.observationCount ?? 0]
320
+ const metrics = {
321
+ completedRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'completedRate'), numeric(currentMetrics ?? emptyMetrics(), 'completedRate')),
322
+ successRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'successRate'), numeric(currentMetrics ?? emptyMetrics(), 'successRate')),
323
+ evidenceCitationRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'evidenceCitationRate'), numeric(currentMetrics ?? emptyMetrics(), 'evidenceCitationRate')),
324
+ evidenceQualityRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'evidenceQualityRate'), numeric(currentMetrics ?? emptyMetrics(), 'evidenceQualityRate')),
325
+ searchHitRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'searchHitRate'), numeric(currentMetrics ?? emptyMetrics(), 'searchHitRate')),
326
+ acceptanceCheckRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'acceptanceCheckRate'), numeric(currentMetrics ?? emptyMetrics(), 'acceptanceCheckRate')),
327
+ errorRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'errorRate'), numeric(currentMetrics ?? emptyMetrics(), 'errorRate')),
328
+ safetyRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'safetyRate'), numeric(currentMetrics ?? emptyMetrics(), 'safetyRate')),
329
+ adjudicatedSuccessRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'adjudicatedSuccessRate'), numeric(currentMetrics ?? emptyMetrics(), 'adjudicatedSuccessRate')),
330
+ documentationFindingCount: delta(numeric(baselineMetrics ?? emptyMetrics(), 'documentationFindingCount'), numeric(currentMetrics ?? emptyMetrics(), 'documentationFindingCount')),
331
+ documentationExampleRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'documentationExampleRate'), numeric(currentMetrics ?? emptyMetrics(), 'documentationExampleRate')),
332
+ documentationFreshnessRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'documentationFreshnessRate'), numeric(currentMetrics ?? emptyMetrics(), 'documentationFreshnessRate')),
333
+ documentationCorrectnessRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'documentationCorrectnessRate'), numeric(currentMetrics ?? emptyMetrics(), 'documentationCorrectnessRate')),
334
+ documentationCompletenessRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'documentationCompletenessRate'), numeric(currentMetrics ?? emptyMetrics(), 'documentationCompletenessRate')),
335
+ documentationClarityRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'documentationClarityRate'), numeric(currentMetrics ?? emptyMetrics(), 'documentationClarityRate')),
336
+ documentationMaintainabilityRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'documentationMaintainabilityRate'), numeric(currentMetrics ?? emptyMetrics(), 'documentationMaintainabilityRate')),
337
+ providerTokens: delta(numeric(baselineMetrics ?? emptyMetrics(), 'providerTokens'), numeric(currentMetrics ?? emptyMetrics(), 'providerTokens')),
338
+ estimatedTokens: delta(numeric(baselineMetrics ?? emptyMetrics(), 'estimatedTokens'), numeric(currentMetrics ?? emptyMetrics(), 'estimatedTokens')),
339
+ tokensToCorrectAnswerP95: delta(numeric(baselineMetrics ?? emptyMetrics(), 'tokensToCorrectAnswerP95'), numeric(currentMetrics ?? emptyMetrics(), 'tokensToCorrectAnswerP95')),
340
+ tokensToFirstEvidenceP95: delta(numeric(baselineMetrics ?? emptyMetrics(), 'tokensToFirstEvidenceP95'), numeric(currentMetrics ?? emptyMetrics(), 'tokensToFirstEvidenceP95')),
341
+ registryAgentCostUsd: delta(numeric(baselineMetrics ?? emptyMetrics(), 'registryAgentCostUsd'), numeric(currentMetrics ?? emptyMetrics(), 'registryAgentCostUsd')),
342
+ latencyP95Ms: delta(numeric(baselineMetrics ?? emptyMetrics(), 'latencyP95Ms'), numeric(currentMetrics ?? emptyMetrics(), 'latencyP95Ms')),
343
+ timeToCorrectAnswerP95Ms: delta(numeric(baselineMetrics ?? emptyMetrics(), 'timeToCorrectAnswerP95Ms'), numeric(currentMetrics ?? emptyMetrics(), 'timeToCorrectAnswerP95Ms')),
344
+ contextBytesP95: delta(numeric(baselineMetrics ?? emptyMetrics(), 'contextBytesP95'), numeric(currentMetrics ?? emptyMetrics(), 'contextBytesP95')),
345
+ responseBytesP95: delta(numeric(baselineMetrics ?? emptyMetrics(), 'responseBytesP95'), numeric(currentMetrics ?? emptyMetrics(), 'responseBytesP95')),
346
+ clarificationRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'clarificationRate'), numeric(currentMetrics ?? emptyMetrics(), 'clarificationRate')),
347
+ reworkRate: delta(numeric(baselineMetrics ?? emptyMetrics(), 'reworkRate'), numeric(currentMetrics ?? emptyMetrics(), 'reworkRate')),
348
+ analysisCostUsd: delta(numeric(baselineMetrics ?? emptyMetrics(), 'analysisCostUsd'), numeric(currentMetrics ?? emptyMetrics(), 'analysisCostUsd')),
349
+ agentCostUsd: delta(numeric(baselineMetrics ?? emptyMetrics(), 'agentCostUsd'), numeric(currentMetrics ?? emptyMetrics(), 'agentCostUsd')),
350
+ totalCostUsd: delta(numeric(baselineMetrics ?? emptyMetrics(), 'totalCostUsd'), numeric(currentMetrics ?? emptyMetrics(), 'totalCostUsd')),
351
+ providerTokenCostUnits: delta(numeric(baselineMetrics ?? emptyMetrics(), 'providerTokenCostUnits'), numeric(currentMetrics ?? emptyMetrics(), 'providerTokenCostUnits')),
352
+ }
353
+ const regressions: string[] = []
354
+ if (sampleSizes.every((size) => size >= 2)) {
355
+ if (metrics.successRate.baseline !== null && metrics.successRate.current !== null && metrics.successRate.current < metrics.successRate.baseline) regressions.push('successRate')
356
+ if (metrics.adjudicatedSuccessRate.baseline !== null && metrics.adjudicatedSuccessRate.current !== null && metrics.adjudicatedSuccessRate.current < metrics.adjudicatedSuccessRate.baseline) regressions.push('adjudicatedSuccessRate')
357
+ if (metrics.evidenceCitationRate.baseline !== null && metrics.evidenceCitationRate.current !== null && metrics.evidenceCitationRate.current < metrics.evidenceCitationRate.baseline) regressions.push('evidenceCitationRate')
358
+ if (metrics.evidenceQualityRate.baseline !== null && metrics.evidenceQualityRate.current !== null && metrics.evidenceQualityRate.current < metrics.evidenceQualityRate.baseline) regressions.push('evidenceQualityRate')
359
+ if (metrics.safetyRate.baseline !== null && metrics.safetyRate.current !== null && metrics.safetyRate.current < metrics.safetyRate.baseline) regressions.push('safetyRate')
360
+ if (metrics.acceptanceCheckRate.baseline !== null && metrics.acceptanceCheckRate.current !== null && metrics.acceptanceCheckRate.current < metrics.acceptanceCheckRate.baseline) regressions.push('acceptanceCheckRate')
361
+ for (const name of ['providerTokens', 'estimatedTokens', 'tokensToCorrectAnswerP95', 'tokensToFirstEvidenceP95', 'latencyP95Ms', 'timeToCorrectAnswerP95Ms', 'contextBytesP95', 'responseBytesP95', 'clarificationRate', 'reworkRate', 'analysisCostUsd', 'agentCostUsd', 'registryAgentCostUsd', 'totalCostUsd', 'providerTokenCostUnits'] as const) {
362
+ const change = metrics[name]
363
+ if (change.baseline !== null && change.current !== null && change.current > change.baseline * 1.05) regressions.push(name)
364
+ }
365
+ }
366
+ const comparable = Object.values(metrics).some((value) => value.baseline !== null && value.current !== null)
367
+ const improved = ['providerTokens', 'estimatedTokens', 'tokensToCorrectAnswerP95', 'tokensToFirstEvidenceP95', 'latencyP95Ms', 'timeToCorrectAnswerP95Ms', 'contextBytesP95', 'responseBytesP95', 'clarificationRate', 'reworkRate', 'analysisCostUsd', 'agentCostUsd', 'registryAgentCostUsd', 'totalCostUsd', 'providerTokenCostUnits'].some((name) => {
368
+ const change = metrics[name as keyof typeof metrics]
369
+ return change.baseline !== null && change.current !== null && change.current < change.baseline * 0.95
370
+ }) || ['successRate', 'adjudicatedSuccessRate', 'evidenceCitationRate', 'evidenceQualityRate'].some((name) => {
371
+ const change = metrics[name as keyof typeof metrics]
372
+ return change.baseline !== null && change.current !== null && change.current > change.baseline
373
+ })
374
+ const status = !baseline || !current || !comparable ? 'not-analyzed' : sampleSizes.some((size) => size < 2) ? 'inconclusive' : regressions.length ? 'regressed' : improved ? 'improved' : 'unchanged'
375
+ return {
376
+ scope: current?.scope ?? baseline?.scope ?? 'aggregate',
377
+ key: current?.key ?? baseline?.key ?? 'all',
378
+ baselineRound,
379
+ currentRound,
380
+ baselineSampleSize: sampleSizes[0] ?? 0,
381
+ currentSampleSize: sampleSizes[1] ?? 0,
382
+ metrics,
383
+ status,
384
+ regressions,
385
+ limitations: sampleSizes.some((size) => size < 2) ? ['Samples smaller than two observations are inconclusive.'] : [],
386
+ }
387
+ }
388
+
389
+ const emptyMetrics = (): StudyMetricSetV1 => ({
390
+ observationCount: 0, completedRate: 0, completedConfidence95: { low: 0, high: 0 }, adjudicatedRate: 0, successRate: null, successConfidence95: null, evidenceCitationRate: 0, evidenceQualityRate: null, searchHitRate: null, acceptanceCheckRate: null, errorRate: null, safetyRate: null, adjudicatedSuccessRate: null, documentationFindingCount: null, documentationExampleRate: null, documentationFreshnessRate: null, documentationCorrectnessRate: null, documentationCompletenessRate: null, documentationClarityRate: null, documentationMaintainabilityRate: null, providerTokens: null, estimatedTokens: null, tokensToCorrectAnswerP95: null, tokensToFirstEvidenceP95: null, latencyP95Ms: 0, timeToCorrectAnswerP95Ms: null, contextBytesP95: 0, responseBytesP95: 0, averageToolCalls: null, clarificationRate: null, reworkRate: null, analysisCostUsd: null, agentCostUsd: null, registryAgentCostUsd: null, registryAgentRuns: null, totalCostUsd: null, providerTokenCostUnits: null, missingMetrics: [],
391
+ })
392
+
393
+ export const calculateStudyMetrics = (observations: readonly ControlledStudyObservationV1[], options: { readonly baselineRound?: string; readonly currentRound?: string; readonly baselineRunId?: string; readonly currentRunId?: string } = {}): StudyMetricsReportV1 => {
394
+ const allRounds = [...new Set(observations.map(roundOf))].sort()
395
+ const baselineRound = options.baselineRound ?? (allRounds.includes('baseline') ? 'baseline' : allRounds[0] ?? null)
396
+ const currentRound = options.currentRound ?? (allRounds.filter((round) => round !== baselineRound).at(-1) ?? null)
397
+ const selectedObservations = options.baselineRunId || options.currentRunId
398
+ ? observations.filter((observation) => {
399
+ const round = roundOf(observation)
400
+ if (baselineRound === currentRound) {
401
+ if (round !== baselineRound) return false
402
+ const selectedRunIds = [options.baselineRunId, options.currentRunId].filter((runId): runId is string => runId !== undefined)
403
+ return selectedRunIds.length === 0 || selectedRunIds.includes(observation.runId)
404
+ }
405
+ if (round === baselineRound) return options.baselineRunId === undefined || observation.runId === options.baselineRunId
406
+ if (round === currentRound) return options.currentRunId === undefined || observation.runId === options.currentRunId
407
+ return false
408
+ })
409
+ : observations
410
+ const rounds = [...new Set(selectedObservations.map(roundOf))].sort()
411
+ const grouped = new Map<string, ControlledStudyObservationV1[]>()
412
+ for (const observation of selectedObservations) for (const group of groupValues(observation)) {
413
+ const key = `${group.scope}\u0000${group.key}\u0000${roundOf(observation)}`
414
+ grouped.set(key, [...(grouped.get(key) ?? []), observation])
415
+ }
416
+ const groups = [...grouped.entries()].map(([key, values]) => {
417
+ const [scope, groupKey, round] = key.split('\u0000') as [GroupScope, string, string]
418
+ return { scope, key: groupKey, round, metrics: metricsFor(values) }
419
+ }).sort((a, b) => `${a.scope}:${a.key}:${a.round}`.localeCompare(`${b.scope}:${b.key}:${b.round}`))
420
+ const comparisonKeys = new Set(groups.filter((group) => group.round === baselineRound || group.round === currentRound).map((group) => `${group.scope}\u0000${group.key}`))
421
+ const comparisons = [...comparisonKeys].map((key) => {
422
+ const [scope, groupKey] = key.split('\u0000') as [GroupScope, string]
423
+ return comparisonFor(groups.find((group) => group.scope === scope && group.key === groupKey && group.round === baselineRound), groups.find((group) => group.scope === scope && group.key === groupKey && group.round === currentRound), baselineRound ?? 'unassigned', currentRound ?? 'unassigned')
424
+ }).sort((a, b) => `${a.scope}:${a.key}`.localeCompare(`${b.scope}:${b.key}`))
425
+ const base = {
426
+ type: 'controlled-study-metrics' as const,
427
+ schemaVersion: STUDY_METRICS_SCHEMA_VERSION,
428
+ metricsVersion: 'v1',
429
+ contentHash: '0'.repeat(64),
430
+ contentHashAlgo: 'sha256-normalized-v1' as const,
431
+ observationCount: selectedObservations.length,
432
+ rounds,
433
+ baselineRound,
434
+ currentRound,
435
+ ...(options.baselineRunId === undefined ? {} : { baselineRunId: options.baselineRunId }),
436
+ ...(options.currentRunId === undefined ? {} : { currentRunId: options.currentRunId }),
437
+ groups,
438
+ comparisons,
439
+ limitations: [
440
+ 'Missing observation fields remain not-analyzed and are excluded from their metric denominator.',
441
+ 'A lower token or latency value is not an improvement when correctness, evidence quality, or rework regresses.',
442
+ 'Small samples are labeled inconclusive; this report does not establish causality from historical observations.',
443
+ ],
444
+ }
445
+ return StudyMetricsReportV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
446
+ }
447
+
448
+ export const parseStudyMetrics = (input: unknown): StudyMetricsReportV1 => {
449
+ const report = StudyMetricsReportV1Schema.parse(input)
450
+ if (report.contentHash !== contentHashForArtifactV1(report)) throw new Error('Invalid controlled-study metrics content hash.')
451
+ return report
452
+ }
453
+
454
+ export const formatStudyMetricsText = (report: StudyMetricsReportV1): readonly string[] => [
455
+ `Study metrics: ${report.metricsVersion}`,
456
+ `Observations: ${report.observationCount} | Rounds: ${report.rounds.length}`,
457
+ `Baseline: ${report.baselineRound ?? 'not-analyzed'} | Current: ${report.currentRound ?? 'not-analyzed'}`,
458
+ ...(report.baselineRunId || report.currentRunId ? [`Baseline run: ${report.baselineRunId ?? 'not-selected'} | Current run: ${report.currentRunId ?? 'not-selected'}`] : []),
459
+ `Groups: ${report.groups.length} | Comparisons: ${report.comparisons.length}`,
460
+ `Regressions: ${report.comparisons.filter((comparison) => comparison.status === 'regressed').length} | Inconclusive: ${report.comparisons.filter((comparison) => comparison.status === 'inconclusive').length}`,
461
+ `Content hash: ${report.contentHash}`,
462
+ ...report.groups
463
+ .filter((group) => group.scope === 'scenario')
464
+ .map((group) => `Tokens to first evidence (p95) ${group.key} @ ${group.round}: ${group.metrics.tokensToFirstEvidenceP95 ?? 'not-analyzed'}${group.metrics.registryAgentCostUsd === null ? '' : ` | registry agent cost ${group.metrics.registryAgentCostUsd} USD over ${group.metrics.registryAgentRuns ?? 0} run(s)`}`),
465
+ ...report.groups.map((group) => `Group ${group.scope}/${group.key} @ ${group.round}: ${JSON.stringify(group.metrics)}`),
466
+ ...report.comparisons.map((comparison) => `Comparison ${comparison.scope}/${comparison.key}: ${JSON.stringify({ status: comparison.status, metrics: comparison.metrics, regressions: comparison.regressions })}`),
467
+ ]