niceeval 0.6.1 → 0.7.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 (378) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +69 -7
  4. package/dist/context/types.d.ts +32 -12
  5. package/dist/i18n/en.d.ts +54 -0
  6. package/dist/i18n/zh-CN.d.ts +57 -3
  7. package/dist/o11y/types.d.ts +16 -2
  8. package/dist/report/aggregate.d.ts +32 -24
  9. package/dist/report/aggregate.js +158 -50
  10. package/dist/report/built-in/index.d.ts +2 -0
  11. package/dist/report/built-in/index.js +8 -0
  12. package/dist/report/components.d.ts +93 -160
  13. package/dist/report/components.js +377 -114
  14. package/dist/report/compute.d.ts +87 -81
  15. package/dist/report/compute.js +597 -417
  16. package/dist/report/flag.d.ts +32 -6
  17. package/dist/report/flag.js +92 -4
  18. package/dist/report/format.d.ts +19 -11
  19. package/dist/report/format.js +30 -13
  20. package/dist/report/index.d.ts +16 -16
  21. package/dist/report/index.js +20 -21
  22. package/dist/report/load.js +3 -2
  23. package/dist/report/locale.d.ts +57 -33
  24. package/dist/report/locale.js +122 -56
  25. package/dist/report/metrics.d.ts +23 -4
  26. package/dist/report/metrics.js +110 -25
  27. package/dist/report/primitives.d.ts +48 -15
  28. package/dist/report/primitives.js +135 -26
  29. package/dist/report/react/AttemptList.d.ts +9 -7
  30. package/dist/report/react/AttemptList.js +17 -10
  31. package/dist/report/react/DeltaTable.js +19 -18
  32. package/dist/report/react/EvalList.d.ts +4 -4
  33. package/dist/report/react/EvalList.js +0 -0
  34. package/dist/report/react/ExperimentComparison.d.ts +10 -0
  35. package/dist/report/react/ExperimentComparison.js +12 -0
  36. package/dist/report/react/ExperimentList.d.ts +4 -3
  37. package/dist/report/react/ExperimentList.js +17 -18
  38. package/dist/report/react/MetricBars.js +5 -4
  39. package/dist/report/react/MetricLine.js +12 -5
  40. package/dist/report/react/MetricMatrix.js +1 -1
  41. package/dist/report/react/MetricScatter.js +59 -28
  42. package/dist/report/react/MetricTable.js +2 -12
  43. package/dist/report/react/ScopeSummary.d.ts +10 -0
  44. package/dist/report/react/ScopeSummary.js +17 -0
  45. package/dist/report/react/Scoreboard.js +6 -6
  46. package/dist/report/react/cell.js +2 -2
  47. package/dist/report/react/chart-math.d.ts +23 -6
  48. package/dist/report/react/chart-math.js +71 -19
  49. package/dist/report/react/fixtures.d.ts +5 -9
  50. package/dist/report/react/fixtures.js +110 -147
  51. package/dist/report/react/index.d.ts +15 -5
  52. package/dist/report/react/index.js +18 -7
  53. package/dist/report/report.d.ts +137 -16
  54. package/dist/report/report.js +259 -28
  55. package/dist/report/text/faces.d.ts +17 -19
  56. package/dist/report/text/faces.js +253 -184
  57. package/dist/report/text/plot.js +1 -1
  58. package/dist/report/text/table.js +38 -7
  59. package/dist/report/tree.d.ts +90 -40
  60. package/dist/report/tree.js +252 -94
  61. package/dist/report/types.d.ts +247 -284
  62. package/dist/report/types.js +4 -3
  63. package/dist/report/web.d.ts +21 -5
  64. package/dist/report/web.js +42 -16
  65. package/dist/results/select.d.ts +38 -16
  66. package/dist/results/select.js +73 -25
  67. package/dist/results/types.d.ts +49 -14
  68. package/dist/runner/feedback/sink.d.ts +110 -0
  69. package/dist/runner/types.d.ts +513 -22
  70. package/dist/sandbox/docker.d.ts +23 -2
  71. package/dist/sandbox/e2b.d.ts +15 -1
  72. package/dist/sandbox/errors.d.ts +30 -3
  73. package/dist/sandbox/io-retry.d.ts +17 -0
  74. package/dist/sandbox/registry.d.ts +2 -0
  75. package/dist/sandbox/resolve.d.ts +18 -5
  76. package/dist/sandbox/retry.d.ts +11 -1
  77. package/dist/sandbox/types.d.ts +39 -5
  78. package/dist/sandbox/vercel.d.ts +7 -1
  79. package/dist/scoring/coverage.d.ts +30 -0
  80. package/dist/scoring/display.d.ts +21 -0
  81. package/dist/scoring/display.js +120 -0
  82. package/dist/scoring/types.d.ts +103 -20
  83. package/dist/shared/aggregate.d.ts +4 -2
  84. package/dist/shared/aggregate.js +8 -7
  85. package/dist/shared/types.d.ts +28 -0
  86. package/dist/tty-line.d.ts +0 -4
  87. package/dist/util.d.ts +23 -0
  88. package/docs-site/zh/README.md +44 -0
  89. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  90. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  91. package/docs-site/zh/examples/index.mdx +50 -0
  92. package/docs-site/zh/{concepts → explanation}/adapter.mdx +31 -13
  93. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  94. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  95. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  96. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  97. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  98. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  99. package/docs-site/zh/{concepts → explanation}/overview.mdx +11 -11
  100. package/docs-site/zh/{guides → explanation}/runner.mdx +18 -8
  101. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  102. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +35 -33
  103. package/docs-site/zh/{guides → how-to}/authoring.mdx +35 -2
  104. package/docs-site/zh/{guides → how-to}/ci-integration.mdx +23 -12
  105. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  106. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +47 -21
  107. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +34 -39
  108. package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +25 -3
  109. package/docs-site/zh/{guides → how-to}/experiments.mdx +12 -5
  110. package/docs-site/zh/how-to/publish-report.mdx +105 -0
  111. package/docs-site/zh/{guides → how-to}/reporters.mdx +2 -2
  112. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +56 -7
  113. package/docs-site/zh/how-to/sandbox-providers.mdx +350 -0
  114. package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +4 -4
  115. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +82 -39
  116. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +6 -4
  117. package/docs-site/zh/{guides → how-to}/write-send.mdx +30 -14
  118. package/docs-site/zh/index.mdx +24 -26
  119. package/docs-site/zh/introduction.mdx +8 -8
  120. package/docs-site/zh/reference/builtin-agents.mdx +32 -5
  121. package/docs-site/zh/reference/capabilities.mdx +8 -8
  122. package/docs-site/zh/reference/cli.mdx +40 -12
  123. package/docs-site/zh/reference/define-agent.mdx +58 -5
  124. package/docs-site/zh/reference/define-config.mdx +1 -1
  125. package/docs-site/zh/reference/define-eval.mdx +42 -9
  126. package/docs-site/zh/reference/events.mdx +3 -3
  127. package/docs-site/zh/reference/expect.mdx +26 -1
  128. package/docs-site/zh/{guides → reference}/official-adapters.mdx +32 -8
  129. package/docs-site/zh/{guides → reference}/report-components.mdx +45 -33
  130. package/docs-site/zh/{guides → reference}/results-data.mdx +21 -13
  131. package/docs-site/zh/troubleshooting/debug-sandbox.mdx +57 -0
  132. package/docs-site/zh/troubleshooting/debugging.mdx +212 -0
  133. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  134. package/package.json +10 -2
  135. package/src/agents/ai-sdk-otel.test.ts +1 -0
  136. package/src/agents/ai-sdk.test.ts +3 -0
  137. package/src/agents/ai-sdk.ts +3 -0
  138. package/src/agents/bub-install-spec.test.ts +34 -0
  139. package/src/agents/bub-install-spec.ts +32 -0
  140. package/src/agents/bub.ts +31 -32
  141. package/src/agents/claude-code.test.ts +130 -9
  142. package/src/agents/claude-code.ts +76 -4
  143. package/src/agents/codex.test.ts +189 -40
  144. package/src/agents/codex.ts +155 -14
  145. package/src/agents/coding-cli-versions.test.ts +15 -0
  146. package/src/agents/coding-cli-versions.ts +3 -0
  147. package/src/agents/index.ts +13 -2
  148. package/src/agents/langgraph.test.ts +204 -0
  149. package/src/agents/langgraph.ts +495 -0
  150. package/src/agents/marketplace.ts +85 -0
  151. package/src/agents/native-config.test.ts +179 -0
  152. package/src/agents/native-config.ts +267 -0
  153. package/src/agents/openai-compat.test.ts +1 -0
  154. package/src/agents/openai-compat.ts +1 -1
  155. package/src/agents/openclaw.test.ts +31 -0
  156. package/src/agents/openclaw.ts +171 -0
  157. package/src/agents/plugin-config.test.ts +1 -0
  158. package/src/agents/sdk-streams.test.ts +79 -0
  159. package/src/agents/sdk-streams.ts +55 -10
  160. package/src/agents/skills.test.ts +1 -0
  161. package/src/agents/streaming.test.ts +3 -9
  162. package/src/agents/streaming.ts +2 -2
  163. package/src/agents/types.ts +71 -8
  164. package/src/agents/ui-message-stream.test.ts +3 -0
  165. package/src/cli.ts +446 -124
  166. package/src/context/context.test.ts +51 -12
  167. package/src/context/context.ts +162 -30
  168. package/src/context/session.test.ts +2 -1
  169. package/src/context/session.ts +115 -7
  170. package/src/context/types.ts +30 -12
  171. package/src/define.test.ts +13 -8
  172. package/src/define.ts +25 -4
  173. package/src/expect/index.ts +53 -23
  174. package/src/i18n/en.ts +81 -17
  175. package/src/i18n/zh-CN.ts +80 -17
  176. package/src/o11y/cost.test.ts +1 -0
  177. package/src/o11y/execution-tree.test.ts +1 -20
  178. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  179. package/src/o11y/otlp/parse.test.ts +1 -0
  180. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  181. package/src/o11y/parsers/bub.test.ts +1 -0
  182. package/src/o11y/parsers/claude-code.test.ts +1 -34
  183. package/src/o11y/parsers/openclaw.test.ts +154 -0
  184. package/src/o11y/parsers/openclaw.ts +310 -0
  185. package/src/o11y/prices.json +746 -311
  186. package/src/o11y/tool-names.test.ts +1 -0
  187. package/src/o11y/types.ts +16 -2
  188. package/src/report/aggregate.ts +178 -61
  189. package/src/report/built-in/index.tsx +9 -0
  190. package/src/report/components.tsx +625 -279
  191. package/src/report/compute.ts +723 -491
  192. package/src/report/dual-render.test.tsx +741 -1024
  193. package/src/report/flag.ts +104 -12
  194. package/src/report/format.ts +32 -12
  195. package/src/report/index.ts +119 -46
  196. package/src/report/load.ts +3 -2
  197. package/src/report/locale.ts +136 -65
  198. package/src/report/metrics.ts +108 -25
  199. package/src/report/primitives.tsx +196 -45
  200. package/src/report/react/AttemptList.tsx +30 -43
  201. package/src/report/react/DeltaTable.tsx +63 -45
  202. package/src/report/react/EvalList.tsx +0 -0
  203. package/src/report/react/ExperimentComparison.tsx +73 -0
  204. package/src/report/react/ExperimentList.tsx +50 -32
  205. package/src/report/react/MetricBars.tsx +5 -4
  206. package/src/report/react/MetricLine.tsx +13 -8
  207. package/src/report/react/MetricMatrix.tsx +2 -2
  208. package/src/report/react/MetricScatter.tsx +86 -34
  209. package/src/report/react/MetricTable.tsx +4 -76
  210. package/src/report/react/ScopeSummary.tsx +86 -0
  211. package/src/report/react/Scoreboard.tsx +28 -10
  212. package/src/report/react/cell.tsx +2 -2
  213. package/src/report/react/chart-math.test.ts +85 -0
  214. package/src/report/react/chart-math.ts +101 -22
  215. package/src/report/react/enhance.js +89 -5
  216. package/src/report/react/fixtures.ts +114 -154
  217. package/src/report/react/index.tsx +24 -39
  218. package/src/report/react/render.test.tsx +138 -158
  219. package/src/report/react/styles.css +243 -82
  220. package/src/report/report.test.ts +779 -841
  221. package/src/report/report.ts +423 -41
  222. package/src/report/text/faces.ts +290 -193
  223. package/src/report/text/plot.ts +1 -1
  224. package/src/report/text/table.ts +44 -7
  225. package/src/report/tree.ts +362 -104
  226. package/src/report/types.ts +261 -271
  227. package/src/report/web.ts +63 -20
  228. package/src/results/annotated-source.test.ts +62 -9
  229. package/src/results/annotated-source.ts +64 -6
  230. package/src/results/attempt-evidence.test.ts +13 -11
  231. package/src/results/attempt-evidence.ts +20 -13
  232. package/src/results/attempt-source.ts +6 -3
  233. package/src/results/copy.ts +150 -60
  234. package/src/results/host-equivalence.test.ts +34 -20
  235. package/src/results/index.ts +12 -4
  236. package/src/results/locator.test.ts +1 -22
  237. package/src/results/open.ts +15 -5
  238. package/src/results/publish.ts +149 -0
  239. package/src/results/results.test.ts +89 -54
  240. package/src/results/select.ts +104 -34
  241. package/src/results/truncate.ts +90 -0
  242. package/src/results/types.ts +43 -14
  243. package/src/results/writer.ts +31 -13
  244. package/src/runner/attempt.test.ts +138 -7
  245. package/src/runner/attempt.ts +603 -104
  246. package/src/runner/discover.test.ts +47 -0
  247. package/src/runner/discover.ts +36 -2
  248. package/src/runner/eval-source.test.ts +1 -27
  249. package/src/runner/feedback/agent.test.ts +504 -0
  250. package/src/runner/feedback/agent.ts +409 -0
  251. package/src/runner/feedback/ci.test.ts +562 -0
  252. package/src/runner/feedback/ci.ts +401 -0
  253. package/src/runner/feedback/coordinator.test.ts +317 -0
  254. package/src/runner/feedback/coordinator.ts +397 -0
  255. package/src/runner/feedback/failure.ts +40 -0
  256. package/src/runner/feedback/human.test.ts +616 -0
  257. package/src/runner/feedback/human.ts +535 -0
  258. package/src/runner/feedback/index.ts +66 -0
  259. package/src/runner/feedback/io.ts +78 -0
  260. package/src/runner/feedback/profile.test.ts +50 -0
  261. package/src/runner/feedback/profile.ts +58 -0
  262. package/src/runner/feedback/reducer.test.ts +395 -0
  263. package/src/runner/feedback/reducer.ts +260 -0
  264. package/src/runner/feedback/renderer.ts +82 -0
  265. package/src/runner/feedback/sink.ts +203 -0
  266. package/src/runner/feedback/testing.ts +106 -0
  267. package/src/runner/ledger.test.ts +230 -0
  268. package/src/runner/ledger.ts +329 -0
  269. package/src/runner/report.test.ts +128 -3
  270. package/src/runner/report.ts +33 -9
  271. package/src/runner/reporters/artifacts.ts +8 -2
  272. package/src/runner/reporters/braintrust.test.ts +8 -7
  273. package/src/runner/reporters/braintrust.ts +9 -2
  274. package/src/runner/reporters/index.ts +2 -2
  275. package/src/runner/reporters/json.test.ts +162 -0
  276. package/src/runner/reporters/json.ts +35 -8
  277. package/src/runner/reporters/shared.ts +1 -5
  278. package/src/runner/run.test.ts +760 -3
  279. package/src/runner/run.ts +243 -37
  280. package/src/runner/sandbox-prep.ts +3 -42
  281. package/src/runner/timing.ts +158 -0
  282. package/src/runner/types.ts +518 -22
  283. package/src/sandbox/checkpoint.test.ts +55 -0
  284. package/src/sandbox/checkpoint.ts +29 -8
  285. package/src/sandbox/cli-commands.ts +407 -0
  286. package/src/sandbox/docker.ts +115 -16
  287. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  288. package/src/sandbox/e2b-agent-template.ts +94 -0
  289. package/src/sandbox/e2b.ts +74 -9
  290. package/src/sandbox/errors.ts +111 -4
  291. package/src/sandbox/index.ts +2 -0
  292. package/src/sandbox/io-retry.test.ts +58 -0
  293. package/src/sandbox/io-retry.ts +45 -0
  294. package/src/sandbox/keep-registry.test.ts +86 -0
  295. package/src/sandbox/keep-registry.ts +142 -0
  296. package/src/sandbox/keep.ts +178 -0
  297. package/src/sandbox/paths.test.ts +1 -0
  298. package/src/sandbox/paths.ts +19 -8
  299. package/src/sandbox/registry.ts +20 -3
  300. package/src/sandbox/resolve.ts +76 -11
  301. package/src/sandbox/retry.test.ts +70 -0
  302. package/src/sandbox/retry.ts +46 -4
  303. package/src/sandbox/types.ts +44 -6
  304. package/src/sandbox/vercel.ts +43 -20
  305. package/src/scoring/collector.ts +60 -17
  306. package/src/scoring/coverage.ts +95 -0
  307. package/src/scoring/diff.ts +81 -0
  308. package/src/scoring/display.test.ts +121 -0
  309. package/src/scoring/display.ts +133 -0
  310. package/src/scoring/evidence.test.ts +189 -0
  311. package/src/scoring/judge.test.ts +142 -0
  312. package/src/scoring/judge.ts +15 -18
  313. package/src/scoring/scoped.ts +217 -50
  314. package/src/scoring/types.ts +117 -20
  315. package/src/scoring/verdict.ts +16 -4
  316. package/src/shared/aggregate.ts +8 -6
  317. package/src/shared/types.ts +31 -0
  318. package/src/show/compose.ts +50 -67
  319. package/src/show/index.ts +127 -56
  320. package/src/show/render.ts +662 -131
  321. package/src/show/report-host.test.ts +188 -0
  322. package/src/show/report-host.ts +375 -0
  323. package/src/show/show.test.ts +320 -54
  324. package/src/tty-line.ts +8 -26
  325. package/src/util.test.ts +1 -0
  326. package/src/util.ts +41 -0
  327. package/src/view/app/App.test.tsx +69 -0
  328. package/src/view/app/App.tsx +144 -48
  329. package/src/view/app/components/AttemptModal.tsx +423 -11
  330. package/src/view/app/components/CodeView.tsx +41 -14
  331. package/src/view/app/components/CopyControls.tsx +2 -2
  332. package/src/view/app/i18n.ts +37 -17
  333. package/src/view/app/lib/attempt-route.test.ts +1 -0
  334. package/src/view/app/lib/verdict.ts +7 -9
  335. package/src/view/app/main.tsx +13 -8
  336. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  337. package/src/view/app/types.ts +4 -1
  338. package/src/view/artifact-serving.test.ts +2 -1
  339. package/src/view/client-dist/app.css +1 -1
  340. package/src/view/client-dist/app.js +17 -17
  341. package/src/view/data.test.ts +10 -3
  342. package/src/view/data.ts +155 -49
  343. package/src/view/index.ts +56 -41
  344. package/src/view/server.ts +37 -15
  345. package/src/view/shared/types.ts +34 -5
  346. package/src/view/styles.css +227 -0
  347. package/src/view/view-report.test.ts +167 -62
  348. package/dist/report/built-ins/experiment-comparison.d.ts +0 -1
  349. package/dist/report/built-ins/experiment-comparison.js +0 -13
  350. package/dist/report/built-ins/index.d.ts +0 -1
  351. package/dist/report/built-ins/index.js +0 -2
  352. package/dist/report/react/GroupSummary.d.ts +0 -8
  353. package/dist/report/react/GroupSummary.js +0 -8
  354. package/dist/report/react/RunOverview.d.ts +0 -8
  355. package/dist/report/react/RunOverview.js +0 -12
  356. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  357. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  358. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  359. package/docs-site/zh/example/showcase.mdx +0 -39
  360. package/docs-site/zh/guides/publish-report.mdx +0 -91
  361. package/docs-site/zh/guides/sandbox-providers.mdx +0 -102
  362. package/src/report/built-in-user-parity.test.tsx +0 -640
  363. package/src/report/built-ins/experiment-comparison.tsx +0 -19
  364. package/src/report/built-ins/index.ts +0 -2
  365. package/src/report/react/GroupSummary.tsx +0 -66
  366. package/src/report/react/RunOverview.tsx +0 -109
  367. package/src/runner/reporters/console.ts +0 -70
  368. package/src/runner/reporters/live.test.ts +0 -56
  369. package/src/runner/reporters/live.ts +0 -247
  370. package/src/runner/reporters/quiet.test.ts +0 -66
  371. package/src/runner/reporters/quiet.ts +0 -49
  372. package/src/runner/reporters/table.ts +0 -277
  373. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  374. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  375. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  376. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  377. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  378. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
@@ -1,41 +1,49 @@
1
- // 计算函数:Selection → 一份组件数据。跑在 Node 侧,产物是算好的、可序列化的普通 JSON
2
- // (终值 + 渲染提示,不含公式);渲染面(web/text)只做展示。
3
- //
4
- // 这些函数不做顶层导出,而是挂在对应组件上(MetricTable.data / Scoreboard.data …,
5
- // 见 components.tsx):配对打点即发现,泛化名不占顶层导出。
1
+ // 计算函数(*Data):ReportInput → 一份组件数据。跑在 Node 侧,产物是算好的、可序列化的
2
+ // 普通 JSON(终值 + 渲染提示,不含公式);渲染面(web/text)只做展示。
3
+ // 它们是双面组件解析面的具名形式(MetricTable / metricTableData),与组件成对导出,
4
+ // 只住在 niceeval/report(docs/feature/reports/library.md「数据计算与缓存边界」)。
6
5
  //
7
6
  // 共同约定(docs/feature/reports/architecture.md「指标聚合不变量」):
8
- // - 第一参收 Selection | Snapshot[];收 Selection 时 warnings 随行进 OverviewData;
7
+ // - 第一参收 ReportInput = Scope | readonly Snapshot[];warnings 不进组件数据(宿主统一显示);
9
8
  // - 聚合前按身份键去重(dedupeAttempts;missing-startedAt 不去重、如实保留、不透出警告);
10
9
  // - null ≠ 0:缺数据不编数,覆盖率经 samples/total 如实暴露;
10
+ // - 显式传入的列表(questions / pairs / metrics)保留声明顺序,从数据发现的维度 domain
11
+ // 按稳定 key 字典序;
11
12
  // - core 中立:只认 Metric / Dimension 接口,不出现具体 agent 名的分支。
12
13
 
13
14
  import type {
14
15
  AttemptListItem,
15
16
  AttemptLocator,
16
17
  DeltaData,
18
+ DeltaPair,
17
19
  DimensionInput,
20
+ EntityListDataOptions,
18
21
  EvalListItem,
22
+ ExperimentComparisonData,
23
+ ExperimentComparisonGroupData,
19
24
  ExperimentListEvalRow,
20
25
  ExperimentListItem,
21
- GroupSummaryData,
26
+ FlagPairs,
22
27
  LineData,
23
28
  MatrixData,
24
29
  Metric,
25
30
  MetricCell,
26
- OverviewData,
27
- FlagRef,
31
+ NumericAxis,
32
+ ReportInput,
28
33
  ScatterData,
34
+ ScopeSummaryData,
29
35
  ScoreboardData,
30
36
  TableData,
31
- TableRowMeta,
37
+ VerdictTally,
32
38
  } from "./types.ts";
33
- import type { AssertionResult, EvalResult } from "../types.ts";
34
- import type { AttemptHandle } from "../results/types.ts";
39
+ import type { EvalResult, JsonValue } from "../types.ts";
40
+ import type { Snapshot } from "../results/types.ts";
41
+ import { comparabilityConfigOf, deepEqualJson } from "../results/select.ts";
35
42
  import { evalLevelStats, foldEvalVerdict } from "../shared/verdict.ts";
43
+ import { experimentGroupOf } from "../shared/aggregate.ts";
36
44
  import {
37
- applyAggregator,
38
45
  assertUniqueMetricNames,
46
+ axisValueOf,
39
47
  collectItems,
40
48
  computeCell,
41
49
  dimensionKey,
@@ -43,259 +51,190 @@ import {
43
51
  displayValue,
44
52
  evalGroupOf,
45
53
  evalIdOf,
46
- evalPrefixPredicate,
47
54
  evaluateMetric,
48
55
  experimentIdOf,
49
56
  filterItems,
57
+ fullEvalKey,
50
58
  groupItems,
51
- flagAxisValue,
52
59
  locatorOf,
60
+ refDisplayKey,
53
61
  resolveInput,
54
62
  snapshotKeyOf,
55
63
  toColumn,
56
64
  type Item,
57
- type SnapshotsInput,
58
65
  } from "./aggregate.ts";
59
- import { attemptCostUSD, costUSD, durationMs, examScore, passRate, tokens } from "./metrics.ts";
60
- import { formatMetricValue, formatPlainNumber } from "./format.ts";
66
+ import { attemptCostUSD, costUSD, durationMs, endToEndPassRate, examScore, tokens } from "./metrics.ts";
67
+ import { formatMetricValue, formatPlainNumber, localizedDisplay } from "./format.ts";
68
+ import { compactAssertionSummary, primaryAssertionSummary, summaryText } from "../scoring/display.ts";
69
+ import { defineMetric } from "./metrics.ts";
70
+ import type { LocalizedText } from "./locale.ts";
61
71
 
62
- // ───────────────────────── MetricTable.data ─────────────────────────
72
+ // ───────────────────────── metricTableData ─────────────────────────
63
73
 
64
- export interface TableDataOptions<M extends readonly Metric[]> {
65
- /** 行维度(内置 / 自定义 / flag())。 */
74
+ export interface MetricTableOptions {
75
+ /** 行维度(内置 / 自定义 / flag() / runConfig())。 */
66
76
  rows: DimensionInput;
67
- /** 每列一个指标;列键 = metric.name 的字面量,拼错编译不过。 */
68
- columns: M;
69
- /** 构建时排序,方向随 better(higher 降序,「好」的一头在上);缺数据行沉底。两面同口径,预排即终排。 */
77
+ /** 每列一个指标;非空元组,元素是静态 import Metric 实例。 */
78
+ columns: readonly [Metric, ...Metric[]];
79
+ /**
80
+ * 初始行序:必须是 columns 中同一个 Metric 实例且声明了 better,方向随 better
81
+ * (「好」的一头在上),缺数据行沉底;省略时按行 key 字典序。
82
+ */
70
83
  sort?: Metric;
71
- /** eval id 前缀过滤,同 CLI 位置参数语义。 */
72
- evals?: string | string[];
84
+ /** eval id 前缀过滤,同 CLI 位置参数语义;在聚合之前收窄题集。 */
85
+ evals?: string | readonly string[];
73
86
  }
74
87
 
75
- // 一组 Item eval 全身份键:experimentId + eval id。单 experiment 场景(如 experimentRowMeta,
76
- // 一组本就只有一个 experimentId)下退化为只按 eval id 折叠,与旧行为一致;多 experiment 场景
77
- // (GroupSummary 的组可能跨多个 experiment)下避免两个 experiment 里同名 eval 被误合并成一道题。
78
- // 分隔符是 NUL(同 aggregate.ts 的 KEY_SEP 手法):不会出现在 eval id / experimentId 里,拼接键不串味。
79
- const GROUP_KEY_SEP = "\u0000";
80
- function fullEvalKey(item: Item): string {
81
- return `${experimentIdOf(item)}${GROUP_KEY_SEP}${evalIdOf(item)}`;
82
- }
83
-
84
- /**
85
- * 一批 Item 的组级统计:eval 级折叠计票(evalLevelStats,与 view 榜单 / `TableRowMeta.verdicts`
86
- * 同一套 foldEvalVerdict 口径,按完整身份键折叠)、experiment/eval/attempt 数量、总成本
87
- * (null-safe 求和)、最后运行时间(组内快照 startedAt 的最大值)。`experimentRowMeta`
88
- * `groupSummaryData` 共用这一份实现,不各自拼装 evalLevelStats。
89
- *
90
- * 内部纯函数,不导出、不进 index.ts:对外只经 `experimentRowMeta`(挑 verdicts)与
91
- * `groupSummaryData`(挑全部字段,包成 `GroupSummaryData`)暴露,调用方拿不到 `Item[]`
92
- * 本身,所以这里也不用担心被越权复用。
93
- */
94
- function summarizeItems(items: Item[]): {
95
- experiments: number;
96
- evals: number;
97
- attempts: number;
98
- verdicts: { passed: number; failed: number; errored: number; skipped: number };
99
- /** 折叠后代表每个「已跑」(非 skipped)eval 的一条 attempt 引用,与 ran 同序同数。 */
100
- refs: AttemptLocator[];
101
- /** 计入通过率分母的 eval 数(passed + failed + errored,不含 skipped)。 */
102
- ran: number;
103
- totalCostUSD: number | null;
104
- lastRunAt: string | undefined;
105
- } {
106
- const experimentIds = new Set<string>();
107
- for (const item of items) experimentIds.add(experimentIdOf(item));
108
-
109
- const byEval = new Map<string, Item[]>();
110
- for (const item of items) {
111
- const key = fullEvalKey(item);
112
- const list = byEval.get(key);
113
- if (list) list.push(item);
114
- else byEval.set(key, [item]);
115
- }
116
- const stats = evalLevelStats(
117
- items.map((item) => ({ verdict: item.attempt.result.verdict, key: fullEvalKey(item) })),
118
- (r) => r.key,
119
- );
120
- // 折叠代表 attempt:每个已跑的 eval 挑一条与折叠判定一致的 attempt 做证据引用,
121
- // skipped 的 eval 不进分母、不出证据。
122
- const refs: AttemptLocator[] = [];
123
- for (const group of byEval.values()) {
124
- const verdict = foldEvalVerdict(group.map((item) => item.attempt.result));
125
- if (verdict === "skipped") continue;
126
- const rep = group.find((item) => item.attempt.result.verdict === verdict) ?? group[0]!;
127
- refs.push(locatorOf(rep));
88
+ export async function metricTableData(input: ReportInput, options: MetricTableOptions): Promise<TableData> {
89
+ assertUniqueMetricNames(options.columns, "metricTableData columns");
90
+ if (options.sort !== undefined) {
91
+ if (!options.columns.includes(options.sort)) {
92
+ throw new Error(
93
+ `metricTableData sort must be one of the Metric instances passed in columns (got "${options.sort.name}"). ` +
94
+ "Pass the same imported instance in both places so the sorted column is visible in the table.",
95
+ );
96
+ }
97
+ if (options.sort.better === undefined) {
98
+ throw new Error(
99
+ `metricTableData cannot sort by "${options.sort.name}": the metric declares no "better" direction, so there is no defined order. ` +
100
+ 'Declare better: "higher" | "lower" on the metric, or drop sort to keep the lexicographic row order.',
101
+ );
102
+ }
128
103
  }
129
-
130
- let totalCostUSD: number | null = null;
131
- for (const item of items) {
132
- const cost = attemptCostUSD(item.attempt.result);
133
- if (cost !== null) totalCostUSD = (totalCostUSD ?? 0) + cost;
104
+ const { snapshots } = resolveInput(input);
105
+ const items = filterItems(collectItems(snapshots), options.evals);
106
+ const groups = groupItems(items, options.rows);
107
+ const rows: TableData["rows"] = [];
108
+ for (const [key, group] of groups) {
109
+ const cells: Record<string, MetricCell> = {};
110
+ for (const metric of options.columns) cells[metric.name] = await computeCell(metric, group);
111
+ rows.push({ key, cells });
134
112
  }
135
-
136
- let lastRunAt: string | undefined;
137
- for (const item of items) {
138
- const startedAt = item.snapshot.startedAt;
139
- if (lastRunAt === undefined || startedAt > lastRunAt) lastRunAt = startedAt;
113
+ if (options.sort) {
114
+ const better = options.sort.better ?? "higher";
115
+ const name = options.sort.name;
116
+ rows.sort((a, b) => {
117
+ const va = a.cells[name]?.value ?? null;
118
+ const vb = b.cells[name]?.value ?? null;
119
+ if (va === null && vb === null) return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
120
+ if (va === null) return 1; // 缺数据沉底
121
+ if (vb === null) return -1;
122
+ const diff = better === "lower" ? va - vb : vb - va;
123
+ if (diff !== 0) return diff;
124
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; // 稳定排序,同值以 key 收口
125
+ });
140
126
  }
141
-
142
127
  return {
143
- experiments: experimentIds.size,
144
- evals: stats.evals,
145
- attempts: items.length,
146
- verdicts: { passed: stats.passed, failed: stats.failed, errored: stats.errored, skipped: stats.skipped },
147
- refs,
148
- ran: stats.passed + stats.failed + stats.errored,
149
- totalCostUSD,
150
- lastRunAt,
128
+ rowDimension: dimensionName(options.rows),
129
+ columns: options.columns.map(toColumn),
130
+ rows,
151
131
  };
152
132
  }
153
133
 
154
- /**
155
- * experiment 行的元信息:agent/model 身份(组内去重后拼接)+ eval 级折叠计票 + eval/attempt
156
- * 数量与最后运行时间(summarizeItems,即 view 榜单 / ExperimentList 的同一套 foldEvalVerdict
157
- * 口径)。其它行维度(agent/eval/自定义…)没有唯一身份,不携带。
158
- */
159
- function experimentRowMeta(group: Item[]): TableRowMeta {
160
- const agents = new Set<string>();
161
- const models = new Set<string>();
162
- for (const item of group) {
163
- agents.add(item.attempt.result.agent);
164
- const model = item.attempt.result.model ?? item.snapshot.model;
165
- if (model !== undefined) models.add(model);
166
- }
167
- const stats = summarizeItems(group);
134
+ // ───────────────────────── metricMatrixData(= MetricBars 的数据)─────────────────────────
135
+
136
+ export interface MetricMatrixOptions {
137
+ rows: DimensionInput;
138
+ columns: DimensionInput;
139
+ cell: Metric;
140
+ /** eval id 前缀过滤,同 CLI 位置参数语义。 */
141
+ evals?: string | readonly string[];
142
+ }
143
+
144
+ export async function metricMatrixData(input: ReportInput, options: MetricMatrixOptions): Promise<MatrixData> {
145
+ const { snapshots } = resolveInput(input);
146
+ const items = filterItems(collectItems(snapshots), options.evals);
147
+ // 稀疏分组:只有真有 attempt (row, column) 组合成格;没有样本的格子不出现
148
+ const groups = new Map<string, { row: string; column: string; items: Item[] }>();
149
+ for (const item of items) {
150
+ const row = dimensionKey(options.rows, item);
151
+ const column = dimensionKey(options.columns, item);
152
+ const key = JSON.stringify([row, column]);
153
+ const group = groups.get(key);
154
+ if (group) group.items.push(item);
155
+ else groups.set(key, { row, column, items: [item] });
156
+ }
157
+ const ordered = [...groups.values()].sort(
158
+ (a, b) => (a.row < b.row ? -1 : a.row > b.row ? 1 : a.column < b.column ? -1 : a.column > b.column ? 1 : 0),
159
+ );
160
+ const cells: MatrixData["cells"] = [];
161
+ for (const group of ordered) {
162
+ cells.push({ row: group.row, column: group.column, cell: await computeCell(options.cell, group.items) });
163
+ }
168
164
  return {
169
- ...(agents.size > 0 ? { agent: [...agents].join(", ") } : {}),
170
- ...(models.size > 0 ? { model: [...models].join(", ") } : {}),
171
- verdicts: stats.verdicts,
172
- evals: stats.evals,
173
- attempts: stats.attempts,
174
- ...(stats.lastRunAt !== undefined ? { lastRunAt: stats.lastRunAt } : {}),
165
+ rowDimension: dimensionName(options.rows),
166
+ columnDimension: dimensionName(options.columns),
167
+ metric: toColumn(options.cell),
168
+ cells,
175
169
  };
176
170
  }
177
171
 
178
- /**
179
- * 一次 attempt 未通过的 gate 断言,原始声明顺序不变;soft 断言不参与判定,不算「失败原因」,
180
- * 只影响得分,永不出现在这份列表里。`EvalList` / `ExperimentList` 的失败诊断与 `AttemptList`
181
- * 的断言列表共用这同一份材料,保证同一个 attempt 在各处给出同一个原因。
182
- */
183
- export function failingGateAssertions(result: EvalResult): AssertionResult[] {
184
- return result.assertions.filter((a) => !a.passed && a.severity === "gate");
185
- }
172
+ // ───────────────────────── 实体列表(experimentListData / evalListData / attemptListData)─────────────────────────
186
173
 
187
174
  /**
188
- * 一次 attempt 的失败原因文案,按优先级取第一个在场的:
189
- * `error` `skipReason` 未通过的 gate 断言(原始声明顺序,`name`,detail 在场则
190
- * `"name: detail"`,多条用「, 」连接) 都缺席则无原因(如某道题恰好没有失败信号)
191
- * soft 断言永不进入这份原因文案,soft 得分是独立概念,不与 reason 混用同一个字段。
175
+ * 一次 attempt 的单行结果摘要(Scoring display 契约):failed 取主失败断言摘要(不含
176
+ * "+N more",N 单独进 moreFailures),errored 取结构化 error 的一层摘要
177
+ * (phase · code · message),passed / skipped 为 null
192
178
  */
193
- export function reasonFor(result: EvalResult): string | undefined {
194
- if (result.error !== undefined) return result.error;
195
- if (result.skipReason !== undefined) return result.skipReason;
196
- const gates = failingGateAssertions(result);
197
- if (gates.length === 0) return undefined;
198
- return gates.map((a) => (a.detail ? `${a.name}: ${a.detail}` : a.name)).join(", ");
199
- }
200
-
201
- export async function tableData<const M extends readonly Metric[]>(
202
- input: SnapshotsInput,
203
- opts: TableDataOptions<M>,
204
- ): Promise<TableData<M[number]["name"]>> {
205
- assertUniqueMetricNames(opts.columns, "MetricTable.data columns");
206
- const { snapshots } = resolveInput(input);
207
- const items = filterItems(collectItems(snapshots), opts.evals);
208
- const groups = groupItems(items, opts.rows);
209
- const rows: TableData["rows"] = [];
210
- const sortCells = new Map<string, MetricCell>();
211
- for (const [key, group] of groups) {
212
- const cells: Record<string, MetricCell> = {};
213
- for (const metric of opts.columns) cells[metric.name] = await computeCell(metric, group);
214
- if (opts.sort) {
215
- // sort 指标不在 columns 里时单独算一遍,只用于排序、不进输出
216
- sortCells.set(key, cells[opts.sort.name] ?? (await computeCell(opts.sort, group)));
217
- }
218
- const meta: TableRowMeta = opts.rows === "experiment" ? experimentRowMeta(group) : {};
219
- rows.push({
220
- key,
221
- cells,
222
- ...(Object.keys(meta).length > 0 ? { meta } : {}),
223
- });
179
+ function failureSummaryOf(result: EvalResult): { summary: string | null; more: number } {
180
+ if (result.verdict === "errored" && result.error !== undefined) {
181
+ const parts = [result.error.phase, result.error.code, result.error.message].filter(
182
+ (part): part is string => typeof part === "string" && part.length > 0,
183
+ );
184
+ return { summary: summaryText(parts.join(" · ")), more: 0 };
224
185
  }
225
- if (opts.sort) {
226
- const better = opts.sort.better ?? "higher";
227
- rows.sort((a, b) => {
228
- const va = sortCells.get(a.key)?.value ?? null;
229
- const vb = sortCells.get(b.key)?.value ?? null;
230
- if (va === null && vb === null) return 0;
231
- if (va === null) return 1; // 缺数据沉底
232
- if (vb === null) return -1;
233
- return better === "lower" ? va - vb : vb - va;
234
- });
186
+ if (result.verdict === "failed" || result.verdict === "errored") {
187
+ const primary = primaryAssertionSummary(result.assertions, result.verdict);
188
+ if (primary !== undefined) {
189
+ return {
190
+ summary: compactAssertionSummary({ ...primary, additionalFailures: 0 }),
191
+ more: primary.additionalFailures,
192
+ };
193
+ }
194
+ if (result.verdict === "errored" && result.skipReason !== undefined) {
195
+ return { summary: summaryText(result.skipReason), more: 0 };
196
+ }
197
+ return { summary: null, more: 0 };
235
198
  }
236
- return {
237
- dimension: dimensionName(opts.rows),
238
- columns: opts.columns.map(toColumn),
239
- rows,
240
- } as TableData<M[number]["name"]>;
199
+ return { summary: null, more: 0 };
241
200
  }
242
201
 
243
- // ───────────────────────── ExperimentList.data / EvalList.data / AttemptList.data ─────────────────────────
244
- //
245
- // 三个实体列表逐级下钻(experiment → experimentId × eval → attempt),固定展示实体事实,
246
- // 没有列配置;过滤是报告作者对返回数组调用 .filter()/.slice() 的事,不进这里
247
- // (docs/feature/reports/library.md「实体列表」)。AttemptListItem 是三者共用的叶子形状——
248
- // ExperimentList / EvalList 的下钻数组直接复用它,不各自精简一份。
249
-
250
- /** 自由文本(error / 断言 detail / evidence)的发布消毒钩子;身份字段(name/severity/loc)不经它。 */
251
- function redactAssertions(assertions: AssertionResult[], redact: (text: string) => string): AssertionResult[] {
252
- if (assertions.length === 0) return assertions;
253
- return assertions.map((a) => ({
254
- ...a,
255
- ...(a.detail !== undefined ? { detail: redact(a.detail) } : {}),
256
- ...(a.evidence !== undefined ? { evidence: redact(a.evidence) } : {}),
257
- }));
258
- }
202
+ const identityRedact = (text: string): string => text;
259
203
 
260
204
  /** AttemptList / ExperimentList / EvalList 共用的叶子构造:一个 Item → 一个 AttemptListItem。 */
261
- function attemptListItemOf(item: Item, redact: (text: string) => string): AttemptListItem {
205
+ async function attemptListItemOf(item: Item, redact: (text: string) => string): Promise<AttemptListItem> {
262
206
  const result = item.attempt.result;
263
- const cost = attemptCostUSD(result);
207
+ const { summary, more } = failureSummaryOf(result);
264
208
  return {
265
- evalId: evalIdOf(item),
266
209
  experimentId: experimentIdOf(item),
210
+ evalId: evalIdOf(item),
267
211
  attempt: result.attempt,
268
212
  agent: result.agent,
269
213
  verdict: result.verdict,
270
- ...(result.error !== undefined ? { error: redact(result.error) } : {}),
271
- assertions: redactAssertions(result.assertions, redact),
214
+ failureSummary: summary === null ? null : redact(summary),
215
+ moreFailures: more,
216
+ examScore: await computeCell(examScore, [item]),
272
217
  durationMs: result.durationMs,
273
- ...(cost !== null ? { costUSD: cost } : {}),
218
+ costUSD: attemptCostUSD(result),
274
219
  locator: locatorOf(item),
275
220
  };
276
221
  }
277
222
 
278
- const identityRedact = (text: string): string => text;
279
-
280
- export interface AttemptListDataOptions {
281
- /** 发布消毒:error / 断言 detail / evidence 经这个钩子;身份字段(experimentId/evalId/locator…)不经它。 */
282
- redact?: (text: string) => string;
283
- }
284
-
285
- /** `AttemptList.data(selection)`:每个 Attempt 一项,顺序取自 Selection 展平顺序(不重排)。 */
223
+ /** `attemptListData(input)`:每个 Attempt 一项,顺序取自 Scope 展平顺序(不重排) */
286
224
  export async function attemptListData(
287
- input: SnapshotsInput,
288
- opts?: AttemptListDataOptions,
225
+ input: ReportInput,
226
+ options?: EntityListDataOptions,
289
227
  ): Promise<AttemptListItem[]> {
290
228
  const { snapshots } = resolveInput(input);
291
- const redact = opts?.redact ?? identityRedact;
229
+ const redact = options?.redact ?? identityRedact;
292
230
  const items = collectItems(snapshots);
293
- return items.map((item) => attemptListItemOf(item, redact));
231
+ return Promise.all(items.map((item) => attemptListItemOf(item, redact)));
294
232
  }
295
233
 
296
- /** `EvalList.data(selection)`:每个 `experimentId + evalId` 一项,按 evalId 再按 experimentId 升序。 */
297
- export async function evalListData(input: SnapshotsInput): Promise<EvalListItem[]> {
234
+ /** `evalListData(input)`:每个 `experimentId + evalId` 一项,按 evalId 再按 experimentId 升序。 */
235
+ export async function evalListData(input: ReportInput, options?: EntityListDataOptions): Promise<EvalListItem[]> {
298
236
  const { snapshots } = resolveInput(input);
237
+ const redact = options?.redact ?? identityRedact;
299
238
  const items = collectItems(snapshots);
300
239
  const groups = new Map<string, Item[]>();
301
240
  for (const item of items) {
@@ -308,16 +247,14 @@ export async function evalListData(input: SnapshotsInput): Promise<EvalListItem[
308
247
  for (const group of groups.values()) {
309
248
  const sorted = [...group].sort((a, b) => a.attempt.result.attempt - b.attempt.result.attempt);
310
249
  const verdict = foldEvalVerdict(sorted.map((item) => item.attempt.result));
311
- const representative = sorted.find((item) => item.attempt.result.verdict === verdict) ?? sorted[0]!;
312
- const attempts = sorted.map((item) => attemptListItemOf(item, identityRedact));
250
+ const attempts = await Promise.all(sorted.map((item) => attemptListItemOf(item, redact)));
313
251
  out.push({
314
- evalId: evalIdOf(sorted[0]!),
315
252
  experimentId: experimentIdOf(sorted[0]!),
253
+ evalId: evalIdOf(sorted[0]!),
316
254
  verdict,
317
- reason: reasonFor(representative.attempt.result),
318
- score: await computeCell(examScore, sorted),
319
- duration: await computeCell(durationMs, sorted),
320
- cost: await computeCell(costUSD, sorted),
255
+ examScore: await computeCell(examScore, sorted),
256
+ durationMs: await computeCell(durationMs, sorted),
257
+ costUSD: await computeCell(costUSD, sorted),
321
258
  attempts,
322
259
  });
323
260
  }
@@ -325,9 +262,37 @@ export async function evalListData(input: SnapshotsInput): Promise<EvalListItem[
325
262
  return out;
326
263
  }
327
264
 
328
- /** `ExperimentList.data(selection)`:每个 experiment 一项,按 experimentId 升序;展开到每道 Eval。 */
329
- export async function experimentListData(input: SnapshotsInput): Promise<ExperimentListItem[]> {
265
+ /**
266
+ * `experimentListData(input)`:每个 experiment 一项,展开到每道 Eval;初始按端到端成功率
267
+ * 从高到低(缺数据沉底,同分按 id)。一行只有一套 agent / model / flags 是输入约束:
268
+ * 宿主注入的 current() Scope 保证每个 experiment 只由可比性配置一致的快照拼成;作者自选
269
+ * Snapshot[] 时若同一 experiment 混入不一致的可比性配置,按完整用户反馈失败并指引——
270
+ * 看跨配置演化用 snapshot 维度或 MetricLine,不把两套配置拼成一行冒充单一配置。
271
+ */
272
+ export async function experimentListData(
273
+ input: ReportInput,
274
+ options?: EntityListDataOptions,
275
+ ): Promise<ExperimentListItem[]> {
330
276
  const { snapshots } = resolveInput(input);
277
+ const redact = options?.redact ?? identityRedact;
278
+
279
+ // 可比性配置单义检查:同一 experiment 的输入快照必须共享一套可比性配置。
280
+ const configByExperiment = new Map<string, { snapshot: Snapshot; config: unknown }>();
281
+ for (const snapshot of snapshots) {
282
+ const config = comparabilityConfigOf(snapshot);
283
+ const existing = configByExperiment.get(snapshot.experimentId);
284
+ if (existing === undefined) {
285
+ configByExperiment.set(snapshot.experimentId, { snapshot, config });
286
+ } else if (!deepEqualJson(existing.config, config)) {
287
+ throw new Error(
288
+ `experimentListData got inconsistent comparability configs for experiment "${snapshot.experimentId}" ` +
289
+ `(snapshots ${existing.snapshot.startedAt} and ${snapshot.startedAt} differ in agent/model/reasoningEffort/flags/budget/timeoutMs/sandbox). ` +
290
+ "One row shows one configuration — it cannot honestly merge two. To chart evolution across configs, " +
291
+ 'use the "snapshot" dimension or MetricLine; to show the current level, pass results.current() which selects a single config per experiment.',
292
+ );
293
+ }
294
+ }
295
+
331
296
  const items = collectItems(snapshots);
332
297
  const groups = groupItems(items, "experiment");
333
298
  const out: ExperimentListItem[] = [];
@@ -339,30 +304,26 @@ export async function experimentListData(input: SnapshotsInput): Promise<Experim
339
304
  for (const [evalId, evalItems] of evalGroups) {
340
305
  const sorted = [...evalItems].sort((a, b) => a.attempt.result.attempt - b.attempt.result.attempt);
341
306
  const verdict = foldEvalVerdict(sorted.map((item) => item.attempt.result));
342
- const representative = sorted.find((item) => item.attempt.result.verdict === verdict) ?? sorted[0]!;
343
- const attempts = sorted.map((item) => attemptListItemOf(item, identityRedact));
307
+ const attempts = await Promise.all(sorted.map((item) => attemptListItemOf(item, redact)));
344
308
  evalRows.push({
345
309
  evalId,
346
310
  verdict,
347
- reason: reasonFor(representative.attempt.result),
348
- duration: await computeCell(durationMs, sorted),
349
- cost: await computeCell(costUSD, sorted),
311
+ durationMs: await computeCell(durationMs, sorted),
312
+ costUSD: await computeCell(costUSD, sorted),
350
313
  attempts,
351
314
  });
352
315
  }
353
- evalRows.sort((a, b) => a.evalId.localeCompare(b.evalId));
354
316
  const experiment = newest.snapshot.experiment ?? newest.attempt.result.experiment;
317
+ const model = newest.attempt.result.model ?? newest.snapshot.model;
355
318
  out.push({
356
319
  experimentId,
357
- agent: newest.snapshot.agent,
358
- ...((newest.attempt.result.model ?? newest.snapshot.model) !== undefined
359
- ? { model: newest.attempt.result.model ?? newest.snapshot.model }
360
- : {}),
320
+ agent: newest.snapshot.agent || newest.attempt.result.agent,
321
+ ...(model !== undefined ? { model } : {}),
361
322
  ...(experiment?.flags ? { flags: experiment.flags } : {}),
362
- verdicts: stats.verdicts,
363
- passRate: await computeCell(passRate, group),
364
- cost: await computeCell(costUSD, group),
365
- duration: await computeCell(durationMs, group),
323
+ evalVerdicts: stats.verdicts,
324
+ endToEndPassRate: await computeCell(endToEndPassRate, group),
325
+ costUSD: await computeCell(costUSD, group),
326
+ durationMs: await computeCell(durationMs, group),
366
327
  tokens: await computeCell(tokens, group),
367
328
  evals: stats.evals,
368
329
  attempts: stats.attempts,
@@ -370,382 +331,653 @@ export async function experimentListData(input: SnapshotsInput): Promise<Experim
370
331
  evalRows,
371
332
  });
372
333
  }
373
- // ExperimentList 是默认实验比较表:初始态按成功率从高到低,缺数据沉底;
374
- // 同分时按 experiment id 稳定排序。web 增强可临时重排,text 面沿用同一基准顺序。
334
+ // 初始态按端到端成功率(endToEndPassRate)从高到低,缺数据沉底;同分按 experiment id 稳定排序。
375
335
  out.sort((a, b) => {
376
- if (a.passRate.value === null && b.passRate.value === null) return a.experimentId.localeCompare(b.experimentId);
377
- if (a.passRate.value === null) return 1;
378
- if (b.passRate.value === null) return -1;
379
- return b.passRate.value - a.passRate.value || a.experimentId.localeCompare(b.experimentId);
336
+ const va = a.endToEndPassRate.value;
337
+ const vb = b.endToEndPassRate.value;
338
+ if (va === null && vb === null) return a.experimentId.localeCompare(b.experimentId);
339
+ if (va === null) return 1;
340
+ if (vb === null) return -1;
341
+ return vb - va || a.experimentId.localeCompare(b.experimentId);
380
342
  });
381
343
  return out;
382
344
  }
383
345
 
384
- // ───────────────────────── MetricMatrix.data(= MetricBars.data)─────────────────────────
346
+ // ───────────────────────── scopeSummaryData ─────────────────────────
385
347
 
386
- export interface MatrixDataOptions {
387
- rows: DimensionInput;
388
- columns: DimensionInput;
389
- cell: Metric;
390
- /** eval id 前缀过滤,同 CLI 位置参数语义。 */
391
- evals?: string | string[];
348
+ /** costUSD 的求和投影:两级都 sum(题内多轮求和 + 跨题求和 = 全量求和),display 走 $。 */
349
+ const totalCostMetric = defineMetric({
350
+ name: "total-cost",
351
+ label: costUSD.label,
352
+ unit: "$",
353
+ value: costUSD.value,
354
+ aggregate: { perEval: "sum", acrossEvals: "sum" },
355
+ });
356
+
357
+ function tallyOf(): VerdictTally {
358
+ return { passed: 0, failed: 0, errored: 0, skipped: 0 };
392
359
  }
393
360
 
394
- export async function matrixData(input: SnapshotsInput, opts: MatrixDataOptions): Promise<MatrixData> {
395
- const { snapshots } = resolveInput(input);
396
- const items = filterItems(collectItems(snapshots), opts.evals);
397
- // 稀疏分组:只有真有 attempt 的 (row, column) 组合成格;没有样本的格子不出现
398
- const groups = new Map<string, { row: string; column: string; items: Item[] }>();
361
+ /** 一批 Item 的组级统计(experimentListData / scopeSummaryData 共用) */
362
+ function summarizeItems(items: Item[]): {
363
+ experiments: number;
364
+ evals: number;
365
+ attempts: number;
366
+ verdicts: VerdictTally;
367
+ lastRunAt: string | undefined;
368
+ } {
369
+ const experimentIds = new Set<string>();
370
+ for (const item of items) experimentIds.add(experimentIdOf(item));
371
+ const stats = evalLevelStats(
372
+ items.map((item) => ({ verdict: item.attempt.result.verdict, key: fullEvalKey(item) })),
373
+ (r) => r.key,
374
+ );
375
+ let lastRunAt: string | undefined;
399
376
  for (const item of items) {
400
- const row = dimensionKey(opts.rows, item);
401
- const column = dimensionKey(opts.columns, item);
402
- const key = JSON.stringify([row, column]);
403
- const group = groups.get(key);
404
- if (group) group.items.push(item);
405
- else groups.set(key, { row, column, items: [item] });
377
+ const startedAt = item.snapshot.startedAt;
378
+ if (lastRunAt === undefined || startedAt > lastRunAt) lastRunAt = startedAt;
406
379
  }
407
- const cells: MatrixData["cells"] = [];
408
- for (const group of groups.values()) {
409
- cells.push({ row: group.row, column: group.column, cell: await computeCell(opts.cell, group.items) });
380
+ return {
381
+ experiments: experimentIds.size,
382
+ evals: stats.evals,
383
+ attempts: items.length,
384
+ verdicts: { passed: stats.passed, failed: stats.failed, errored: stats.errored, skipped: stats.skipped },
385
+ lastRunAt,
386
+ };
387
+ }
388
+
389
+ /**
390
+ * `scopeSummaryData(input)`:范围摘要——快照时间窗、experiment / eval / attempt 数、
391
+ * 两级判定计票、端到端成功率与总成本(docs/feature/reports/library/summaries.md)。
392
+ * data 恒携带两级计票;成功率来自官方两级指标引擎,不从任一计票重算。
393
+ */
394
+ export async function scopeSummaryData(input: ReportInput): Promise<ScopeSummaryData> {
395
+ const { snapshots } = resolveInput(input);
396
+ const items = collectItems(snapshots);
397
+
398
+ let earliest: string | null = null;
399
+ let latest: string | null = null;
400
+ for (const snapshot of snapshots) {
401
+ if (earliest === null || snapshot.startedAt < earliest) earliest = snapshot.startedAt;
402
+ if (latest === null || snapshot.startedAt > latest) latest = snapshot.startedAt;
410
403
  }
404
+
405
+ const stats = summarizeItems(items);
406
+ const attemptVerdicts = tallyOf();
407
+ for (const item of items) attemptVerdicts[item.attempt.result.verdict] += 1;
408
+
411
409
  return {
412
- rows: dimensionName(opts.rows),
413
- columns: dimensionName(opts.columns),
414
- metric: toColumn(opts.cell),
415
- cells,
410
+ range: { earliestStartedAt: earliest, latestStartedAt: latest },
411
+ experiments: stats.experiments,
412
+ evals: stats.evals,
413
+ attempts: stats.attempts,
414
+ evalVerdicts: stats.verdicts,
415
+ attemptVerdicts,
416
+ endToEndPassRate: await computeCell(endToEndPassRate, items),
417
+ totalCostUSD: await computeCell(totalCostMetric, items),
416
418
  };
417
419
  }
418
420
 
419
- // ───────────────────────── Scoreboard.data ─────────────────────────
421
+ // ───────────────────────── experimentComparisonData ─────────────────────────
422
+
423
+ /** 完整父路径是组键;没有父路径的 experiment 不能互相比,自己形成单例组。 */
424
+ export function experimentComparisonGroupKey(experimentId: string): string {
425
+ return experimentGroupOf(experimentId) ?? experimentId;
426
+ }
427
+
428
+ /** 每组散点的唯一口径:默认 definition 与公开计算共用,不各写一份。 */
429
+ const COMPARISON_SCATTER_OPTIONS: MetricScatterOptions = {
430
+ points: "experiment",
431
+ series: "agent",
432
+ x: costUSD,
433
+ y: endToEndPassRate,
434
+ };
435
+
436
+ /**
437
+ * `experimentComparisonData(input)`:先把 input 按可比组分区(experiment id 的完整父路径),
438
+ * 再为每组分别计算 ScopeSummary、成本 × 端到端成功率散点和 ExperimentList——分区发生在任何
439
+ * 指标计算之前,组外 attempt 不可能污染该组的坐标尺度、series、成功率、成本、排序或缺数据计数。
440
+ */
441
+ export async function experimentComparisonData(input: ReportInput): Promise<ExperimentComparisonData> {
442
+ const { snapshots } = resolveInput(input);
443
+ const snapshotsByGroup = new Map<string, Snapshot[]>();
444
+ for (const snapshot of snapshots) {
445
+ const key = experimentComparisonGroupKey(snapshot.experimentId);
446
+ const group = snapshotsByGroup.get(key);
447
+ if (group) group.push(snapshot);
448
+ else snapshotsByGroup.set(key, [snapshot]);
449
+ }
450
+ const groups = await Promise.all(
451
+ [...snapshotsByGroup.entries()]
452
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
453
+ .map(async ([key, groupSnapshots]): Promise<ExperimentComparisonGroupData> => {
454
+ const [summary, scatter, experiments] = await Promise.all([
455
+ scopeSummaryData(groupSnapshots),
456
+ metricScatterData(groupSnapshots, COMPARISON_SCATTER_OPTIONS),
457
+ experimentListData(groupSnapshots),
458
+ ]);
459
+ return { key, summary, scatter, experiments };
460
+ }),
461
+ );
462
+ return { groups };
463
+ }
464
+
465
+ // ───────────────────────── scoreboardData ─────────────────────────
420
466
 
421
- export interface ScoreboardDataOptions {
422
- /** 给谁打分(被打分的维度);维度槽与 MetricTable.data 统一叫 rows。 */
467
+ export interface ScoreboardOptions {
423
468
  rows: DimensionInput;
424
- /** 按什么分科;默认 "evalGroup"(考试里的「科目」)。 */
425
- subjects?: DimensionInput;
426
- /** eval id 前缀 每题分值;未列默认 1;前缀重叠时最长的生效。 */
427
- weights?: Record<string, number>;
428
- /** 折算满分;默认 100。 */
469
+ /** 固定题集;eval id 必须唯一。元素引用运行时数据,类型放宽为普通数组,空数组在计算时报错。 */
470
+ questions: readonly string[];
471
+ /** 分科函数;默认与 evalGroup 维度同一条规则:取 eval id 的完整父路径,无 `/` 取完整 id。 */
472
+ subject?: (evalId: string) => string;
473
+ /** 权重按 eval id 前缀匹配,多个命中时最长前缀生效;默认 1。 */
474
+ weights?: Readonly<Record<string, number>>;
429
475
  fullMarks?: number;
430
- /** 每题得分指标;缺省即 examScore,可换自定义(如「答对但超预算扣分」)。 */
431
476
  score?: Metric;
432
- /** 选中范围:eval id 前缀过滤;题集(分母)只遍历这个范围。 */
433
- evals?: string | string[];
434
477
  }
435
478
 
436
479
  /**
437
- * 逐题分值制,分母对所有被打分者恒定:
438
- * 题分值 = 命中的权重(默认 1) 题得分 = score 指标的题级值(perEval 折叠后)
439
- * 总分 = fullMarks × Σ(题得分 × 题分值) / Σ(题分值) Σ 遍历选中范围内全部题
440
- * 没跑到的题挣 0 分但留在分母里,missing 如实报 —— 这是显式的考试契约,不是「null ≠ 0」的例外。
480
+ * 固定题集分母:未跑题按 0 分计入 `notRun`,跑了但指标为 null 的题按 0 分计入 `unscorable`,
481
+ * 两个计数不合并——成绩单能回答「这 0 分是没去考还是考了判不了」。组件不从已观测 attempt
482
+ * 的并集猜分母;Scope 中题集之外的 eval 被忽略并计入 `ignoredEvals`。
441
483
  */
442
- export async function scoreboardData(
443
- input: SnapshotsInput,
444
- opts: ScoreboardDataOptions,
445
- ): Promise<ScoreboardData> {
446
- const { snapshots } = resolveInput(input);
447
- const fullMarks = opts.fullMarks ?? 100;
448
- const scoreMetric = opts.score ?? examScore;
449
- const subjectsDim: DimensionInput = opts.subjects ?? "evalGroup";
450
- const match = evalPrefixPredicate(opts.evals);
451
- const items = filterItems(collectItems(snapshots), opts.evals);
452
-
453
- // 题集(固定分母):选中范围内、任一快照声明覆盖或实际出现过的全部题
454
- const universe = new Set<string>();
455
- for (const snapshot of snapshots) {
456
- for (const e of snapshot.evals) if (match(e.id)) universe.add(e.id);
457
- for (const id of snapshot.knownEvalIds ?? []) if (match(id)) universe.add(id);
484
+ export async function scoreboardData(input: ReportInput, options: ScoreboardOptions): Promise<ScoreboardData> {
485
+ const questions = options.questions;
486
+ if (!Array.isArray(questions) || questions.length === 0) {
487
+ throw new Error(
488
+ "scoreboardData questions must be a non-empty list of eval ids: the fixed question set is the denominator, and an empty denominator makes no scoreboard. " +
489
+ "Pass the eval ids to grade, or filter your source list before passing it.",
490
+ );
458
491
  }
459
- for (const item of items) universe.add(evalIdOf(item));
460
- const sortedUniverse = [...universe].sort();
492
+ const seen = new Set<string>();
493
+ for (const q of questions) {
494
+ if (seen.has(q)) {
495
+ throw new Error(
496
+ `scoreboardData questions contains "${q}" twice — each question is one denominator slot; remove the duplicate.`,
497
+ );
498
+ }
499
+ seen.add(q);
500
+ }
501
+ const fullMarks = options.fullMarks ?? 100;
502
+ if (!Number.isFinite(fullMarks) || fullMarks <= 0) {
503
+ throw new Error(`scoreboardData fullMarks must be a positive finite number (got ${String(fullMarks)}).`);
504
+ }
505
+ const weightEntries = Object.entries(options.weights ?? {});
506
+ for (const [prefix, weight] of weightEntries) {
507
+ if (prefix.length === 0) {
508
+ throw new Error('scoreboardData weights contains an empty prefix ""; weight prefixes must be non-empty eval id prefixes.');
509
+ }
510
+ if (!Number.isFinite(weight) || weight <= 0) {
511
+ throw new Error(
512
+ `scoreboardData weight for prefix "${prefix}" must be a positive finite number (got ${String(weight)}).`,
513
+ );
514
+ }
515
+ }
516
+ const scoreMetric = options.score ?? examScore;
517
+ const subjectOf = options.subject ?? evalGroupOf;
461
518
 
462
- // 每题的科目:先从任一 attempt 解析(自定义 subjects 维度也能算);
463
- // 全程无 attempt 的题按内置规则兜底,自定义维度无从计算时如实标 "(unknown)"
464
- const subjectByEval = new Map<string, string>();
465
- for (const item of items) {
519
+ const { snapshots } = resolveInput(input);
520
+ const allItems = collectItems(snapshots);
521
+ const questionSet = new Set(questions);
522
+ const items = allItems.filter((item) => questionSet.has(evalIdOf(item)));
523
+ const ignored = new Set<string>();
524
+ for (const item of allItems) {
466
525
  const id = evalIdOf(item);
467
- if (!subjectByEval.has(id)) subjectByEval.set(id, dimensionKey(subjectsDim, item));
468
- }
469
- const subjectOf = (id: string): string => {
470
- const known = subjectByEval.get(id);
471
- if (known !== undefined) return known;
472
- if (subjectsDim === "eval") return id;
473
- if (subjectsDim === "evalGroup") return evalGroupOf(id);
474
- return "(unknown)";
475
- };
526
+ if (!questionSet.has(id)) ignored.add(id);
527
+ }
476
528
 
477
529
  // 权重:最长前缀生效(排序后线性找第一个命中即最长)
478
- const weights = Object.entries(opts.weights ?? {})
530
+ const weights = weightEntries
479
531
  .map(([prefix, weight]) => ({ prefix, weight }))
480
532
  .sort((a, b) => b.prefix.length - a.prefix.length);
481
533
  const weightOf = (id: string): number => weights.find((w) => id.startsWith(w.prefix))?.weight ?? 1;
482
534
 
483
- const groups = groupItems(items, opts.rows);
535
+ const subjectByQuestion = new Map<string, string>();
536
+ for (const id of questions) {
537
+ const subject = subjectOf(id);
538
+ if (typeof subject !== "string" || subject.length === 0) {
539
+ throw new Error(
540
+ `scoreboardData subject("${id}") returned an empty value; every question must map to a non-empty subject name.`,
541
+ );
542
+ }
543
+ subjectByQuestion.set(id, subject);
544
+ }
545
+
546
+ const groups = groupItems(items, options.rows);
484
547
  const rows: ScoreboardData["rows"] = [];
485
548
  for (const [key, group] of groups) {
486
- // 题得分:perEval 折叠(同 eval × 快照 内);同题出现在多个快照时取快照级值的均值
487
- const perSnapshot = new Map<string, Map<string, number[]>>(); // evalId → 快照键 → 原始值
549
+ const byQuestion = new Map<string, Item[]>();
488
550
  for (const item of group) {
489
- const value = await evaluateMetric(scoreMetric, item.attempt);
490
- if (value === null) continue; // 测不了的 attempt 不进题得分;整题无样本 → missing
491
551
  const id = evalIdOf(item);
492
- const snapKey = snapshotKeyOf(item.snapshot);
493
- let bySnap = perSnapshot.get(id);
494
- if (!bySnap) perSnapshot.set(id, (bySnap = new Map()));
495
- const bucket = bySnap.get(snapKey);
496
- if (bucket) bucket.push(value);
497
- else bySnap.set(snapKey, [value]);
498
- }
499
- const perEvalAgg = scoreMetric.aggregate?.perEval ?? "mean";
500
- const scoreByEval = new Map<string, number>();
501
- for (const [id, bySnap] of perSnapshot) {
502
- const snapValues = [...bySnap.values()].map((values) => applyAggregator(perEvalAgg, values));
503
- scoreByEval.set(id, snapValues.reduce((a, b) => a + b, 0) / snapValues.length);
552
+ const list = byQuestion.get(id);
553
+ if (list) list.push(item);
554
+ else byQuestion.set(id, [item]);
504
555
  }
505
556
 
506
- // 科目累计:固定分母 —— 没跑的题 0 分挣、留在分母、计入 missing
507
557
  const subjects = new Map<
508
558
  string,
509
- { key: string; earned: number; possible: number; evals: number; missing: number }
559
+ {
560
+ key: string;
561
+ earned: number;
562
+ possible: number;
563
+ questions: number;
564
+ notRun: number;
565
+ unscorable: number;
566
+ refs: Set<AttemptLocator>;
567
+ }
510
568
  >();
511
- for (const id of sortedUniverse) {
512
- const subjectKey = subjectOf(id);
569
+ const totalRefs = new Set<AttemptLocator>();
570
+ for (const id of questions) {
571
+ const subjectKey = subjectByQuestion.get(id)!;
513
572
  let subject = subjects.get(subjectKey);
514
573
  if (!subject) {
515
- subjects.set(subjectKey, (subject = { key: subjectKey, earned: 0, possible: 0, evals: 0, missing: 0 }));
574
+ subjects.set(
575
+ subjectKey,
576
+ (subject = { key: subjectKey, earned: 0, possible: 0, questions: 0, notRun: 0, unscorable: 0, refs: new Set() }),
577
+ );
516
578
  }
517
579
  const weight = weightOf(id);
518
- const got = scoreByEval.get(id);
519
- subject.earned += (got ?? 0) * weight;
520
580
  subject.possible += weight;
521
- subject.evals += 1;
522
- if (got === undefined) subject.missing += 1;
581
+ subject.questions += 1;
582
+ const questionItems = byQuestion.get(id);
583
+ if (questionItems === undefined) {
584
+ subject.notRun += 1;
585
+ continue;
586
+ }
587
+ for (const item of questionItems) {
588
+ const locator = locatorOf(item);
589
+ subject.refs.add(locator);
590
+ totalRefs.add(locator);
591
+ }
592
+ const cell = await computeCell(scoreMetric, questionItems);
593
+ if (cell.value === null) {
594
+ subject.unscorable += 1;
595
+ continue;
596
+ }
597
+ if (cell.value < 0 || cell.value > 1) {
598
+ throw new Error(
599
+ `scoreboardData score metric "${scoreMetric.name}" produced ${cell.value} for eval "${id}" — scores must stay in [0, 1] so weighted totals stay auditable. Normalize the metric, or use a different score metric.`,
600
+ );
601
+ }
602
+ subject.earned += cell.value * weight;
523
603
  }
604
+
524
605
  let earned = 0;
525
606
  let possible = 0;
607
+ let notRun = 0;
608
+ let unscorable = 0;
526
609
  for (const subject of subjects.values()) {
527
610
  earned += subject.earned;
528
611
  possible += subject.possible;
612
+ notRun += subject.notRun;
613
+ unscorable += subject.unscorable;
529
614
  }
530
615
  const value = possible === 0 ? 0 : (fullMarks * earned) / possible;
531
- rows.push({ key, total: { value, display: formatPlainNumber(value) }, subjects: [...subjects.values()] });
616
+ rows.push({
617
+ key,
618
+ total: {
619
+ value,
620
+ display: formatPlainNumber(value),
621
+ notRun,
622
+ unscorable,
623
+ refs: [...totalRefs].sort(),
624
+ },
625
+ subjects: [...subjects.values()].map((subject) => ({
626
+ key: subject.key,
627
+ earned: subject.earned,
628
+ possible: subject.possible,
629
+ questions: subject.questions,
630
+ notRun: subject.notRun,
631
+ unscorable: subject.unscorable,
632
+ display: subjectDisplay(subject.earned, subject.possible),
633
+ refs: [...subject.refs].sort(),
634
+ })),
635
+ });
532
636
  }
533
637
 
534
- return { dimension: dimensionName(opts.rows), fullMarks, weights, rows };
638
+ return {
639
+ rowDimension: dimensionName(options.rows),
640
+ questions: [...questions],
641
+ fullMarks,
642
+ weights,
643
+ ignoredEvals: ignored.size,
644
+ rows,
645
+ };
646
+ }
647
+
648
+ /** 分科显示:earned / possible 与同尺度百分比。 */
649
+ function subjectDisplay(earned: number, possible: number): LocalizedText {
650
+ const ratio = possible === 0 ? 0 : earned / possible;
651
+ return `${formatPlainNumber(earned)}/${formatPlainNumber(possible)} (${formatMetricValue(ratio, "%")})`;
535
652
  }
536
653
 
537
- // ───────────────────────── MetricScatter.data ─────────────────────────
654
+ // ───────────────────────── metricScatterData ─────────────────────────
538
655
 
539
- export interface ScatterDataOptions {
656
+ export interface MetricScatterOptions {
540
657
  /** 点维度:每个点 = 该组 attempt 的聚合。 */
541
658
  points: DimensionInput;
542
- /** 可选:同系列的点连成线;省略 = 纯散点。 */
659
+ /** 可选:只决定颜色和分组,默认不连线。 */
543
660
  series?: DimensionInput;
544
661
  x: Metric;
545
662
  y: Metric;
663
+ /** eval id 前缀过滤,同 CLI 位置参数语义。 */
664
+ evals?: string | readonly string[];
546
665
  }
547
666
 
548
- export async function scatterData(input: SnapshotsInput, opts: ScatterDataOptions): Promise<ScatterData> {
667
+ export async function metricScatterData(input: ReportInput, options: MetricScatterOptions): Promise<ScatterData> {
549
668
  const { snapshots } = resolveInput(input);
550
- const items = collectItems(snapshots);
551
- const groups = groupItems(items, opts.points);
669
+ const items = filterItems(collectItems(snapshots), options.evals);
670
+ const groups = groupItems(items, options.points);
552
671
  const rows: ScatterData["rows"] = [];
553
672
  for (const [key, group] of groups) {
554
673
  rows.push({
555
674
  key,
556
675
  // 组内取第一条解析系列:点维度细于系列维度时(experiment ⊂ agent)天然一致
557
- series: opts.series ? dimensionKey(opts.series, group[0]) : undefined,
558
- x: await computeCell(opts.x, group),
559
- y: await computeCell(opts.y, group), // 任一轴 null 的点留在 rows 里:组件不画,但注脚要报的数就从这里数
676
+ ...(options.series ? { series: dimensionKey(options.series, group[0]!) } : {}),
677
+ x: await computeCell(options.x, group),
678
+ y: await computeCell(options.y, group), // 任一轴 null 的点留在 rows 里:组件不画,但注脚要报的数就从这里数
560
679
  });
561
680
  }
562
681
  return {
563
- points: dimensionName(opts.points),
564
- series: opts.series ? dimensionName(opts.series) : undefined,
565
- x: toColumn(opts.x),
566
- y: toColumn(opts.y),
682
+ pointDimension: dimensionName(options.points),
683
+ ...(options.series ? { seriesDimension: dimensionName(options.series) } : {}),
684
+ x: toColumn(options.x),
685
+ y: toColumn(options.y),
567
686
  rows,
568
687
  };
569
688
  }
570
689
 
571
- // ───────────────────────── MetricLine.data ─────────────────────────
690
+ // ───────────────────────── metricLineData ─────────────────────────
572
691
 
573
- export interface LineDataOptions {
574
- /** x 轴:experiment 声明的 flag(数值),不解析 experiment 命名。 */
575
- x: FlagRef;
576
- y: Metric;
577
- /** 可选:每个系列一条线(flag 或普通维度);省略 = 单系列。 */
692
+ export interface MetricLineOptions {
693
+ /** x 轴:NumericAxis(numericFlag() / numericRunConfig() 或自定义 of),不解析 experiment 命名。 */
694
+ x: NumericAxis;
578
695
  series?: DimensionInput;
696
+ y: Metric;
697
+ /** eval id 前缀过滤,同 CLI 位置参数语义。 */
698
+ evals?: string | readonly string[];
579
699
  }
580
700
 
581
- /** 每个点 = 一个 experiment 的聚合;同系列的点按 x 排序连线(排序在组件面,数据保持分组序)。 */
582
- export async function lineData(input: SnapshotsInput, opts: LineDataOptions): Promise<LineData> {
701
+ /**
702
+ * 点身份 = (series, x):落进同一桶的全部 attempt 先在各自 experiment × eval 内 perEval 聚合,
703
+ * 再 acrossEvals 跨题折成该点唯一的 y——聚合顺序是 (series, x, experiment, eval),同一桶里有
704
+ * 多个 experiment 时它们合成一个点,不画垂直来回线。前提是 x 在同一 experiment × eval 内恒定:
705
+ * 自定义 NumericAxis.of() 对同一 experiment × eval 的不同 attempt 返回不同值时按完整用户反馈失败。
706
+ * x 为 null 的 attempt 不伪造 x 值,归入该 series 的未绘制行,组件报告未绘制数量。
707
+ */
708
+ export async function metricLineData(input: ReportInput, options: MetricLineOptions): Promise<LineData> {
583
709
  const { snapshots } = resolveInput(input);
584
- const items = collectItems(snapshots);
585
- const groups = groupItems(items, "experiment");
710
+ const items = filterItems(collectItems(snapshots), options.evals);
711
+
712
+ // x 恒定性检查:同一 experiment × eval 内的全部 attempt 必须得到同一个 x。
713
+ const xByEvalKey = new Map<string, { x: number | null; item: Item }>();
714
+ const buckets = new Map<string, { series: string | undefined; x: number | null; items: Item[] }>();
715
+ for (const item of items) {
716
+ const x = axisValueOf(options.x, item.attempt);
717
+ const evalKey = fullEvalKey(item);
718
+ const existing = xByEvalKey.get(evalKey);
719
+ if (existing === undefined) {
720
+ xByEvalKey.set(evalKey, { x, item });
721
+ } else if (!Object.is(existing.x, x)) {
722
+ throw new Error(
723
+ `Numeric axis "${options.x.name}" is not constant within experiment "${experimentIdOf(item)}" × eval "${evalIdOf(item)}" ` +
724
+ `(got ${String(existing.x)} and ${String(x)} for different attempts). A parameter axis must describe the configuration, ` +
725
+ "not vary per attempt — a per-attempt quantity is material for the y metric, not an x axis. " +
726
+ "Fix of() to read experiment-level configuration (numericFlag()/numericRunConfig() do this by construction).",
727
+ );
728
+ }
729
+ const series = options.series ? dimensionKey(options.series, item) : undefined;
730
+ const bucketKey = `${series ?? ""}${x === null ? "null" : String(x)}`;
731
+ const bucket = buckets.get(bucketKey);
732
+ if (bucket) bucket.items.push(item);
733
+ else buckets.set(bucketKey, { series, x, items: [item] });
734
+ }
735
+
736
+ const ordered = [...buckets.values()].sort((a, b) => {
737
+ const sa = a.series ?? "";
738
+ const sb = b.series ?? "";
739
+ if (sa !== sb) return sa < sb ? -1 : 1;
740
+ if (a.x === null) return b.x === null ? 0 : 1;
741
+ if (b.x === null) return -1;
742
+ return a.x - b.x;
743
+ });
744
+
586
745
  const rows: LineData["rows"] = [];
587
- for (const [key, group] of groups) {
588
- const x = flagAxisValue(opts.x, group[0]); // flag 是 experiment 级声明,组内一致
746
+ for (const bucket of ordered) {
589
747
  rows.push({
590
- key,
591
- series: opts.series ? dimensionKey(opts.series, group[0]) : undefined,
592
- x,
593
- xDisplay: x === null ? "" : formatMetricValue(x, opts.x.unit),
594
- y: await computeCell(opts.y, group),
748
+ key: bucket.x === null ? "null" : String(bucket.x),
749
+ ...(bucket.series !== undefined ? { series: bucket.series } : {}),
750
+ x: bucket.x,
751
+ xDisplay: bucket.x === null ? "" : formatMetricValue(bucket.x, options.x.unit),
752
+ y: await computeCell(options.y, bucket.items),
595
753
  });
596
754
  }
755
+
597
756
  return {
598
757
  x: {
599
- key: opts.x.name,
600
- label: typeof opts.x.label === "string" ? opts.x.label : opts.x.name,
601
- unit: opts.x.unit,
758
+ key: options.x.name,
759
+ label: options.x.label ?? options.x.name,
760
+ ...(options.x.unit !== undefined ? { unit: options.x.unit } : {}),
602
761
  },
603
- series: opts.series ? dimensionName(opts.series) : undefined,
604
- y: toColumn(opts.y),
762
+ ...(options.series ? { seriesDimension: dimensionName(options.series) } : {}),
763
+ y: toColumn(options.y),
605
764
  rows,
606
765
  };
607
766
  }
608
767
 
609
- // ───────────────────────── RunOverview.data ─────────────────────────
768
+ // ───────────────────────── deltaTableData 与 pairsByFlag ─────────────────────────
610
769
 
611
- /** Selection 的 warnings 随行进 OverviewData,RunOverview 直接渲染 —— 诚实不靠使用者记得接线。 */
612
- export async function overviewData(input: SnapshotsInput): Promise<OverviewData> {
613
- const { snapshots, warnings } = resolveInput(input);
614
- const items = collectItems(snapshots);
615
- const evalIds = new Set<string>();
616
- let passed = 0;
617
- let failed = 0;
618
- let errored = 0;
619
- let skipped = 0;
620
- let durationMs = 0;
621
- let costUSD: number | null = null; // 任一 attempt 报了成本才有;全缺 = null,不编 0
622
- for (const item of items) {
623
- const result = item.attempt.result;
624
- evalIds.add(evalIdOf(item));
625
- switch (result.verdict) {
626
- case "passed":
627
- passed += 1;
628
- break;
629
- case "failed":
630
- failed += 1;
631
- break;
632
- case "errored":
633
- errored += 1;
634
- break;
635
- case "skipped":
636
- skipped += 1;
637
- break;
638
- }
639
- durationMs += result.durationMs;
640
- const cost = attemptCostUSD(result);
641
- if (cost !== null) costUSD = (costUSD ?? 0) + cost;
770
+ /**
771
+ * flag 派生 A/B 对(docs/feature/reports/library/metric-views.md「DeltaTable」):
772
+ * 配对域 = 同可比组 + 删除该 flag 后可比性配置深相等;a 取 baseline(缺省 = 未声明该 flag),
773
+ * b 侧该 flag 的每个其它取值各成一对;label 自动 `<a 末段> · <flag>=<显示键>`。
774
+ */
775
+ export function pairsByFlag(name: string, options?: { baseline?: JsonValue }): FlagPairs {
776
+ if (typeof name !== "string" || name.length === 0) {
777
+ throw new Error("pairsByFlag: name must be a non-empty string (the key declared in the experiment's flags).");
642
778
  }
643
- // 通过率的唯一官方口径:两级聚合(computeCell),不是从上面四个 verdict 计票现场重算——
644
- // 一道题内 attempt 部分通过要算部分 credit,不是二元投票。
645
- const passRateCell = await computeCell(passRate, items);
646
779
  return {
647
- snapshots: snapshots.map((s) => ({
648
- experimentId: s.experimentId,
649
- agent: s.agent,
650
- model: s.model,
651
- startedAt: s.startedAt,
652
- })),
653
- totals: {
654
- evals: evalIds.size,
655
- attempts: items.length,
656
- passed,
657
- failed,
658
- errored,
659
- skipped,
660
- passRate: passRateCell,
661
- costUSD,
662
- durationMs,
663
- },
664
- warnings: [...warnings],
780
+ kind: "flagPairs",
781
+ flag: name,
782
+ ...(options?.baseline !== undefined ? { baseline: options.baseline } : {}),
665
783
  };
666
784
  }
667
785
 
668
- // ───────────────────────── GroupSummary.data ─────────────────────────
786
+ export interface DeltaTableOptions {
787
+ /** 显式维度,必填——"baseline" 不会被猜成 experiment、agent、flag 或 snapshot 中的某一种。 */
788
+ by: DimensionInput;
789
+ /** 字面 pair 数组(自定义 label),或 pairsByFlag() 的派生声明;空数组在计算时报错。 */
790
+ pairs: readonly DeltaPair[] | FlagPairs;
791
+ metrics: readonly [Metric, ...Metric[]];
792
+ /** eval id 前缀过滤,同 CLI 位置参数语义。 */
793
+ evals?: string | readonly string[];
794
+ }
669
795
 
670
- /**
671
- * 一组 experiment 的摘要:experiment/eval/attempt 数量、eval 级折叠计票、通过率(
672
- * `GroupSelector` 卡片口径,见 summarizeItems)、总成本(null-safe 求和)、最后运行时间
673
- * (组内快照 startedAt 最大值)。`input` 就是调用方已经收窄好的组 Selection(如自定义报告
674
- * 按 experiment 组前缀 filter 出来的那份)——本函数不再自己分组。
675
- */
676
- export async function groupSummaryData(input: SnapshotsInput): Promise<GroupSummaryData> {
677
- const { snapshots } = resolveInput(input);
678
- const items = collectItems(snapshots);
679
- const summary = summarizeItems(items);
680
- const ratio = summary.ran > 0 ? summary.verdicts.passed / summary.ran : null; // 分母为 0 → 缺数据,不编 0%
681
- const passRateCell: MetricCell = {
682
- value: ratio,
683
- display: ratio === null ? "—" : formatMetricValue(ratio, "%"),
684
- samples: summary.ran,
685
- total: summary.evals,
686
- refs: summary.refs,
687
- };
688
- return {
689
- experiments: summary.experiments,
690
- evals: summary.evals,
691
- attempts: summary.attempts,
692
- verdicts: summary.verdicts,
693
- passRate: passRateCell,
694
- totalCostUSD: summary.totalCostUSD,
695
- ...(summary.lastRunAt !== undefined ? { lastRunAt: summary.lastRunAt } : {}),
696
- };
796
+ function isFlagPairs(pairs: DeltaTableOptions["pairs"]): pairs is FlagPairs {
797
+ return typeof pairs === "object" && pairs !== null && !Array.isArray(pairs) && (pairs as FlagPairs).kind === "flagPairs";
697
798
  }
698
799
 
699
- // ───────────────────────── DeltaTable.data ─────────────────────────
800
+ /** experiment id 相对可比组的末段。 */
801
+ function experimentTail(experimentId: string): string {
802
+ const slash = experimentId.lastIndexOf("/");
803
+ return slash === -1 ? experimentId : experimentId.slice(slash + 1);
804
+ }
700
805
 
701
- export interface DeltaPair {
702
- /** 基线侧:experiment id,或快照键 "<experimentId> @ <startedAt>"(时间轴对比用手挑的快照数组)。 */
703
- a: string;
704
- /** 对比侧,同上。 */
705
- b: string;
706
- label?: string;
806
+ /** 派生配对:同可比组 + 删除该 flag 后可比性配置深相等。返回 pair 列表与配对域实验数。 */
807
+ function derivePairsByFlag(
808
+ snapshots: readonly Snapshot[],
809
+ spec: FlagPairs,
810
+ ): { pairs: DeltaPair[]; experiments: number } {
811
+ // 每个 experiment 取最新快照的配置(current() Scope 天然一实验一快照)。
812
+ const byExperiment = new Map<string, Snapshot>();
813
+ for (const snapshot of snapshots) {
814
+ const existing = byExperiment.get(snapshot.experimentId);
815
+ if (existing === undefined || snapshot.startedAt > existing.startedAt) {
816
+ byExperiment.set(snapshot.experimentId, snapshot);
817
+ }
818
+ }
819
+ interface Entry {
820
+ id: string;
821
+ flagValue: JsonValue | undefined;
822
+ bucket: string;
823
+ }
824
+ const entries: Entry[] = [];
825
+ for (const [id, snapshot] of byExperiment) {
826
+ const config = comparabilityConfigOf(snapshot) as { flags?: Record<string, JsonValue> };
827
+ const flagValue = config.flags?.[spec.flag];
828
+ const reduced = { ...config, flags: { ...config.flags } };
829
+ delete reduced.flags[spec.flag];
830
+ const group = experimentGroupOf(id) ?? id;
831
+ entries.push({ id, flagValue, bucket: `${group}${JSON.stringify(sortedJson(reduced))}` });
832
+ }
833
+
834
+ const baseline = spec.baseline; // undefined = 未声明该 flag 的实验作 a
835
+ const buckets = new Map<string, Entry[]>();
836
+ for (const entry of entries) {
837
+ const list = buckets.get(entry.bucket);
838
+ if (list) list.push(entry);
839
+ else buckets.set(entry.bucket, [entry]);
840
+ }
841
+
842
+ const pairs: DeltaPair[] = [];
843
+ for (const bucket of buckets.values()) {
844
+ const aSide = bucket.filter((e) => deepEqualJson(e.flagValue, baseline));
845
+ const bSide = bucket.filter((e) => !deepEqualJson(e.flagValue, baseline));
846
+ for (const a of aSide) {
847
+ for (const b of bSide) {
848
+ pairs.push({
849
+ a: a.id,
850
+ b: b.id,
851
+ label: `${experimentTail(a.id)} · ${spec.flag}=${refDisplayKey(b.flagValue)[0]}`,
852
+ });
853
+ }
854
+ }
855
+ }
856
+ pairs.sort((p, q) => {
857
+ const ta = experimentTail(p.a);
858
+ const tb = experimentTail(q.a);
859
+ if (ta !== tb) return ta < tb ? -1 : 1;
860
+ const la = p.label as string;
861
+ const lb = q.label as string;
862
+ return la < lb ? -1 : la > lb ? 1 : 0;
863
+ });
864
+ return { pairs, experiments: byExperiment.size };
707
865
  }
708
866
 
709
- export interface DeltaDataOptions<M extends readonly Metric[]> {
710
- /** 每行一对:B 相对 A。 */
711
- pairs: DeltaPair[];
712
- metrics: M;
867
+ /** 对象键递归排序(派生配对的 bucket 键用;undefined 字段剔除)。 */
868
+ function sortedJson(value: unknown): unknown {
869
+ if (Array.isArray(value)) return value.map(sortedJson);
870
+ if (typeof value === "object" && value !== null) {
871
+ const out: Record<string, unknown> = {};
872
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
873
+ const v = (value as Record<string, unknown>)[key];
874
+ if (v !== undefined) out[key] = sortedJson(v);
875
+ }
876
+ return out;
877
+ }
878
+ return value;
713
879
  }
714
880
 
715
- export async function deltaData<const M extends readonly Metric[]>(
716
- input: SnapshotsInput,
717
- opts: DeltaDataOptions<M>,
718
- ): Promise<DeltaData<M[number]["name"]>> {
719
- assertUniqueMetricNames(opts.metrics, "DeltaTable.data metrics");
881
+ export async function deltaTableData(input: ReportInput, options: DeltaTableOptions): Promise<DeltaData> {
882
+ assertUniqueMetricNames(options.metrics, "deltaTableData metrics");
883
+ if (!Array.isArray(options.metrics) || options.metrics.length === 0) {
884
+ throw new Error("deltaTableData metrics must be a non-empty tuple of Metric instances.");
885
+ }
720
886
  const { snapshots } = resolveInput(input);
721
- const items = collectItems(snapshots);
722
- // 一侧的键既匹配 experiment id 也匹配快照键 —— 与 "snapshot" 维度同一格式,不另造对比语义
723
- const sideItems = (key: string) =>
724
- items.filter((item) => experimentIdOf(item) === key || snapshotKeyOf(item.snapshot) === key);
887
+
888
+ let pairs: readonly DeltaPair[];
889
+ let experiments: number | undefined;
890
+ if (isFlagPairs(options.pairs)) {
891
+ if (options.by !== "experiment") {
892
+ throw new Error(
893
+ `deltaTableData pairs came from pairsByFlag("${options.pairs.flag}"), which derives experiment A/B pairs — it only works with by: "experiment" (got by: ${JSON.stringify(
894
+ dimensionName(options.by),
895
+ )}). Set by: "experiment", or write literal pairs for other dimensions.`,
896
+ );
897
+ }
898
+ const derived = derivePairsByFlag(snapshots, options.pairs);
899
+ pairs = derived.pairs;
900
+ experiments = derived.experiments;
901
+ } else {
902
+ if (!Array.isArray(options.pairs)) {
903
+ throw new Error("deltaTableData pairs must be an array of { label, a, b } or a pairsByFlag(...) declaration.");
904
+ }
905
+ if (options.pairs.length === 0) {
906
+ throw new Error(
907
+ "deltaTableData pairs is empty — a delta table with no pairs has nothing to compare. " +
908
+ "Declare at least one { label, a, b } pair, or use pairsByFlag(name) to derive pairs from experiment flags.",
909
+ );
910
+ }
911
+ const seenLabels = new Set<string>();
912
+ for (const pair of options.pairs) {
913
+ const labelKey = JSON.stringify(sortedJson(pair.label));
914
+ if (pair.label === undefined || pair.label === "" || labelKey === "{}") {
915
+ throw new Error(`deltaTableData pair (${pair.a} vs ${pair.b}) has an empty label; every pair needs a display label.`);
916
+ }
917
+ if (seenLabels.has(labelKey)) {
918
+ throw new Error(`deltaTableData pair label ${labelKey} is used twice — labels must be unique within one table.`);
919
+ }
920
+ seenLabels.add(labelKey);
921
+ if (pair.a === pair.b) {
922
+ throw new Error(`deltaTableData pair "${labelKey}" compares "${pair.a}" with itself; a and b must differ.`);
923
+ }
924
+ }
925
+ pairs = options.pairs;
926
+ }
927
+
928
+ const items = filterItems(collectItems(snapshots), options.evals);
929
+ const groups = groupItems(items, options.by);
725
930
  const rows: DeltaData["rows"] = [];
726
- for (const pair of opts.pairs) {
727
- const aItems = sideItems(pair.a);
728
- const bItems = sideItems(pair.b);
729
- const cells: Record<string, DeltaData["rows"][number]["cells"][string]> = {};
730
- for (const metric of opts.metrics) {
931
+ for (const pair of pairs) {
932
+ // 精确匹配分组后的维度 key,不做前缀或模糊匹配;未命中保留 pair,对应侧格子为缺失。
933
+ const aItems = groups.get(pair.a) ?? [];
934
+ const bItems = groups.get(pair.b) ?? [];
935
+ const cells: DeltaData["rows"][number]["cells"] = {};
936
+ for (const metric of options.metrics) {
731
937
  const a = await computeCell(metric, aItems);
732
938
  const b = await computeCell(metric, bItems);
733
- const d = a.value === null || b.value === null ? null : b.value - a.value;
734
- cells[metric.name] = { a, b, delta: d, display: deltaDisplay(metric, d) };
939
+ const delta = a.value === null || b.value === null ? null : b.value - a.value;
940
+ cells[metric.name] = {
941
+ a,
942
+ b,
943
+ delta,
944
+ display: deltaDisplay(metric, delta),
945
+ outcome: deltaOutcome(metric, delta),
946
+ };
735
947
  }
736
948
  rows.push({
737
- key: pair.label ?? `${pair.a} vs ${pair.b}`,
738
- a: { experimentId: pair.a },
739
- b: { experimentId: pair.b },
949
+ key: `${pair.a} ${pair.b}`,
950
+ label: pair.label,
951
+ a: { key: pair.a },
952
+ b: { key: pair.b },
740
953
  cells,
741
954
  });
742
955
  }
743
- return { columns: opts.metrics.map(toColumn), rows } as DeltaData<M[number]["name"]>;
956
+ return {
957
+ byDimension: dimensionName(options.by),
958
+ columns: options.metrics.map(toColumn),
959
+ ...(experiments !== undefined ? { experiments } : {}),
960
+ rows,
961
+ };
744
962
  }
745
963
 
746
- function deltaDisplay(metric: Metric, delta: number | null): string {
964
+ function deltaDisplay(metric: Metric, delta: number | null): LocalizedText {
747
965
  if (delta === null) return "—"; // 任一侧缺数据:Δ 显示为缺,不硬算
748
966
  if (delta === 0) return "±0";
749
- const text = displayValue(metric, delta); // 负号由格式化自带
750
- return delta > 0 ? `+${text}` : text;
967
+ if (metric.display) {
968
+ const display = metric.display;
969
+ return localizedDisplay((locale) => {
970
+ const text = display(Math.abs(delta), locale);
971
+ return delta > 0 ? `+${text}` : `-${text}`;
972
+ });
973
+ }
974
+ const text = formatMetricValue(Math.abs(delta), metric.unit);
975
+ return delta > 0 ? `+${text}` : `-${text}`;
976
+ }
977
+
978
+ function deltaOutcome(metric: Metric, delta: number | null): "improved" | "regressed" | "unchanged" | "unavailable" {
979
+ if (delta === null) return "unavailable";
980
+ if (delta === 0) return "unchanged";
981
+ const better = metric.better ?? "higher";
982
+ return (delta > 0) === (better === "higher") ? "improved" : "regressed";
751
983
  }