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,35 +1,53 @@
1
+ // cases: docs/engineering/unit-tests/reports/cases.md
1
2
  // niceeval/report 计算层的单元测试:全部用内存 fake(Snapshot / AttemptHandle 按
2
- // niceeval/results 的读取契约手工构造),专门覆盖 docs/feature/reports/architecture.md 点名的坑 ——
3
- // 两级聚合 vs 平铺、pass@kexamScore 空真、skipped 稀释、scoreboard 固定分母与
4
- // 最长前缀、scatter/delta null 语义、快照键对比、flag 维度与轴、
5
- // cases redact/truncated、身份键去重、Selection warnings 随行。
3
+ // niceeval/results 的读取契约手工构造)。覆盖登记行:两级聚合 vs 平铺、errored=0 口径、
4
+ // skipped=nullnull≠0、Scoreboard 固定分母(notRun/unscorable 分开)、权重最长前缀、
5
+ // 身份键去重、现刻水位、自定义指标 where/aggregate、evalGroup 完整父路径、verdict 权威、
6
+ // MetricCell 诚实、缺 artifact 指标、repeatedFailedCommands、实体列表 failureSummary /
7
+ // redact、scopeSummaryData 两级计票、experimentComparisonData 分区、pairsByFlag、
8
+ // MetricLine 点身份、空数组反馈、metricTableData sort。
6
9
 
7
10
  import { describe, expect, it } from "vitest";
8
11
 
9
- import type { AssertionResult, EvalResult, O11ySummary, Verdict } from "../types.ts";
10
- import type { AttemptHandle, Selection, SelectionWarning, Snapshot } from "../results/index.ts";
11
- import type { Dimension, MetricCell } from "./types.ts";
12
- import { costUSD, defineMetric, durationMs, examScore, passRate, tokens, turns } from "./metrics.ts";
13
- import { flag } from "./flag.ts";
14
- import { formatMetricValue } from "./format.ts";
12
+ import type { AssertionResult, AttemptError, EvalResult, O11ySummary, Verdict } from "../types.ts";
13
+ import type { AttemptHandle, Scope, ScopeWarning, Snapshot } from "../results/index.ts";
14
+ import { makeScope, selectCurrentResults } from "../results/select.ts";
15
+ import type { Results } from "../results/types.ts";
15
16
  import {
16
- AttemptList,
17
- DeltaTable,
18
- EvalList,
19
- ExperimentList,
20
- GroupSummary,
21
- MetricBars,
22
- MetricLine,
23
- MetricMatrix,
24
- MetricScatter,
25
- MetricTable,
26
- RunOverview,
27
- Scoreboard,
28
- } from "./components.tsx";
29
- import { reasonFor } from "./compute.ts";
17
+ assistantTurns,
18
+ costUSD,
19
+ defineMetric,
20
+ durationMs,
21
+ endToEndPassRate,
22
+ examScore,
23
+ executionReliability,
24
+ repeatedFailedCommands,
25
+ taskPassRate,
26
+ } from "./metrics.ts";
27
+ import { flag, numericFlag } from "./flag.ts";
28
+ import {
29
+ attemptListData,
30
+ deltaTableData,
31
+ evalListData,
32
+ experimentComparisonData,
33
+ experimentListData,
34
+ metricLineData,
35
+ metricMatrixData,
36
+ metricScatterData,
37
+ metricTableData,
38
+ pairsByFlag,
39
+ scopeSummaryData,
40
+ scoreboardData,
41
+ } from "./compute.ts";
42
+ import { evalGroupOf } from "./aggregate.ts";
30
43
 
31
44
  // ───────────────────────── fake 数据(按 results 读取契约造)─────────────────────────
32
45
 
46
+ /** 结构化 `AttemptError` 的最小构造(测试用)。 */
47
+ function erroredWith(message: string): AttemptError {
48
+ return { code: "unexpected-error", message, phase: "eval.run" };
49
+ }
50
+
33
51
  let seq = 0;
34
52
 
35
53
  /** 造一条结果;默认给每条唯一 startedAt —— 身份键含 startedAt,免得普通样本被去重误伤。 */
@@ -48,13 +66,13 @@ function res(id: string, verdict: Verdict, extra: Partial<EvalResult> = {}): Eva
48
66
  }
49
67
 
50
68
  function softAssertion(name: string, score: number, extra: Partial<AssertionResult> = {}): AssertionResult {
51
- return { name, severity: "soft", score, passed: true, ...extra };
69
+ return { name, severity: "soft", score, outcome: "passed" as const, ...extra } as AssertionResult;
52
70
  }
53
71
 
54
- /** 最小合规 O11ySummary,只有 totalTurns 会变;供 turns 指标测试内联挂到 EvalResult.o11y。 */
55
- function o11ySummary(totalTurns: number): O11ySummary {
72
+ /** 最小合规 O11ySummary;shellCommands / totalTurns 按需变。 */
73
+ function o11ySummary(partial: Partial<O11ySummary> = {}): O11ySummary {
56
74
  return {
57
- totalTurns,
75
+ totalTurns: 0,
58
76
  toolCalls: {},
59
77
  totalToolCalls: 0,
60
78
  filesRead: [],
@@ -66,7 +84,8 @@ function o11ySummary(totalTurns: number): O11ySummary {
66
84
  compactions: 0,
67
85
  durationMs: 0,
68
86
  usage: { inputTokens: 0, outputTokens: 0 },
69
- };
87
+ ...partial,
88
+ } as O11ySummary;
70
89
  }
71
90
 
72
91
  interface SnapSpec {
@@ -76,6 +95,7 @@ interface SnapSpec {
76
95
  model?: string;
77
96
  runStartedAt?: string;
78
97
  knownEvalIds?: string[];
98
+ experiment?: Snapshot["experiment"];
79
99
  }
80
100
 
81
101
  let runSeq = 0;
@@ -91,6 +111,7 @@ function snap(spec: SnapSpec): Snapshot {
91
111
  completedAt: startedAt,
92
112
  agent: spec.agent ?? "agent-x",
93
113
  model: spec.model,
114
+ experiment: spec.experiment,
94
115
  schemaVersion: 1,
95
116
  dir,
96
117
  knownEvalIds: spec.knownEvalIds,
@@ -119,973 +140,890 @@ function snap(spec: SnapSpec): Snapshot {
119
140
  return snapshot;
120
141
  }
121
142
 
122
- /** 手工造一个 Selection(warnings 随行的形状,filter 语义与 results 一致) */
123
- function selection(snapshots: Snapshot[], warnings: SelectionWarning[]): Selection {
124
- return {
125
- snapshots,
126
- warnings,
127
- filter(predicate) {
128
- const kept = snapshots.filter(predicate);
129
- const survivors = new Set(kept.map((s) => s.experimentId));
130
- return selection(
131
- kept,
132
- warnings.filter((w) => typeof w.experimentId !== "string" || survivors.has(w.experimentId)),
133
- );
134
- },
135
- };
143
+ function scopeOf(snapshots: Snapshot[], warnings: ScopeWarning[] = []): Scope {
144
+ return makeScope("current-evals", snapshots, warnings);
136
145
  }
137
146
 
138
- describe("ExperimentList.data", () => {
139
- it("身份、flags、Eval 判定构成、官方两级聚合汇总指标,展开到 Eval 携带全部 Attempt locator", async () => {
140
- const s = snap({
141
- experimentId: "group/codex-e2b--mempal",
142
- agent: "codex",
143
- model: "gpt-5.4-mini",
144
- runStartedAt: "2026-07-10T22:44:00.000Z",
145
- results: [
146
- res("memory/a", "failed", {
147
- attempt: 0,
148
- durationMs: 1_000,
149
- assertions: [{ name: "gate", severity: "gate", passed: false, score: 0, detail: "wrong" }],
150
- usage: { inputTokens: 10, outputTokens: 5, costUSD: 0.1 },
151
- hasEvents: true,
152
- }),
153
- res("memory/a", "passed", {
154
- attempt: 1,
155
- durationMs: 2_000,
156
- usage: { inputTokens: 20, outputTokens: 10, costUSD: 0.2 },
157
- }),
158
- res("memory/b", "errored", { error: "timeout", durationMs: 3_000 }),
159
- ],
160
- });
161
- s.experiment = { id: s.experimentId, runs: 2, earlyExit: false, sandbox: "e2b:fast", budget: 2, flags: { cache: true } };
162
-
163
- const items = await ExperimentList.data([s]);
164
- expect(items).toHaveLength(1);
165
- const item = items[0]!;
166
- expect(item).toMatchObject({
167
- experimentId: "group/codex-e2b--mempal",
168
- agent: "codex",
169
- model: "gpt-5.4-mini",
170
- flags: { cache: true },
171
- verdicts: { passed: 1, failed: 0, errored: 1, skipped: 0 },
172
- evals: 2,
173
- attempts: 3,
174
- lastRunAt: "2026-07-10T22:44:00.000Z",
175
- });
176
- // 官方两级聚合口径:memory/a 题内均值 (0+1)/2=0.5,memory/b 题内 0;跨题均值 0.25
177
- expect(item.passRate.value).toBeCloseTo(0.25);
178
- expect(item.cost.samples).toBe(2); // 只有 memory/a 的两次 attempt 报了成本
179
-
180
- const evalA = item.evalRows.find((e) => e.evalId === "memory/a")!;
181
- expect(evalA.verdict).toBe("passed"); // 任一轮通过 → 该题通过
182
- expect(evalA.attempts.map((a) => a.attempt)).toEqual([0, 1]);
183
- expect(evalA.attempts[0]!.locator).toMatch(/^@1[0-9a-z]{7}$/);
184
- // 每次 attempt 独立编码,即使同一道题也不撞车
185
- expect(evalA.attempts[0]!.locator).not.toBe(evalA.attempts[1]!.locator);
186
-
187
- const evalB = item.evalRows.find((e) => e.evalId === "memory/b")!;
188
- expect(evalB.verdict).toBe("errored");
189
- expect(evalB.reason).toBe("timeout");
147
+ // ───────────────────────── 指标聚合口径 ─────────────────────────
148
+
149
+ describe("两级聚合口径", () => {
150
+ // 区分力 fixture:题级值 [1, 2/3, 0] → 两级 5/9;attempt 平铺 3/5;
151
+ // 条件任务通过率 5/6(errored 不进分母);「任一轮通过」2/3。四种口径互不相等。
152
+ const discriminating = snap({
153
+ experimentId: "exp/a",
154
+ results: [
155
+ res("e1", "passed"),
156
+ res("e2", "passed", { attempt: 0 }),
157
+ res("e2", "passed", { attempt: 1 }),
158
+ res("e2", "failed", { attempt: 2 }),
159
+ res("e3", "errored", { error: erroredWith("boom") }),
160
+ ],
190
161
  });
191
162
 
192
- it("展开数组按 evalId 升序,顶层数组按 experimentId 升序", async () => {
193
- const s1 = snap({ experimentId: "exp/b", results: [res("z", "passed"), res("a", "passed")] });
194
- const s2 = snap({ experimentId: "exp/a", results: [res("x", "passed")] });
195
- const items = await ExperimentList.data([s1, s2]);
196
- expect(items.map((i) => i.experimentId)).toEqual(["exp/a", "exp/b"]);
197
- expect(items.find((i) => i.experimentId === "exp/b")!.evalRows.map((e) => e.evalId)).toEqual(["a", "z"]);
163
+ it("endToEndPassRate 先题内折叠再跨题折叠:5/9,不是平铺 3/5、条件 5/6 或任一轮 2/3", async () => {
164
+ const data = await scopeSummaryData([discriminating]);
165
+ expect(data.endToEndPassRate.value).toBeCloseTo(5 / 9);
166
+ expect(data.endToEndPassRate.value).not.toBeCloseTo(3 / 5);
167
+ expect(data.endToEndPassRate.value).not.toBeCloseTo(5 / 6);
168
+ expect(data.endToEndPassRate.value).not.toBeCloseTo(2 / 3);
169
+ expect(data.endToEndPassRate.display).toBe("55.6%");
170
+ expect(data.endToEndPassRate.samples).toBe(5);
171
+ expect(data.endToEndPassRate.total).toBe(5);
198
172
  });
199
- });
200
173
 
201
- describe("EvalList.data", () => {
202
- it("每项一个 experimentId + evalId,判定/分数/成本耗时均值/失败原因与展开到 Attempt 都在", async () => {
203
- const s = snap({
204
- experimentId: "compare/codex",
205
- results: [
206
- res("weather/brooklyn", "failed", {
207
- attempt: 0,
208
- durationMs: 40_000,
209
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.02 },
210
- assertions: [{ name: 'calledTool("get_weather")', severity: "gate", passed: false, score: 0, detail: "tool was never called" }],
211
- }),
212
- res("weather/brooklyn", "failed", {
213
- attempt: 1,
214
- durationMs: 42_000,
215
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.06 },
216
- assertions: [{ name: 'calledTool("get_weather")', severity: "gate", passed: false, score: 0, detail: "tool was never called" }],
217
- }),
218
- ],
174
+ it("taskPassRate 排除 errored,只能作为带限定名称的诊断指标:同 fixture 得 5/6", async () => {
175
+ const table = await metricTableData([discriminating], {
176
+ rows: "agent",
177
+ columns: [endToEndPassRate, taskPassRate, executionReliability],
219
178
  });
220
- const items = await EvalList.data([s]);
221
- expect(items).toHaveLength(1);
222
- const item = items[0]!;
223
- expect(item.evalId).toBe("weather/brooklyn");
224
- expect(item.experimentId).toBe("compare/codex");
225
- expect(item.verdict).toBe("failed");
226
- expect(item.reason).toBe('calledTool("get_weather"): tool was never called');
227
- expect(item.score.value).toBe(0); // examScore: failed → 0
228
- expect(item.duration.value).toBeCloseTo(41_000); // 平均耗时
229
- expect(item.cost.value).toBeCloseTo(0.04); // 平均成本
230
- expect(item.attempts.map((a) => a.attempt)).toEqual([0, 1]);
231
- expect(item.attempts[0]!.locator).not.toBe(item.attempts[1]!.locator);
232
- });
233
-
234
- it("同一个 eval id 出现在两个 experiment 上是两条不同结果,不合并", async () => {
235
- const s1 = snap({ experimentId: "exp/a", results: [res("shared", "passed")] });
236
- const s2 = snap({ experimentId: "exp/b", results: [res("shared", "failed")] });
237
- const items = await EvalList.data([s1, s2]);
238
- expect(items).toHaveLength(2);
239
- expect(items.map((i) => `${i.experimentId}:${i.verdict}`).sort()).toEqual(["exp/a:passed", "exp/b:failed"]);
179
+ const cells = table.rows[0]!.cells;
180
+ expect(cells[taskPassRate.name]!.value).toBeCloseTo(5 / 6);
181
+ expect(cells[endToEndPassRate.name]!.value).toBeCloseTo(5 / 9);
182
+ // executionReliability:e1=1、e2=1、e3=0 → 2/3
183
+ expect(cells[executionReliability.name]!.value).toBeCloseTo(2 / 3);
240
184
  });
241
- });
242
185
 
243
- describe("AttemptList.data", () => {
244
- it("每项一个 Attempt:身份、判定、assertions、耗时、成本与 locator", async () => {
186
+ it("2 passed + 5 errored 的默认成功率是 2/7,不是 100%", async () => {
245
187
  const s = snap({
246
- experimentId: "exp/x",
188
+ experimentId: "exp/err",
247
189
  results: [
248
- res("A", "failed", {
249
- durationMs: 5_000,
250
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.05 },
251
- assertions: [{ name: "gate", severity: "gate", passed: false, score: 0, detail: "nope" }],
252
- hasEvents: true,
253
- hasSources: true,
254
- }),
190
+ res("q1", "passed"),
191
+ res("q2", "passed"),
192
+ ...[3, 4, 5, 6, 7].map((n) => res(`q${n}`, "errored", { error: erroredWith("x") })),
255
193
  ],
256
194
  });
257
- const items = await AttemptList.data([s]);
258
- expect(items).toHaveLength(1);
259
- const item = items[0]!;
260
- expect(item).toMatchObject({
261
- evalId: "A",
262
- experimentId: "exp/x",
263
- attempt: 0,
264
- agent: "agent-x",
265
- verdict: "failed",
266
- durationMs: 5_000,
267
- costUSD: 0.05,
268
- });
269
- expect(item.assertions).toHaveLength(1);
270
- expect(item.locator).toMatch(/^@1[0-9a-z]{7}$/);
195
+ const data = await scopeSummaryData([s]);
196
+ expect(data.endToEndPassRate.value).toBeCloseTo(2 / 7);
271
197
  });
272
198
 
273
- it("redact 作用于 error 与断言 detail/evidence,不作用于身份字段", async () => {
199
+ it("skipped 对内置指标返回 null:不进有效样本但保留在 total,value 不受影响", async () => {
274
200
  const s = snap({
275
- experimentId: "exp/x",
276
- results: [
277
- res("A", "errored", { error: "ENOENT /Users/me/repo/tool" }),
278
- res("B", "failed", {
279
- assertions: [
280
- {
281
- name: "includes",
282
- severity: "gate",
283
- score: 0,
284
- passed: false,
285
- detail: "missing text under /Users/me/repo/src",
286
- evidence: "checked /Users/me/repo/src/app.ts",
287
- },
288
- ],
289
- }),
290
- ],
201
+ experimentId: "exp/skip",
202
+ results: [res("a", "passed"), res("b", "skipped"), res("c", "failed")],
291
203
  });
292
- const items = await AttemptList.data([s], { redact: (text) => text.replaceAll("/Users/me/repo", "<repo>") });
293
- const a = items.find((i) => i.evalId === "A")!;
294
- expect(a.error).toBe("ENOENT <repo>/tool");
295
- const b = items.find((i) => i.evalId === "B")!;
296
- expect(b.assertions[0]!.detail).toBe("missing text under <repo>/src");
297
- expect(b.assertions[0]!.evidence).toBe("checked <repo>/src/app.ts");
298
- // 身份字段不经 redact
299
- expect(items.every((i) => i.experimentId === "exp/x")).toBe(true);
204
+ const data = await scopeSummaryData([s]);
205
+ expect(data.endToEndPassRate.value).toBeCloseTo(0.5); // (1+0)/2,skipped 不稀释
206
+ expect(data.endToEndPassRate.samples).toBe(2);
207
+ expect(data.endToEndPassRate.total).toBe(3);
300
208
  });
301
209
 
302
- it("不预设只看失败:passedattempt 同样列出,原样携带 assertions", async () => {
303
- const s = snap({
304
- experimentId: "exp/x",
305
- results: [res("A", "passed", { assertions: [{ name: "ok", severity: "gate", passed: true, score: 1 }] })],
210
+ it("null 表示测不了不参与聚合,0 正常参与:[null, 0, 1] mean 0.5 而非 1/3", async () => {
211
+ const values = new Map([
212
+ ["a", null],
213
+ ["b", 0],
214
+ ["c", 1],
215
+ ]);
216
+ const metric = defineMetric({
217
+ name: "tri",
218
+ value: (attempt) => values.get(attempt.evalId) ?? null,
306
219
  });
307
- const items = await AttemptList.data([s]);
308
- expect(items).toHaveLength(1);
309
- expect(items[0]).toMatchObject({ verdict: "passed" });
310
- expect(items[0]!.assertions).toHaveLength(1);
220
+ const s = snap({ experimentId: "exp/tri", results: [res("a", "passed"), res("b", "passed"), res("c", "passed")] });
221
+ const table = await metricTableData([s], { rows: "agent", columns: [metric] });
222
+ expect(table.rows[0]!.cells.tri!.value).toBeCloseTo(0.5);
311
223
  });
312
- });
313
224
 
314
- // ───────────────────────── 两级聚合 ─────────────────────────
225
+ it("跨快照计算先按身份键去重:局部补跑重叠快照下 samples 不虚增", async () => {
226
+ const carried = res("dup/a", "passed", { startedAt: "2026-07-01T09:00:00.000Z" });
227
+ const s1 = snap({ experimentId: "exp/dup", results: [carried], runStartedAt: "2026-07-01T09:00:00.000Z" });
228
+ // 携带合入:同一条结果(同身份键)原样出现在更新的快照里
229
+ const s2 = snap({ experimentId: "exp/dup", results: [{ ...carried }], runStartedAt: "2026-07-02T09:00:00.000Z" });
230
+ const data = await scopeSummaryData([s1, s2]);
231
+ expect(data.attempts).toBe(1);
232
+ expect(data.endToEndPassRate.samples).toBe(1);
233
+ });
315
234
 
316
- describe("两级聚合引擎", () => {
317
- it("题内先折再跨题平均:A=[1]、B=[0,0,0] → 0.5,不是平铺的 0.25", async () => {
235
+ it("自定义指标:where 是进入计算前的过滤;perEval + acrossEvals 两级分别生效", async () => {
318
236
  const s = snap({
319
- experimentId: "exp/x",
320
- results: [res("A", "passed"), res("B", "failed"), res("B", "failed"), res("B", "failed")],
237
+ experimentId: "exp/custom",
238
+ results: [
239
+ res("a", "passed", { durationMs: 100, attempt: 0 }),
240
+ res("a", "passed", { durationMs: 300, attempt: 1 }),
241
+ res("b", "failed", { durationMs: 900 }),
242
+ res("c", "passed", { durationMs: 500 }),
243
+ ],
321
244
  });
322
- const data = await MetricTable.data([s], { rows: "agent", columns: [passRate] });
323
- expect(data.dimension).toBe("agent");
324
- expect(data.rows).toHaveLength(1);
325
- const cell = data.rows[0].cells["pass-rate"];
326
- expect(cell.value).toBe(0.5);
327
- expect(cell.display).toBe("50%");
328
- expect(cell.samples).toBe(4);
245
+ const fastest = defineMetric({
246
+ name: "fastest-pass",
247
+ where: (attempt) => attempt.result.verdict === "passed",
248
+ value: (attempt) => attempt.result.durationMs,
249
+ aggregate: { perEval: "min", acrossEvals: "mean" },
250
+ });
251
+ const table = await metricTableData([s], { rows: "agent", columns: [fastest] });
252
+ const cell = table.rows[0]!.cells["fastest-pass"]!;
253
+ // b 被 where 排除;a 题内 min = 100,c = 500 → mean 300(双 mean 会是 (200+500)/2=350,可区分)
254
+ expect(cell.value).toBe(300);
255
+ expect(cell.samples).toBe(3);
329
256
  expect(cell.total).toBe(4);
330
- expect(cell.refs).toHaveLength(4);
331
- });
332
257
 
333
- it("pass@k = perEval:max —— k 次里过一次的题算过", async () => {
334
- const passAtK = defineMetric({
335
- name: "pass@k",
336
- better: "higher",
337
- unit: "%",
338
- value: (a) => (a.result.verdict === "skipped" ? null : a.result.verdict === "passed" ? 1 : 0),
339
- aggregate: { perEval: "max", across: "mean" },
340
- });
341
- const s = snap({
342
- experimentId: "exp/x",
343
- results: [res("A", "failed"), res("A", "failed"), res("B", "failed"), res("B", "passed")],
258
+ const allExcluded = defineMetric({
259
+ name: "none",
260
+ where: () => false,
261
+ value: () => 1,
344
262
  });
345
- const data = await MetricTable.data([s], { rows: "agent", columns: [passAtK, passRate] });
346
- // A: max(0,0)=0;B: max(0,1)=1 → (0+1)/2
347
- expect(data.rows[0].cells["pass@k"].value).toBe(0.5);
348
- // 对照:默认 mean/mean 的 passRate = (0 + 0.5)/2
349
- expect(data.rows[0].cells["pass-rate"].value).toBe(0.25);
350
- });
351
-
352
- it("skipped 是 null:不稀释均值,但计入 total(覆盖率如实)", async () => {
353
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "skipped")] });
354
- const data = await MetricTable.data([s], { rows: "agent", columns: [passRate] });
355
- const cell = data.rows[0].cells["pass-rate"];
356
- expect(cell.value).toBe(1); // B 整桶为 null,不参与 across,不是 0.5
357
- expect(cell.samples).toBe(1);
358
- expect(cell.total).toBe(2);
359
- expect(cell.refs).toHaveLength(1);
360
- });
361
-
362
- it("全组 null → value null、display 兜底,不编 0;refs 必填(空数组)", async () => {
363
- const s = snap({ experimentId: "exp/x", results: [res("A", "skipped")] });
364
- const data = await MetricTable.data([s], { rows: "agent", columns: [passRate] });
365
- const cell = data.rows[0].cells["pass-rate"];
366
- expect(cell.value).toBeNull();
367
- expect(cell.display).toBe("—");
368
- expect(cell.samples).toBe(0);
369
- expect(cell.total).toBe(1);
370
- expect(cell.refs).toEqual([]);
263
+ const empty = await metricTableData([s], { rows: "agent", columns: [allExcluded] });
264
+ expect(empty.rows[0]!.cells.none!.value).toBeNull();
371
265
  });
372
266
 
373
- it("where 不满足 null,不进聚合", async () => {
374
- const onlyPassed = defineMetric({
375
- name: "only-passed",
376
- where: (a) => a.result.verdict === "passed",
377
- value: () => 5,
267
+ it("evalGroup 按完整父路径分组(无 / 取完整 id),与可比组同一条派生规则", async () => {
268
+ expect(evalGroupOf("a/b/c")).toBe("a/b");
269
+ expect(evalGroupOf("security/sql-injection")).toBe("security");
270
+ expect(evalGroupOf("standalone")).toBe("standalone");
271
+ const s = snap({
272
+ experimentId: "exp/g",
273
+ results: [res("a/b/c", "passed"), res("a/b/d", "failed"), res("solo", "passed")],
378
274
  });
379
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
380
- const data = await MetricTable.data([s], { rows: "agent", columns: [onlyPassed] });
381
- const cell = data.rows[0].cells["only-passed"];
382
- expect(cell.value).toBe(5);
383
- expect(cell.samples).toBe(1);
384
- expect(cell.total).toBe(2);
275
+ const matrix = await metricMatrixData([s], { rows: "evalGroup", columns: "agent", cell: endToEndPassRate });
276
+ const rowKeys = [...new Set(matrix.cells.map((c) => c.row))];
277
+ expect(rowKeys.sort()).toEqual(["a/b", "solo"]);
385
278
  });
386
279
 
387
- it("自定义维度:第一级折叠发生在各组内部", async () => {
388
- const byParity: Dimension = {
389
- name: "parity",
390
- of: (a) => (a.result.attempt % 2 === 0 ? "even" : "odd"),
391
- };
280
+ it("报告消费落盘 verdict,不重新判卷:断言与 verdict 矛盾时以 verdict 为准", async () => {
392
281
  const s = snap({
393
- experimentId: "exp/x",
282
+ experimentId: "exp/v",
394
283
  results: [
395
- res("A", "passed", { attempt: 0 }),
396
- res("A", "failed", { attempt: 1 }),
397
- res("A", "passed", { attempt: 2 }),
284
+ res("a", "failed", {
285
+ // 断言看起来全过,但 verdict 是 failed(如 --strict 翻案):按 verdict 记 0
286
+ assertions: [softAssertion("s", 1)],
287
+ }),
398
288
  ],
399
289
  });
400
- const data = await MetricTable.data([s], { rows: byParity, columns: [passRate] });
401
- expect(data.dimension).toBe("parity");
402
- const byKey = Object.fromEntries(data.rows.map((r) => [r.key, r.cells["pass-rate"].value]));
403
- // 同一道题的 attempt 分进两组:even 组内 [1,1] 折成 1,odd 组内 [0] 折成 0
404
- expect(byKey).toEqual({ even: 1, odd: 0 });
290
+ const data = await scopeSummaryData([s]);
291
+ expect(data.endToEndPassRate.value).toBe(0);
405
292
  });
293
+ });
406
294
 
407
- it("同一次计算里指标重名是错误", async () => {
408
- const dup = defineMetric({ name: "pass-rate", value: () => 1 });
409
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
410
- await expect(MetricTable.data([s], { rows: "agent", columns: [passRate, dup] })).rejects.toThrow(
411
- /Duplicate metric name "pass-rate"/,
412
- );
295
+ describe("宿主现刻水位(selectCurrentResults)", () => {
296
+ function resultsOf(snapshots: Snapshot[]): Results {
297
+ const byId = new Map<string, Snapshot[]>();
298
+ for (const s of snapshots) byId.set(s.experimentId, [...(byId.get(s.experimentId) ?? []), s]);
299
+ const experiments = [...byId.entries()].map(([id, snaps]) => {
300
+ const sorted = [...snaps].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
301
+ const evalIds = [...new Set(sorted.flatMap((s) => s.evals.map((e) => e.id)))].sort();
302
+ return { id, snapshots: sorted, latest: sorted[0]!, evalIds };
303
+ });
304
+ const results = {
305
+ experiments,
306
+ skipped: [],
307
+ latest: () => scopeOf(experiments.map((e) => e.latest)),
308
+ current: () => selectCurrentResults(results),
309
+ } as unknown as Results;
310
+ return results;
311
+ }
312
+
313
+ it("每个 experiment × eval 取跨历史最新判定:先 failed 后 passed 只用最新", async () => {
314
+ const older = snap({
315
+ experimentId: "exp/w",
316
+ results: [res("a", "failed"), res("b", "passed")],
317
+ runStartedAt: "2026-07-01T00:00:00.000Z",
318
+ });
319
+ const newer = snap({
320
+ experimentId: "exp/w",
321
+ results: [res("a", "passed")],
322
+ runStartedAt: "2026-07-02T00:00:00.000Z",
323
+ });
324
+ const scope = selectCurrentResults(resultsOf([older, newer]));
325
+ expect(scope.mode).toBe("current-evals");
326
+ const data = await scopeSummaryData(scope);
327
+ expect(data.evals).toBe(2);
328
+ expect(data.evalVerdicts).toEqual({ passed: 2, failed: 0, errored: 0, skipped: 0 });
413
329
  });
414
330
 
415
- it("sort 方向随 better,缺数据行沉底", async () => {
416
- const good = snap({ experimentId: "exp/good", agent: "good", results: [res("A", "passed", { agent: "good" })] });
417
- const bad = snap({ experimentId: "exp/bad", agent: "bad", results: [res("A", "failed", { agent: "bad" })] });
418
- const none = snap({ experimentId: "exp/none", agent: "none", results: [res("A", "skipped", { agent: "none" })] });
419
- const data = await MetricTable.data([none, bad, good], {
420
- rows: "agent",
421
- columns: [passRate],
422
- sort: passRate,
331
+ it("可比性前提:配置不一致的旧快照不贡献 attempt,缺口走 partial-coverage", () => {
332
+ const oldConfig = snap({
333
+ experimentId: "exp/cfg",
334
+ model: "gpt-old",
335
+ results: [res("a", "passed"), res("b", "passed")],
336
+ runStartedAt: "2026-07-01T00:00:00.000Z",
337
+ });
338
+ const newConfig = snap({
339
+ experimentId: "exp/cfg",
340
+ model: "gpt-new",
341
+ results: [res("a", "failed")],
342
+ runStartedAt: "2026-07-02T00:00:00.000Z",
423
343
  });
424
- expect(data.rows.map((r) => r.key)).toEqual(["good", "bad", "none"]);
344
+ const scope = selectCurrentResults(resultsOf([oldConfig, newConfig]));
345
+ // 旧 model 的 b 不冒充新配置的水位
346
+ expect(scope.snapshots[0]!.evals.map((e) => e.id)).toEqual(["a"]);
347
+ const warning = scope.warnings.find((w) => w.kind === "partial-coverage");
348
+ expect(warning).toMatchObject({ covered: 1, total: 2, command: "niceeval exp exp/cfg" });
425
349
  });
426
350
 
427
- it("列键是字面量联合:拼错列名编译不过", async () => {
428
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
429
- const data = await MetricTable.data([s], { rows: "agent", columns: [passRate, costUSD] });
430
- const cell: MetricCell = data.rows[0].cells[passRate.name]; // 键锚在指标对象上
431
- expect(cell.value).toBe(1);
432
- // @ts-expect-error 列里没有这个键 —— 编译期挡住,不是运行时 undefined
433
- data.rows[0].cells["pass-rat"];
351
+ it("编排字段(runs / maxConcurrency / description…)不参与可比性比较", () => {
352
+ const older = snap({
353
+ experimentId: "exp/orch",
354
+ results: [res("a", "passed"), res("b", "passed")],
355
+ runStartedAt: "2026-07-01T00:00:00.000Z",
356
+ experiment: { runs: 3, earlyExit: true, maxConcurrency: 2, selectedEvalIds: ["a", "b"], description: "old" },
357
+ });
358
+ const newer = snap({
359
+ experimentId: "exp/orch",
360
+ results: [res("a", "failed")],
361
+ runStartedAt: "2026-07-02T00:00:00.000Z",
362
+ experiment: { runs: 1, earlyExit: false, selectedEvalIds: ["a"], description: "new" },
363
+ });
364
+ const scope = selectCurrentResults(resultsOf([older, newer]));
365
+ expect(scope.snapshots[0]!.evals.map((e) => e.id)).toEqual(["a", "b"]);
366
+ expect(scope.warnings.filter((w) => w.kind === "partial-coverage")).toHaveLength(0);
434
367
  });
435
368
  });
436
369
 
437
- // ───────────────────────── examScore ─────────────────────────
370
+ // ───────────────────────── MetricCell 与缺数据行为 ─────────────────────────
438
371
 
439
- describe("examScore", () => {
440
- it("errored(断言空数组)得 0 —— 不因「gate 全过」空真得满分", async () => {
441
- const s = snap({
442
- experimentId: "exp/x",
443
- results: [res("A", "errored", { assertions: [], error: "adapter crashed" })],
372
+ describe("MetricCell 诚实契约", () => {
373
+ it("measuredZero / partial / missing 三种格子互不混淆;refs 序列化后不丢", async () => {
374
+ const zero = snap({ experimentId: "exp/zero", results: [res("a", "failed")] });
375
+ const partial = snap({ experimentId: "exp/partial", results: [res("a", "passed"), res("b", "skipped")] });
376
+ const missing = snap({ experimentId: "exp/missing", results: [res("a", "skipped")] });
377
+ const table = await metricTableData([zero, partial, missing], {
378
+ rows: "experiment",
379
+ columns: [endToEndPassRate],
444
380
  });
445
- const data = await MetricTable.data([s], { rows: "agent", columns: [examScore] });
446
- const cell = data.rows[0].cells["exam-score"];
447
- expect(cell.value).toBe(0); // 交白卷是 0 分,不是缺数据,更不是满分
448
- expect(cell.samples).toBe(1);
381
+ const cellOf = (key: string) => table.rows.find((r) => r.key === key)!.cells[endToEndPassRate.name]!;
382
+ expect(cellOf("exp/zero")).toMatchObject({ value: 0, samples: 1, total: 1 });
383
+ expect(cellOf("exp/partial")).toMatchObject({ value: 1, samples: 1, total: 2 });
384
+ expect(cellOf("exp/missing")).toMatchObject({ value: null, samples: 0, total: 1 });
385
+ // 覆盖率与 refs 不因 JSON 序列化丢失;refs 跟随覆盖范围(含 null 值的 attempt)
386
+ const roundTrip = JSON.parse(JSON.stringify(table)) as typeof table;
387
+ expect(roundTrip.rows.find((r) => r.key === "exp/partial")!.cells[endToEndPassRate.name]!.refs).toHaveLength(2);
388
+ expect(cellOf("exp/missing").refs).toHaveLength(1);
449
389
  });
450
390
 
451
- it("failed 0,哪怕 soft 分不低(报告不重新判卷)", async () => {
452
- const s = snap({
453
- experimentId: "exp/x",
454
- results: [res("A", "failed", { assertions: [softAssertion("judge", 0.9)] })],
391
+ it(" o11y.json assistantTurns / repeatedFailedCommands 为 missing;result.json 指标不受影响", async () => {
392
+ const s = snap({ experimentId: "exp/noo11y", results: [res("a", "passed", { durationMs: 1234 })] });
393
+ const table = await metricTableData([s], {
394
+ rows: "agent",
395
+ columns: [assistantTurns, repeatedFailedCommands, durationMs],
455
396
  });
456
- const data = await MetricTable.data([s], { rows: "agent", columns: [examScore] });
457
- expect(data.rows[0].cells["exam-score"].value).toBe(0);
397
+ const cells = table.rows[0]!.cells;
398
+ expect(cells[assistantTurns.name]!.value).toBeNull();
399
+ expect(cells[repeatedFailedCommands.name]!.value).toBeNull();
400
+ expect(cells[durationMs.name]!.value).toBe(1234);
458
401
  });
459
402
 
460
- it("passed:soft 均分;gate 不参与给分;无 soft 则满分 1", async () => {
461
- const withSoft = snap({
462
- experimentId: "exp/a",
403
+ it("repeatedFailedCommands:同命令失败 3 次记 2;两条不同命令各失败 1 次记 0", async () => {
404
+ const repeat = snap({
405
+ experimentId: "exp/repeat",
463
406
  results: [
464
- res("A", "passed", {
465
- assertions: [
466
- softAssertion("judge-1", 0.5),
467
- softAssertion("judge-2", 1),
468
- { name: "includes", severity: "gate", score: 1, passed: true },
469
- ],
407
+ res("a", "failed", {
408
+ o11y: o11ySummary({
409
+ shellCommands: [
410
+ { command: "pnpm test", success: false },
411
+ { command: "pnpm test", success: false },
412
+ { command: "pnpm test", success: false },
413
+ { command: "ls", success: true },
414
+ ],
415
+ }),
470
416
  }),
471
417
  ],
472
418
  });
473
- const noSoft = snap({
474
- experimentId: "exp/b",
419
+ const distinct = snap({
420
+ experimentId: "exp/distinct",
475
421
  results: [
476
- res("B", "passed", {
477
- assertions: [{ name: "includes", severity: "gate", score: 1, passed: true }],
422
+ res("a", "failed", {
423
+ o11y: o11ySummary({
424
+ shellCommands: [
425
+ { command: "pnpm test", success: false },
426
+ { command: "pnpm build", success: false },
427
+ ],
428
+ }),
478
429
  }),
479
430
  ],
480
431
  });
481
- const a = await MetricTable.data([withSoft], { rows: "agent", columns: [examScore] });
482
- expect(a.rows[0].cells["exam-score"].value).toBe(0.75);
483
- const b = await MetricTable.data([noSoft], { rows: "agent", columns: [examScore] });
484
- expect(b.rows[0].cells["exam-score"].value).toBe(1);
485
- });
486
-
487
- it("skipped → null,不进聚合", async () => {
488
- const s = snap({ experimentId: "exp/x", results: [res("A", "skipped")] });
489
- const data = await MetricTable.data([s], { rows: "agent", columns: [examScore] });
490
- expect(data.rows[0].cells["exam-score"].value).toBeNull();
491
- });
492
- });
493
-
494
- // ───────────────────────── 内置指标口径 ─────────────────────────
495
-
496
- describe("内置指标", () => {
497
- it("tokens 只加 input+output,缓存读写不计入;无 usage → null", async () => {
498
- const s = snap({
499
- experimentId: "exp/x",
500
- results: [
501
- res("A", "passed", {
502
- usage: { inputTokens: 1000, outputTokens: 200, cacheReadTokens: 999_999, cacheWriteTokens: 888 },
503
- }),
504
- res("B", "failed"), // 无 usage → null,不稀释
505
- ],
432
+ const table = await metricTableData([repeat, distinct], {
433
+ rows: "experiment",
434
+ columns: [repeatedFailedCommands],
506
435
  });
507
- const data = await MetricTable.data([s], { rows: "agent", columns: [tokens] });
508
- const cell = data.rows[0].cells["tokens"];
509
- expect(cell.value).toBe(1200);
510
- expect(cell.display).toBe("1.2k tokens");
511
- expect(cell.samples).toBe(1);
512
- expect(cell.total).toBe(2);
436
+ const cellOf = (key: string) => table.rows.find((r) => r.key === key)!.cells[repeatedFailedCommands.name]!;
437
+ expect(cellOf("exp/repeat").value).toBe(2);
438
+ expect(cellOf("exp/distinct").value).toBe(0);
513
439
  });
514
440
 
515
- it("costUSD:网关实测优先于估算;durationMs errored 取实测", async () => {
441
+ it("value display 分别可断言;display 由 unit 或自定义 display(value, locale) 驱动", async () => {
516
442
  const s = snap({
517
- experimentId: "exp/x",
443
+ experimentId: "exp/display",
518
444
  results: [
519
- res("A", "passed", {
520
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.5 },
521
- estimatedCostUSD: 999, // 实测优先,不该被用到
522
- }),
523
- res("B", "errored", { durationMs: 3000 }),
445
+ ...[1, 1, 1, 1, 1].map((_, i) => res(`q${i}`, "passed")),
446
+ res("q5", "failed"),
524
447
  ],
525
448
  });
526
- const cost = await MetricTable.data([s], { rows: "agent", columns: [costUSD] });
527
- expect(cost.rows[0].cells["cost"].value).toBe(0.5);
528
- const dur = await MetricTable.data([s], { rows: "agent", columns: [durationMs] });
529
- expect(dur.rows[0].cells["duration"].value).toBe(2000); // (1000 + 3000)/2,errored 实测照算
449
+ const data = await scopeSummaryData([s]);
450
+ expect(data.endToEndPassRate.value).toBeCloseTo(5 / 6);
451
+ expect(data.endToEndPassRate.display).toBe("83.3%");
452
+
453
+ const localized = defineMetric({
454
+ name: "loc",
455
+ value: () => 1,
456
+ display: (value, locale) => (locale === "zh-CN" ? `${value} 个` : `${value} item`),
457
+ });
458
+ const table = await metricTableData([s], { rows: "agent", columns: [localized] });
459
+ expect(table.rows[0]!.cells.loc!.display).toEqual({ en: "1 item", "zh-CN": "1 个" });
530
460
  });
531
461
 
532
- it("turns:读 o11y.totalTurns;o11y 缺失(未随发布带上) null,不是 0;skipped 不进聚合", async () => {
533
- const s = snap({
534
- experimentId: "exp/x",
535
- results: [
536
- res("A", "passed", { o11y: o11ySummary(12) }),
537
- res("B", "failed"), // 没带 o11y(如 copySnapshots 漏选 artifact)→ null,不稀释成 0
538
- res("C", "skipped", { o11y: o11ySummary(3) }), // skipped 恒 null,哪怕 o11y 在场
539
- ],
462
+ it("value() 抛错时整个计算失败,错误带 metric name 与 attempt locator,不伪装成测不了", async () => {
463
+ const bad = defineMetric({
464
+ name: "explode",
465
+ value: () => {
466
+ throw new Error("boom");
467
+ },
540
468
  });
541
- const data = await MetricTable.data([s], { rows: "agent", columns: [turns] });
542
- const cell = data.rows[0].cells["turns"];
543
- expect(cell.value).toBe(12);
544
- expect(cell.samples).toBe(1); // 只有 A 测得了(B 缺 o11y、C 是 skipped)
545
- expect(cell.total).toBe(3); // total 是组内全部 attempt 数,不是「本该测得」的分母
469
+ const s = snap({ experimentId: "exp/bad", results: [res("a", "passed")] });
470
+ await expect(metricTableData([s], { rows: "agent", columns: [bad] })).rejects.toThrow(/explode.*boom/s);
546
471
  });
547
472
  });
548
473
 
549
- // ───────────────────────── Scoreboard.data ─────────────────────────
474
+ // ───────────────────────── Scoreboard ─────────────────────────
550
475
 
551
- describe("Scoreboard.data", () => {
552
- it("固定分母 + missing 如实 + 权重最长前缀生效", async () => {
553
- const alpha = snap({
554
- experimentId: "exp/alpha",
555
- agent: "alpha",
556
- results: [
557
- res("algebra/x", "passed", { agent: "alpha" }),
558
- res("algebra/hard/y", "passed", { agent: "alpha" }),
559
- res("geometry/z", "passed", { agent: "alpha" }),
560
- ],
476
+ describe("scoreboardData", () => {
477
+ it("固定题集分母:未跑题按 0 分计入 notRun;跑了但 null 的题计入 unscorable,两个计数不合并", async () => {
478
+ const nullScore = defineMetric({
479
+ name: "maybe-score",
480
+ value: (attempt) => (attempt.evalId === "s/unscorable" ? null : attempt.result.verdict === "passed" ? 1 : 0),
561
481
  });
562
- const beta = snap({
563
- experimentId: "exp/beta",
564
- agent: "beta",
565
- results: [res("algebra/x", "passed", { agent: "beta" })],
482
+ const s = snap({
483
+ experimentId: "exp/board",
484
+ results: [res("s/ran", "passed"), res("s/unscorable", "passed")],
566
485
  });
567
- const board = await Scoreboard.data([alpha, beta], {
486
+ const data = await scoreboardData([s], {
568
487
  rows: "agent",
569
- subjects: "evalGroup",
570
- weights: { "algebra/": 3, "algebra/hard/": 9 },
571
- fullMarks: 100,
488
+ questions: ["s/ran", "s/unscorable", "s/never-1", "s/never-2"],
489
+ score: nullScore,
572
490
  });
573
- expect(board.dimension).toBe("agent");
574
- // 生效权重表可审计:最长前缀在前(匹配顺序)
575
- expect(board.weights).toEqual([
576
- { prefix: "algebra/hard/", weight: 9 },
577
- { prefix: "algebra/", weight: 3 },
578
- ]);
579
-
580
- const alphaRow = board.rows.find((r) => r.key === "alpha")!;
581
- const betaRow = board.rows.find((r) => r.key === "beta")!;
582
-
583
- // Σ分值 = 3(algebra/x)+ 9(algebra/hard/y,最长前缀 9 不是 3)+ 1(geometry/z,默认)= 13
584
- expect(alphaRow.total.value).toBeCloseTo(100);
585
- // beta 只答了 algebra/x:总分 = 100 × 3/13 ≈ 23.1。
586
- // 若最长前缀错配成 "algebra/" 的 3,分母变 7,会得 100 × 3/7 ≈ 42.9 —— 钉死歧义。
587
- expect(betaRow.total.value).toBeCloseTo((100 * 3) / 13);
588
-
589
- // 固定分母:beta 没跑的题留在分母,missing 如实报在科目行
590
- const betaAlgebra = betaRow.subjects.find((s) => s.key === "algebra")!;
591
- expect(betaAlgebra.possible).toBe(12); // 3 + 9
592
- expect(betaAlgebra.earned).toBe(3);
593
- expect(betaAlgebra.evals).toBe(2);
594
- expect(betaAlgebra.missing).toBe(1);
595
- const betaGeometry = betaRow.subjects.find((s) => s.key === "geometry")!;
596
- expect(betaGeometry.possible).toBe(1);
597
- expect(betaGeometry.earned).toBe(0);
598
- expect(betaGeometry.missing).toBe(1);
599
-
600
- const alphaAlgebra = alphaRow.subjects.find((s) => s.key === "algebra")!;
601
- expect(alphaAlgebra.earned).toBe(12);
602
- expect(alphaAlgebra.missing).toBe(0);
491
+ const row = data.rows[0]!;
492
+ // 分母恒 4:1 分挣到 1(s/ran),其余 0 → 100 × 1/4 = 25
493
+ expect(row.total.value).toBe(25);
494
+ expect(row.total.notRun).toBe(2);
495
+ expect(row.total.unscorable).toBe(1);
496
+ expect(row.subjects[0]!.questions).toBe(4);
603
497
  });
604
498
 
605
- it("默认 score examScore:soft 分进总分;skipped missing 而非 0 分入账", async () => {
606
- const solo = snap({
607
- experimentId: "exp/solo",
608
- agent: "solo",
609
- results: [
610
- res("algebra/x", "passed", { agent: "solo", assertions: [softAssertion("judge", 0.5)] }),
611
- res("algebra/y", "skipped", { agent: "solo" }), // 无有效样本 → missing(按 0 计但如实标注)
612
- ],
499
+ it("权重按最长前缀命中;无命中默认 1;总分 fullMarks × earned / possible", async () => {
500
+ const s = snap({
501
+ experimentId: "exp/w",
502
+ results: [res("security/auth/a", "passed"), res("security/b", "failed"), res("misc", "passed")],
613
503
  });
614
- const board = await Scoreboard.data([solo], { rows: "agent" });
615
- const row = board.rows[0];
616
- // 两题各 1 分:0.5 + 0(missing)→ 100 × 0.5/2 = 25
617
- expect(row.total.value).toBeCloseTo(25);
618
- expect(row.subjects[0].missing).toBe(1);
619
- expect(row.subjects[0].evals).toBe(2);
504
+ const data = await scoreboardData([s], {
505
+ rows: "agent",
506
+ questions: ["security/auth/a", "security/b", "misc"],
507
+ weights: { "security/": 2, "security/auth/": 4 },
508
+ fullMarks: 100,
509
+ });
510
+ const row = data.rows[0]!;
511
+ // earned = 4(auth/a) + 0 + 1(misc) = 5;possible = 4 + 2 + 1 = 7
512
+ expect(row.total.value).toBeCloseTo((100 * 5) / 7);
513
+ expect(data.weights[0]).toEqual({ prefix: "security/auth/", weight: 4 }); // 最长前缀在前
620
514
  });
621
515
 
622
- it("快照携带的 knownEvalIds 进固定分母(发布目录上的残缺不消失)", async () => {
516
+ it("subject 缺省与 evalGroup 同一条规则(完整父路径);题集外的 eval 忽略并计入 ignoredEvals", async () => {
623
517
  const s = snap({
624
- experimentId: "exp/x",
625
- agent: "solo",
626
- results: [res("algebra/x", "passed", { agent: "solo" })],
627
- knownEvalIds: ["algebra/x", "algebra/y"],
518
+ experimentId: "exp/subject",
519
+ results: [res("a/b/c", "passed"), res("outside", "passed")],
628
520
  });
629
- const board = await Scoreboard.data([s], { rows: "agent" });
630
- expect(board.rows[0].subjects[0].evals).toBe(2);
631
- expect(board.rows[0].subjects[0].missing).toBe(1);
632
- expect(board.rows[0].total.value).toBeCloseTo(50);
521
+ const data = await scoreboardData([s], { rows: "agent", questions: ["a/b/c"] });
522
+ expect(data.rows[0]!.subjects[0]!.key).toBe("a/b");
523
+ expect(data.ignoredEvals).toBe(1);
633
524
  });
634
- });
635
-
636
- // ───────────────────────── MetricScatter.data ─────────────────────────
637
525
 
638
- describe("MetricScatter.data", () => {
639
- it("任一轴 null 的点仍在 rows 里、可数;series 随组解析", async () => {
640
- const withCost = snap({
641
- experimentId: "exp/a",
642
- agent: "a1",
643
- results: [res("A", "passed", { agent: "a1", usage: { inputTokens: 10, outputTokens: 5, costUSD: 0.5 } })],
644
- });
645
- const noCost = snap({
646
- experimentId: "exp/b",
647
- agent: "b1",
648
- results: [res("A", "passed", { agent: "b1" })],
649
- });
650
- const data = await MetricScatter.data([withCost, noCost], {
651
- points: "experiment",
652
- series: "agent",
653
- x: costUSD,
654
- y: passRate,
655
- });
656
- expect(data.points).toBe("experiment");
657
- expect(data.series).toBe("agent");
658
- expect(data.rows).toHaveLength(2);
659
-
660
- const a = data.rows.find((r) => r.key === "exp/a")!;
661
- expect(a.series).toBe("a1");
662
- expect(a.x.value).toBe(0.5);
663
- expect(a.y.value).toBe(1);
664
-
665
- const b = data.rows.find((r) => r.key === "exp/b")!;
666
- expect(b.x.value).toBeNull(); // 没有成本数据:点在,值缺
667
- expect(b.y.value).toBe(1);
668
- // 注脚「n 个点缺数据」就从 rows 里数出来,不需要另一份数据
669
- expect(data.rows.filter((r) => r.x.value === null || r.y.value === null)).toHaveLength(1);
526
+ it("questions 空数组 / 重复、非法权重、fullMarks<=0、score 出界、subject 空串都按完整用户反馈失败", async () => {
527
+ const s = snap({ experimentId: "exp/e", results: [res("a", "passed")] });
528
+ await expect(scoreboardData([s], { rows: "agent", questions: [] })).rejects.toThrow(/non-empty/);
529
+ await expect(scoreboardData([s], { rows: "agent", questions: ["a", "a"] })).rejects.toThrow(/twice/);
530
+ await expect(
531
+ scoreboardData([s], { rows: "agent", questions: ["a"], weights: { a: 0 } }),
532
+ ).rejects.toThrow(/positive finite/);
533
+ await expect(scoreboardData([s], { rows: "agent", questions: ["a"], fullMarks: 0 })).rejects.toThrow(/fullMarks/);
534
+ await expect(
535
+ scoreboardData([s], { rows: "agent", questions: ["a"], score: defineMetric({ name: "big", value: () => 2 }) }),
536
+ ).rejects.toThrow(/\[0, 1\]/);
537
+ await expect(
538
+ scoreboardData([s], { rows: "agent", questions: ["a"], subject: () => "" }),
539
+ ).rejects.toThrow(/empty/);
670
540
  });
671
541
  });
672
542
 
673
- // ───────────────────────── flag():维度与轴 ─────────────────────────
674
-
675
- describe("flag()", () => {
676
- const withFlags = (id: string, flags: Record<string, unknown> | undefined, verdict: Verdict) =>
677
- snap({
678
- experimentId: id,
679
- results: [res("A", verdict, { experimentId: id, experiment: { id, flags } })],
680
- });
681
-
682
- it("MetricLine.data:x 收 flag、按 experiment 聚合;未声明的作轴 x=null 报数", async () => {
683
- const s1 = withFlags("ultra/lat-100", { latencyMs: 100, agents: 1 }, "passed");
684
- const s2 = withFlags("ultra/lat-300", { latencyMs: 300, agents: 1 }, "failed");
685
- const legacy = withFlags("ultra/legacy", undefined, "passed");
686
- const data = await MetricLine.data([s1, s2, legacy], {
687
- x: flag("latencyMs", { label: "Simulated latency", unit: "ms" }),
688
- series: flag("agents", { label: (v) => `${v} agents` }),
689
- y: passRate,
690
- });
691
- expect(data.x).toEqual({ key: "latencyMs", label: "Simulated latency", unit: "ms" });
692
- expect(data.series).toBe("agents");
693
- expect(data.rows).toHaveLength(3);
694
-
695
- const p100 = data.rows.find((r) => r.key === "ultra/lat-100")!;
696
- expect(p100.x).toBe(100);
697
- expect(p100.xDisplay).toBe("100ms");
698
- expect(p100.series).toBe("1 agents");
699
- expect(p100.y.value).toBe(1);
700
-
701
- // 未声明 flag 的 experiment 不猜:作轴 x=null(组件不画、注脚报数),分组归 (unset)
702
- const legacyRow = data.rows.find((r) => r.key === "ultra/legacy")!;
703
- expect(legacyRow.x).toBeNull();
704
- expect(legacyRow.xDisplay).toBe("");
705
- expect(legacyRow.series).toBe("(unset)");
543
+ // ───────────────────────── 实体列表 ─────────────────────────
544
+
545
+ describe("实体列表 data", () => {
546
+ const failed = res("list/failed", "failed", {
547
+ assertions: [
548
+ {
549
+ name: "equals",
550
+ severity: "gate",
551
+ outcome: "failed" as const,
552
+ score: 0,
553
+ detail: "equals(42)",
554
+ expected: "42",
555
+ received: "41",
556
+ },
557
+ { name: "second", severity: "gate", outcome: "failed" as const, score: 0, detail: "second-check" },
558
+ ] as AssertionResult[],
559
+ usage: { inputTokens: 10, outputTokens: 5, costUSD: 0.1 },
706
560
  });
707
-
708
- it("flag 当维度用:按声明值分组,label 函数折组名", async () => {
709
- const s1 = withFlags("exp/a", { agents: 1 }, "passed");
710
- const s2 = withFlags("exp/b", { agents: 16 }, "failed");
711
- const data = await MetricTable.data([s1, s2], {
712
- rows: flag("agents", { label: (v) => `${v} agents` }),
713
- columns: [passRate],
714
- });
715
- expect(data.dimension).toBe("agents");
716
- expect(data.rows.map((r) => r.key)).toEqual(["1 agents", "16 agents"]);
561
+ const errored = res("list/errored", "errored", {
562
+ error: {
563
+ code: "sandbox-create-failed",
564
+ message: "docker daemon unreachable",
565
+ phase: "sandbox.create",
566
+ stack: "Error: docker daemon unreachable\n at boot (sandbox.ts:10:3)",
567
+ },
717
568
  });
718
- });
719
-
720
- // ───────────────────────── MetricTable.data:没有实体下钻 ─────────────────────────
721
-
722
- describe("MetricTable.data", () => {
723
- it("rows: \"experiment\" meta 不带 subRows —— MetricTable 只表达维度 × 指标,没有 expand", async () => {
724
- const s = snap({
725
- experimentId: "exp/x",
726
- results: [res("A", "passed"), res("B", "failed")],
727
- });
728
- const data = await MetricTable.data([s], { rows: "experiment", columns: [passRate] });
729
- expect(data.rows).toHaveLength(1);
730
- expect(data.rows[0]!.meta).not.toHaveProperty("subRows");
731
- // 展开到 Eval 是 ExperimentList 的职责,不在这里验证 —— 见 ExperimentList.data 的测试组。
569
+ const passed = res("list/passed", "passed");
570
+ const skipped = res("list/skipped", "skipped");
571
+ const listSnap = () => snap({ experimentId: "exp/list", results: [failed, errored, passed, skipped] });
572
+
573
+ it("failureSummary 三态:failed 取主失败断言摘要、errored 取 error 一层摘要(phase · code · message)、passed/skipped 为 null", async () => {
574
+ const items = await attemptListData([listSnap()]);
575
+ const byEval = new Map(items.map((item) => [item.evalId, item]));
576
+ expect(byEval.get("list/failed")!.failureSummary).toContain("equals(42)");
577
+ expect(byEval.get("list/failed")!.failureSummary).toContain("received 41");
578
+ expect(byEval.get("list/failed")!.moreFailures).toBe(1);
579
+ expect(byEval.get("list/errored")!.failureSummary).toBe(
580
+ "sandbox.create · sandbox-create-failed · docker daemon unreachable",
581
+ );
582
+ expect(byEval.get("list/passed")!.failureSummary).toBeNull();
583
+ expect(byEval.get("list/skipped")!.failureSummary).toBeNull();
732
584
  });
733
- });
734
585
 
735
- // ───────────────────────── reasonFor(原因优先级)─────────────────────────
736
- // docs/feature/reports/show.md `show` 的 RESULT 列口径:error → skipReason → 未通过的 gate 断言。
737
- // ExperimentList / EvalList 的 evalRows.reason、AttemptList 的 attemptItemReason 都port这同一份材料,
738
- // 这里直接测 reasonFor 本身,不必再经 MetricTable 之类的中间组件绕一圈。
739
-
740
- describe("reasonFor", () => {
741
- it("同一 result 同时含 error、失败 gate、失败 soft → 只显示 error", () => {
742
- const result = res("A", "errored", {
743
- error: "adapter crashed",
744
- assertions: [
745
- { name: "includes", severity: "gate", score: 0, passed: false, detail: "missing text" },
746
- softAssertion("judge", 0.2, { passed: false }),
747
- ],
748
- });
749
- expect(reasonFor(result)).toBe("adapter crashed");
586
+ it("序列化 JSON 不含第二条断言文本、stack、evidence 或 diagnostics;costUSD 缺失一律 null", async () => {
587
+ const items = await attemptListData([listSnap()]);
588
+ const json = JSON.stringify(items);
589
+ expect(json).not.toContain("second-check");
590
+ expect(json).not.toContain("sandbox.ts:10:3");
591
+ expect(json).not.toContain('"assertions"');
592
+ expect(json).not.toContain('"diagnostics"');
593
+ const byEval = new Map(items.map((item) => [item.evalId, item]));
594
+ expect(byEval.get("list/failed")!.costUSD).toBe(0.1);
595
+ expect(byEval.get("list/errored")!.costUSD).toBeNull();
750
596
  });
751
597
 
752
- it("skipReason 优先于 gate 断言,但让位给 error", () => {
753
- const result = res("A", "skipped", {
754
- skipReason: "missing fixture",
755
- assertions: [{ name: "includes", severity: "gate", score: 0, passed: false }],
756
- });
757
- expect(reasonFor(result)).toBe("missing fixture");
598
+ it("redact 只改写 failureSummary(含嵌套 attempt 条目);身份字段、locator 与数值指标原样", async () => {
599
+ const redact = (text: string) => text.replaceAll("41", "[redacted]");
600
+ const attempts = await attemptListData([listSnap()], { redact });
601
+ const failedItem = attempts.find((item) => item.evalId === "list/failed")!;
602
+ expect(failedItem.failureSummary).toContain("[redacted]");
603
+ expect(failedItem.evalId).toBe("list/failed");
604
+ expect(failedItem.experimentId).toBe("exp/list");
605
+ expect(failedItem.costUSD).toBe(0.1);
606
+
607
+ const evals = await evalListData([listSnap()], { redact });
608
+ const nested = evals.find((item) => item.evalId === "list/failed")!.attempts[0]!;
609
+ expect(nested.failureSummary).toContain("[redacted]");
610
+
611
+ const experiments = await experimentListData([listSnap()], { redact });
612
+ const nestedInExp = experiments[0]!.evalRows.find((row) => row.evalId === "list/failed")!.attempts[0]!;
613
+ expect(nestedInExp.failureSummary).toContain("[redacted]");
758
614
  });
759
615
 
760
- it("多个失败 gate 按原始声明顺序全部保留,拼接成一行;失败 soft 不出现在 reason ", () => {
761
- const result = res("A", "failed", {
762
- assertions: [
763
- softAssertion("judge-first", 0.1, { passed: false }), // 声明顺序第一,但 soft 不进 reason
764
- { name: "includes", severity: "gate", score: 0, passed: false, detail: "missing text" },
765
- { name: "matches", severity: "gate", score: 0, passed: false }, // 无 detail,只有 name
766
- softAssertion("judge-last", 0.3, { passed: false }), // soft 依旧不进 reason
767
- ],
768
- });
769
- expect(reasonFor(result)).toBe("includes: missing text, matches");
616
+ it("experimentListData:evalVerdicts / endToEndPassRate / costUSD / durationMs / tokens 齐全,默认按端到端成功率降序", async () => {
617
+ const winner = snap({ experimentId: "exp/win", results: [res("a", "passed"), res("b", "passed")] });
618
+ const loser = snap({ experimentId: "exp/lose", results: [res("a", "failed"), res("b", "passed")] });
619
+ const items = await experimentListData([loser, winner]);
620
+ expect(items.map((item) => item.experimentId)).toEqual(["exp/win", "exp/lose"]);
621
+ expect(items[0]!.evalVerdicts).toEqual({ passed: 2, failed: 0, errored: 0, skipped: 0 });
622
+ expect(items[0]!.endToEndPassRate.value).toBe(1);
623
+ expect(items[1]!.evals).toBe(2);
770
624
  });
771
625
 
772
- it("都缺席 无原因", () => {
773
- expect(reasonFor(res("A", "passed"))).toBeUndefined();
626
+ it("同一 experiment 的输入含不一致可比性配置时按完整用户反馈失败,指引 snapshot 维度 / MetricLine", async () => {
627
+ const a = snap({ experimentId: "exp/mixed", model: "gpt-a", results: [res("x", "passed")] });
628
+ const b = snap({ experimentId: "exp/mixed", model: "gpt-b", results: [res("y", "passed")] });
629
+ await expect(experimentListData([a, b])).rejects.toThrow(/snapshot.*MetricLine|MetricLine/s);
630
+ // current() 口径的 Scope(一实验一配置)照常计算
631
+ const clean = scopeOf([a]);
632
+ await expect(experimentListData(clean)).resolves.toHaveLength(1);
774
633
  });
775
634
  });
776
635
 
777
- // ───────────────────────── RunOverview.data ─────────────────────────
778
-
779
- describe("RunOverview.data", () => {
780
- it("costUSD 全缺为 null 不编 0;有实测/估算则求和;Snapshot[] 输入无警告", async () => {
781
- const bare = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
782
- const bareOverview = await RunOverview.data([bare]);
783
- expect(bareOverview.totals.costUSD).toBeNull();
784
- expect(bareOverview.totals.attempts).toBe(2);
785
- expect(bareOverview.totals.passed).toBe(1);
786
- expect(bareOverview.totals.failed).toBe(1);
787
- expect(bareOverview.warnings).toEqual([]);
636
+ // ───────────────────────── scopeSummaryData ─────────────────────────
637
+
638
+ describe("scopeSummaryData", () => {
639
+ it("evals experimentId + evalId 计数(2 实验 × 6 题 = 12),与 evalVerdicts 同分母;两级计票在含重试时不同", async () => {
640
+ const mk = (experimentId: string) =>
641
+ snap({
642
+ experimentId,
643
+ results: [
644
+ res("q1", "passed"),
645
+ res("q2", "failed", { attempt: 0 }),
646
+ res("q2", "passed", { attempt: 1 }), // 重试后过:eval 级 passed,attempt 级 1 failed + 1 passed
647
+ res("q3", "passed"),
648
+ res("q4", "failed"),
649
+ res("q5", "errored", { error: erroredWith("x") }),
650
+ res("q6", "skipped"),
651
+ ],
652
+ });
653
+ const data = await scopeSummaryData([mk("cmp/a"), mk("cmp/b")]);
654
+ expect(data.experiments).toBe(2);
655
+ expect(data.evals).toBe(12);
656
+ expect(data.evalVerdicts).toEqual({ passed: 6, failed: 2, errored: 2, skipped: 2 });
657
+ expect(
658
+ data.evalVerdicts.passed + data.evalVerdicts.failed + data.evalVerdicts.errored + data.evalVerdicts.skipped,
659
+ ).toBe(data.evals);
660
+ expect(data.attemptVerdicts).toEqual({ passed: 6, failed: 4, errored: 2, skipped: 2 });
661
+ expect(data.attemptVerdicts).not.toEqual(data.evalVerdicts);
662
+ expect(data.range.earliestStartedAt).not.toBeNull();
663
+ expect(data.range.latestStartedAt).not.toBeNull();
664
+ });
788
665
 
789
- const priced = snap({
790
- experimentId: "exp/y",
666
+ it("totalCostUSD attempt 求和;一次成本都没报时 value 为 null,不伪造 0", async () => {
667
+ const withCost = snap({
668
+ experimentId: "exp/cost",
791
669
  results: [
792
- res("A", "passed", { estimatedCostUSD: 0.1 }),
793
- res("B", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } }),
670
+ res("a", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.25 } }),
671
+ res("b", "failed", { estimatedCostUSD: 0.05 }),
672
+ res("c", "passed"),
794
673
  ],
795
674
  });
796
- const data = await RunOverview.data([priced]);
797
- expect(data.totals.costUSD).toBeCloseTo(0.3);
798
- expect(data.totals.evals).toBe(2);
799
- expect(data.snapshots).toEqual([
800
- { experimentId: "exp/y", agent: "agent-x", model: undefined, startedAt: priced.startedAt },
801
- ]);
675
+ const data = await scopeSummaryData([withCost]);
676
+ expect(data.totalCostUSD.value).toBeCloseTo(0.3);
677
+ const none = await scopeSummaryData([snap({ experimentId: "exp/free", results: [res("a", "passed")] })]);
678
+ expect(none.totalCostUSD.value).toBeNull();
802
679
  });
803
680
 
804
- it(" Selection warnings 随行进数据 —— 诚实不靠使用者记得透传", async () => {
805
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
806
- const warning: SelectionWarning = {
807
- kind: "partial-coverage",
808
- experimentId: "exp/x",
809
- covered: 1,
810
- total: 50,
811
- message: "snapshot covers 1 of 50 evals seen in history",
812
- };
813
- const data = await RunOverview.data(selection([s], [warning]));
814
- expect(data.warnings).toEqual([warning]);
681
+ it("空范围的 range null,不编造当前时间", async () => {
682
+ const data = await scopeSummaryData([]);
683
+ expect(data.range).toEqual({ earliestStartedAt: null, latestStartedAt: null });
684
+ expect(data.evals).toBe(0);
815
685
  });
816
686
  });
817
687
 
818
- // ───────────────────────── GroupSummary.data ─────────────────────────
688
+ // ───────────────────────── experimentComparisonData ─────────────────────────
819
689
 
820
- describe("GroupSummary.data", () => {
821
- it(" experiment、多 attempt、部分成本缺失、不同 startedAt:eval 级折叠计票(完整身份键)、总成本 null-safe 求和、lastRunAt 取组内最大值", async () => {
822
- const expA = snap({
823
- experimentId: "exp/a",
824
- runStartedAt: "2026-07-01T08:00:00Z",
825
- results: [
826
- // "shared" 两轮:一败一过 → 折叠成 passed(与 exp/b 同名的 "shared" 各算各的,见下方独立测试)
827
- res("shared", "failed", { attempt: 0 }),
828
- res("shared", "passed", { attempt: 1, usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.5 } }),
829
- // "only-a" 只有 exp/a 跑过,失败、没报成本
830
- res("only-a", "failed"),
831
- ],
832
- });
833
- const expB = snap({
834
- experimentId: "exp/b",
835
- runStartedAt: "2026-07-03T08:00:00Z", // 比 exp/a 晚 —— lastRunAt 应取这个
836
- results: [
837
- // 与 exp/a 同名的 "shared":不同 experiment,必须独立计一票,不能被误合并
838
- res("shared", "errored", { estimatedCostUSD: 0.2 }),
839
- ],
840
- });
690
+ describe("experimentComparisonData", () => {
691
+ it("计算前按完整父路径分区,根目录 experiment 单例组;每组与独立调用三个计算函数深等,refs 不跨组", async () => {
692
+ const g1a = snap({ experimentId: "compare/a", agent: "bub", results: [res("q", "passed")] });
693
+ const g1b = snap({ experimentId: "compare/b", agent: "codex", results: [res("q", "failed")] });
694
+ const g2 = snap({ experimentId: "bench/long/x", results: [res("q", "passed")] });
695
+ const solo = snap({ experimentId: "standalone", results: [res("q", "errored", { error: erroredWith("x") })] });
841
696
 
842
- const data = await GroupSummary.data([expA, expB]);
697
+ const data = await experimentComparisonData([g1a, g1b, g2, solo]);
698
+ expect(data.groups.map((g) => g.key)).toEqual(["bench/long", "compare", "standalone"]);
843
699
 
844
- expect(data.experiments).toBe(2);
845
- // 3 道题:exp/a shared、exp/a only-a、exp/b 的 shared —— 完整身份键各算各的
846
- expect(data.evals).toBe(3);
847
- expect(data.attempts).toBe(4); // 原始 attempt 计数:exp/a 2(shared)+1(only-a)+ exp/b 1(shared)
848
- expect(data.verdicts).toEqual({ passed: 1, failed: 1, errored: 1, skipped: 0 });
849
- // eval 级折叠通过率:1 / (1 + 1 + 1) = 1/3,不是 attempt 原始占比(1 passed / 4 attempts = 25%)
850
- expect(data.passRate.value).toBeCloseTo(1 / 3, 10);
851
- expect(data.passRate.display).toBe("33.3%");
852
- expect(data.passRate.samples).toBe(3);
853
- expect(data.passRate.total).toBe(3);
854
- // 总成本 null-safe 求和:0.5(实测)+ 0.2(估算)= 0.7;only-a 的失败 attempt 没报成本,不计 0
855
- expect(data.totalCostUSD).toBeCloseTo(0.7, 10);
856
- // 最后运行时间:组内两个快照的 startedAt 取最大值(exp/b 更晚)
857
- expect(data.lastRunAt).toBe(expB.startedAt);
700
+ const compare = data.groups.find((g) => g.key === "compare")!;
701
+ const [summary, scatter, experiments] = await Promise.all([
702
+ scopeSummaryData([g1a, g1b]),
703
+ metricScatterData([g1a, g1b], { points: "experiment", series: "agent", x: costUSD, y: endToEndPassRate }),
704
+ experimentListData([g1a, g1b]),
705
+ ]);
706
+ expect(compare.summary).toEqual(summary);
707
+ expect(compare.scatter).toEqual(scatter);
708
+ expect(compare.experiments).toEqual(experiments);
709
+
710
+ // 组外 attempt 不污染:compare 组的 refs 不含 bench/standalone locator
711
+ const soloRefs = data.groups.find((g) => g.key === "standalone")!.summary.endToEndPassRate.refs;
712
+ const compareRefs = compare.summary.endToEndPassRate.refs;
713
+ expect(compareRefs.some((ref) => soloRefs.includes(ref))).toBe(false);
858
714
  });
715
+ });
859
716
 
860
- it('跨 experiment 同名 eval 不合并:两个 experiment 各自的 "x" 独立计一票,不是被折成一票', async () => {
861
- const a = snap({ experimentId: "exp/a", results: [res("x", "passed")] });
862
- const b = snap({ experimentId: "exp/b", results: [res("x", "failed")] });
863
- const data = await GroupSummary.data([a, b]);
864
- expect(data.experiments).toBe(2);
865
- expect(data.evals).toBe(2); // 只按 eval id 折叠(误把两个 experiment 的 "x" 当一道题)会变成 1
866
- expect(data.verdicts).toEqual({ passed: 1, failed: 1, errored: 0, skipped: 0 });
867
- expect(data.passRate.value).toBeCloseTo(0.5, 10); // 误合并会因为「任一轮过即过」变成 100%
868
- });
717
+ // ───────────────────────── metricScatterData / metricMatrixData ─────────────────────────
869
718
 
870
- it("全组没有任何 attempt 报成本 → totalCostUSD null,不编 0", async () => {
871
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
872
- const data = await GroupSummary.data([s]);
873
- expect(data.totalCostUSD).toBeNull();
719
+ describe("metricScatterData / metricMatrixData", () => {
720
+ it("缺 x y 的点留在 rows 里可数(组件不画并报数);矩阵稀疏:无 attempt 的组合不生成格子", async () => {
721
+ const withCost = snap({
722
+ experimentId: "cmp/priced",
723
+ agent: "bub",
724
+ results: [res("a", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
725
+ });
726
+ const noCost = snap({ experimentId: "cmp/free", agent: "codex", results: [res("b", "passed")] });
727
+ const scatter = await metricScatterData([withCost, noCost], {
728
+ points: "experiment",
729
+ series: "agent",
730
+ x: costUSD,
731
+ y: endToEndPassRate,
732
+ });
733
+ expect(scatter.pointDimension).toBe("experiment");
734
+ expect(scatter.rows).toHaveLength(2);
735
+ expect(scatter.rows.find((r) => r.key === "cmp/free")!.x.value).toBeNull();
736
+
737
+ const matrix = await metricMatrixData([withCost, noCost], {
738
+ rows: "eval",
739
+ columns: "agent",
740
+ cell: endToEndPassRate,
741
+ });
742
+ // a×codex、b×bub 没有样本 → 不出现(不是 value: 0)
743
+ expect(matrix.cells).toHaveLength(2);
744
+ expect(matrix.rowDimension).toBe("eval");
745
+ expect(matrix.columnDimension).toBe("agent");
874
746
  });
875
747
 
876
- it("分母为 0(组内 eval 全部 skipped) passRate.value 为 null,不编 0%", async () => {
877
- const s = snap({ experimentId: "exp/x", results: [res("A", "skipped"), res("B", "skipped")] });
878
- const data = await GroupSummary.data([s]);
879
- expect(data.verdicts).toEqual({ passed: 0, failed: 0, errored: 0, skipped: 2 });
880
- expect(data.passRate.value).toBeNull();
881
- expect(data.passRate.display).toBe("—");
882
- expect(data.passRate.samples).toBe(0);
883
- expect(data.passRate.total).toBe(2);
748
+ it("分组维度上未声明的 flag (missing) 组,不丢行", async () => {
749
+ const withFlag = snap({
750
+ experimentId: "f/on",
751
+ results: [res("a", "passed")],
752
+ experiment: { runs: 1, earlyExit: false, selectedEvalIds: [], flags: { memory: "mempal" } },
753
+ });
754
+ const withoutFlag = snap({ experimentId: "f/off", results: [res("a", "failed")] });
755
+ const table = await metricTableData([withFlag, withoutFlag], {
756
+ rows: flag("memory"),
757
+ columns: [endToEndPassRate],
758
+ });
759
+ expect(table.rows.map((r) => r.key).sort()).toEqual(["(missing)", "mempal"]);
884
760
  });
885
761
 
886
- it("lastRunAt 只从传入的组 Selection 取值,不读全局最新 —— 组外快照更晚也不影响", async () => {
887
- const inGroup = snap({
888
- experimentId: "exp/a",
889
- runStartedAt: "2026-07-01T08:00:00Z",
890
- results: [res("A", "passed")],
762
+ it("metricTableData sort:必须是 columns 中同一实例且声明 better;方向随 better,缺数据沉底", async () => {
763
+ const hi = snap({ experimentId: "s/hi", results: [res("a", "passed")] });
764
+ const lo = snap({ experimentId: "s/lo", results: [res("a", "failed")] });
765
+ const na = snap({ experimentId: "s/na", results: [res("a", "skipped")] });
766
+ const byPass = await metricTableData([lo, hi, na], {
767
+ rows: "experiment",
768
+ columns: [endToEndPassRate],
769
+ sort: endToEndPassRate,
891
770
  });
892
- const outOfGroup = snap({
893
- experimentId: "exp/b",
894
- runStartedAt: "2026-07-09T08:00:00Z",
895
- results: [res("B", "passed")],
771
+ expect(byPass.rows.map((r) => r.key)).toEqual(["s/hi", "s/lo", "s/na"]);
772
+
773
+ const fast = snap({ experimentId: "d/fast", results: [res("a", "passed", { durationMs: 10 })] });
774
+ const slow = snap({ experimentId: "d/slow", results: [res("a", "passed", { durationMs: 99 })] });
775
+ const byDuration = await metricTableData([slow, fast], {
776
+ rows: "experiment",
777
+ columns: [durationMs],
778
+ sort: durationMs,
896
779
  });
897
- const sel = selection([inGroup, outOfGroup], []);
898
- const scoped = sel.filter((s) => s.experimentId === "exp/a"); // 只留组内快照,模拟按实验组收窄的 Selection
899
- const data = await GroupSummary.data(scoped);
900
- expect(data.lastRunAt).toBe(inGroup.startedAt);
901
- expect(data.lastRunAt).not.toBe(outOfGroup.startedAt);
780
+ expect(byDuration.rows.map((r) => r.key)).toEqual(["d/fast", "d/slow"]); // lower better:低在前
781
+
782
+ await expect(
783
+ metricTableData([hi], { rows: "experiment", columns: [endToEndPassRate], sort: durationMs }),
784
+ ).rejects.toThrow(/columns/);
785
+ const noBetter = defineMetric({ name: "plain", value: () => 1 });
786
+ await expect(
787
+ metricTableData([hi], { rows: "experiment", columns: [noBetter], sort: noBetter }),
788
+ ).rejects.toThrow(/better/);
902
789
  });
903
790
 
904
- it('MetricTable rows: "experiment" verdicts meta 复用同一份 eval 级统计,行为不变(回归)', async () => {
905
- const s = snap({
906
- experimentId: "exp/x",
907
- results: [res("A", "passed", { attempt: 0 }), res("A", "failed", { attempt: 1 }), res("B", "failed")],
908
- });
909
- const data = await MetricTable.data([s], { rows: "experiment", columns: [passRate] });
910
- // A 两轮折成 passed(任一轮过即过),B 失败:1 passed / 1 failed
911
- expect(data.rows[0].meta?.verdicts).toEqual({ passed: 1, failed: 1, errored: 0, skipped: 0 });
791
+ it("省略 sort 时按行 key 字典序(维度 domain 稳定序,不随文件扫描顺序)", async () => {
792
+ const b = snap({ experimentId: "o/bbb", results: [res("a", "passed")] });
793
+ const a = snap({ experimentId: "o/aaa", results: [res("a", "failed")] });
794
+ const table = await metricTableData([b, a], { rows: "experiment", columns: [endToEndPassRate] });
795
+ expect(table.rows.map((r) => r.key)).toEqual(["o/aaa", "o/bbb"]);
912
796
  });
913
797
  });
914
798
 
915
- // ───────────────────────── DeltaTable.data ─────────────────────────
799
+ // ───────────────────────── metricLineData ─────────────────────────
916
800
 
917
- describe("DeltaTable.data", () => {
918
- it("任一侧 null delta null 不硬算;双侧有值给带符号 display;Δ=0 ±0", async () => {
919
- const base = snap({
920
- experimentId: "exp/base",
921
- results: [res("A", "failed"), res("B", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
922
- });
923
- const plus = snap({
924
- experimentId: "exp/plus",
925
- results: [res("A", "passed"), res("B", "passed")], // 无任何成本数据
926
- });
927
- const data = await DeltaTable.data([base, plus], {
928
- pairs: [{ a: "exp/base", b: "exp/plus", label: "memory" }],
929
- metrics: [passRate, costUSD],
801
+ describe("metricLineData", () => {
802
+ const flaggedSnap = (experimentId: string, budget: number | undefined, verdicts: Verdict[]) =>
803
+ snap({
804
+ experimentId,
805
+ results: verdicts.map((v, i) => res(`q${i}`, v)),
806
+ experiment: {
807
+ runs: 1,
808
+ earlyExit: false,
809
+ selectedEvalIds: [],
810
+ ...(budget !== undefined ? { flags: { budget } } : {}),
811
+ },
930
812
  });
813
+
814
+ it("未声明数值 flag 的 experiment 不伪造 x 值(不落到 x=0)并可数", async () => {
815
+ const data = await metricLineData(
816
+ [flaggedSnap("l/100", 100, ["passed"]), flaggedSnap("l/none", undefined, ["passed"])],
817
+ { x: numericFlag("budget"), y: endToEndPassRate },
818
+ );
819
+ const missing = data.rows.filter((r) => r.x === null);
820
+ expect(missing).toHaveLength(1);
821
+ expect(data.rows.some((r) => r.x === 0)).toBe(false);
822
+ });
823
+
824
+ it("点身份 = (series, x):同桶多 experiment 合成一个点,y 按 (series, x, experiment, eval) 顺序聚合", async () => {
825
+ // 两个 experiment 同 x=100:各 1 题,一个 passed 一个 failed → 合成一点 y = (1+0)/2
826
+ const data = await metricLineData(
827
+ [flaggedSnap("m/one", 100, ["passed"]), flaggedSnap("m/two", 100, ["failed"])],
828
+ { x: numericFlag("budget"), y: endToEndPassRate },
829
+ );
931
830
  expect(data.rows).toHaveLength(1);
932
- const row = data.rows[0];
933
- expect(row.key).toBe("memory");
934
- expect(row.a).toEqual({ experimentId: "exp/base" });
935
- expect(row.b).toEqual({ experimentId: "exp/plus" });
936
-
937
- const pass = row.cells["pass-rate"];
938
- expect(pass.a.value).toBe(0.5);
939
- expect(pass.b.value).toBe(1);
940
- expect(pass.delta).toBeCloseTo(0.5);
941
- expect(pass.display).toBe("+50%");
942
-
943
- const cost = row.cells["cost"];
944
- expect(cost.a.value).toBeCloseTo(0.2);
945
- expect(cost.b.value).toBeNull();
946
- expect(cost.delta).toBeNull(); // 单侧缺数据:不硬算
947
- expect(cost.display).toBe("—");
948
-
949
- const flat = await DeltaTable.data([base, base], {
950
- pairs: [{ a: "exp/base", b: "exp/base", label: "same" }],
951
- metrics: [passRate],
952
- });
953
- expect(flat.rows[0].cells["pass-rate"].display).toBe("±0");
831
+ expect(data.rows[0]!.key).toBe("100"); // x 的稳定十进制字符串
832
+ expect(data.rows[0]!.x).toBe(100);
833
+ expect(data.rows[0]!.y.value).toBeCloseTo(0.5);
954
834
  });
955
835
 
956
- it("时间轴对比:pairs a/b 收快照键 <experimentId> @ <startedAt>", async () => {
957
- const older = snap({
958
- experimentId: "exp/x",
959
- runStartedAt: "2026-07-01T08:00:00Z",
960
- results: [res("A", "failed")],
961
- });
962
- const newer = snap({
963
- experimentId: "exp/x",
964
- runStartedAt: "2026-07-02T08:00:00Z",
965
- results: [res("A", "passed")],
966
- });
967
- const data = await DeltaTable.data([older, newer], {
968
- pairs: [
969
- {
970
- a: "exp/x @ 2026-07-01T08:00:00Z",
971
- b: "exp/x @ 2026-07-02T08:00:00Z",
972
- label: "this week vs last",
973
- },
974
- ],
975
- metrics: [passRate],
836
+ it("自定义 NumericAxis.of 在同一 experiment × eval 内不恒定时报完整用户反馈,不静默取首值", async () => {
837
+ const s = snap({
838
+ experimentId: "l/vary",
839
+ results: [res("q", "passed", { attempt: 0, durationMs: 10 }), res("q", "passed", { attempt: 1, durationMs: 20 })],
976
840
  });
977
- const cell = data.rows[0].cells["pass-rate"];
978
- expect(cell.a.value).toBe(0); // 旧快照那份
979
- expect(cell.b.value).toBe(1); // 新快照那份
980
- expect(cell.display).toBe("+100%");
841
+ const perAttempt = {
842
+ name: "per-attempt",
843
+ of: (attempt: AttemptHandle) => attempt.result.durationMs,
844
+ };
845
+ await expect(metricLineData([s], { x: perAttempt, y: endToEndPassRate })).rejects.toThrow(/not constant/);
981
846
  });
982
847
  });
983
848
 
984
- // ───────────────────────── 身份键去重 ─────────────────────────
849
+ // ───────────────────────── deltaTableData 与 pairsByFlag ─────────────────────────
985
850
 
986
- describe("身份键去重", () => {
987
- it(" (experimentId, evalId, attempt, startedAt) 两份 → 保留最新 run 的那份", async () => {
988
- const identity = {
989
- experimentId: "exp/x",
990
- attempt: 0,
991
- startedAt: "2026-07-01T08:00:00Z",
992
- };
993
- // --resume 场景:旧 run 里 failed,新 run 合入同身份键的 passed
994
- const older = snap({
995
- experimentId: "exp/x",
996
- runStartedAt: "2026-07-01T08:00:00Z",
997
- results: [res("A", "failed", identity)],
851
+ describe("deltaTableData", () => {
852
+ it("任一侧缺数据时 delta 保持缺失;方向按指标 better 判断改善/退化", async () => {
853
+ const a = snap({
854
+ experimentId: "d/base",
855
+ results: [res("q", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.4 } })],
998
856
  });
999
- const newer = snap({
1000
- experimentId: "exp/x",
1001
- runStartedAt: "2026-07-02T08:00:00Z",
1002
- results: [res("A", "passed", identity)],
857
+ const b = snap({
858
+ experimentId: "d/next",
859
+ results: [res("q", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
1003
860
  });
1004
-
1005
- for (const order of [
1006
- [older, newer],
1007
- [newer, older],
1008
- ]) {
1009
- const data = await MetricTable.data(order, { rows: "agent", columns: [passRate] });
1010
- const cell = data.rows[0].cells["pass-rate"];
1011
- expect(cell.total).toBe(1); // 两份只算一份
1012
- expect(cell.value).toBe(1); // 留的是最新 run 里的 passed,与快照传入顺序无关
1013
-
1014
- const ov = await RunOverview.data(order);
1015
- expect(ov.totals.attempts).toBe(1);
1016
- expect(ov.totals.passed).toBe(1);
1017
- expect(ov.totals.failed).toBe(0);
1018
- }
861
+ const data = await deltaTableData([a, b], {
862
+ by: "experiment",
863
+ pairs: [{ label: "next vs base", a: "d/base", b: "d/next" }],
864
+ metrics: [costUSD, assistantTurns],
865
+ });
866
+ expect(data.byDimension).toBe("experiment");
867
+ const row = data.rows[0]!;
868
+ expect(row.label).toBe("next vs base");
869
+ // costUSD 下降且 better: "lower" → improved
870
+ expect(row.cells[costUSD.name]).toMatchObject({ delta: expect.closeTo(-0.2, 5), outcome: "improved" });
871
+ // assistantTurns 两侧都缺 o11y → delta null → unavailable
872
+ expect(row.cells[assistantTurns.name]).toMatchObject({ delta: null, outcome: "unavailable" });
1019
873
  });
1020
874
 
1021
- it("startedAt 缺失:不去重、如实保留重复,不透出警告", async () => {
1022
- const identity = { experimentId: "exp/x", attempt: 0, startedAt: undefined };
1023
- const one = snap({ experimentId: "exp/x", results: [res("A", "passed", identity)] });
1024
- const two = snap({ experimentId: "exp/x", results: [res("A", "passed", identity)] });
1025
- const ov = await RunOverview.data([one, two]);
1026
- expect(ov.totals.attempts).toBe(2);
1027
- expect(ov.warnings).toEqual([]); // missing-startedAt 不透出到组件数据(裁决记录 7)
875
+ it("pairs 空数组在计算时按完整用户反馈报错;运行期构造的非空 pairs 直接可用", async () => {
876
+ const s = snap({ experimentId: "d/x", results: [res("q", "passed")] });
877
+ await expect(
878
+ deltaTableData([s], { by: "experiment", pairs: [] as { label: string; a: string; b: string }[], metrics: [costUSD] }),
879
+ ).rejects.toThrow(/empty/);
880
+ const dynamic = [{ label: "run", a: "d/x", b: "d/y" }].filter(() => true);
881
+ await expect(
882
+ deltaTableData([s], { by: "experiment", pairs: dynamic, metrics: [costUSD] }),
883
+ ).resolves.toMatchObject({ rows: [{ label: "run" }] });
1028
884
  });
1029
- });
1030
885
 
1031
- // ───────────────────────── 格式化 ─────────────────────────
1032
-
1033
- describe("unit 驱动格式化", () => {
1034
- it('"%" / "ms" / "$" / 其余缩写', () => {
1035
- expect(formatMetricValue(0.87, "%")).toBe("87%");
1036
- expect(formatMetricValue(0.875, "%")).toBe("87.5%");
1037
- expect(formatMetricValue(-0.008, "%")).toBe("-0.8%");
1038
- expect(formatMetricValue(850, "ms")).toBe("850ms");
1039
- expect(formatMetricValue(1234, "ms")).toBe("1.2s");
1040
- expect(formatMetricValue(125_000, "ms")).toBe("2m 5s");
1041
- expect(formatMetricValue(0.31, "$")).toBe("$0.31");
1042
- expect(formatMetricValue(0.0042, "$")).toBe("$0.0042");
1043
- expect(formatMetricValue(-0.8, "$")).toBe("-$0.80");
1044
- expect(formatMetricValue(1234, "lines")).toBe("1.2k lines");
1045
- expect(formatMetricValue(3_400_000, "tokens")).toBe("3.4M tokens");
1046
- expect(formatMetricValue(42)).toBe("42");
886
+ it("字面 pair 校验:label 空/重复、a === b 报错;a/b 精确匹配维度 key,未命中保留 pair、对应侧缺失", async () => {
887
+ const s = snap({ experimentId: "d/only", results: [res("q", "passed")] });
888
+ await expect(
889
+ deltaTableData([s], {
890
+ by: "experiment",
891
+ pairs: [
892
+ { label: "dup", a: "d/only", b: "d/gone" },
893
+ { label: "dup", a: "d/gone", b: "d/only" },
894
+ ],
895
+ metrics: [endToEndPassRate],
896
+ }),
897
+ ).rejects.toThrow(/twice/);
898
+ await expect(
899
+ deltaTableData([s], { by: "experiment", pairs: [{ label: "self", a: "d/only", b: "d/only" }], metrics: [endToEndPassRate] }),
900
+ ).rejects.toThrow(/itself/);
901
+ const data = await deltaTableData([s], {
902
+ by: "experiment",
903
+ pairs: [{ label: "half", a: "d/only", b: "d/gone" }],
904
+ metrics: [endToEndPassRate],
905
+ });
906
+ const cell = data.rows[0]!.cells[endToEndPassRate.name]!;
907
+ expect(cell.a.value).toBe(1);
908
+ expect(cell.b.value).toBeNull();
909
+ expect(cell.delta).toBeNull();
1047
910
  });
1048
911
 
1049
- it("metric.display 覆盖内置格式化", async () => {
1050
- const raw = defineMetric({
1051
- name: "raw",
1052
- unit: "%",
1053
- display: (v) => `${v} raw`,
1054
- value: () => 0.5,
912
+ describe("pairsByFlag", () => {
913
+ /** agent × baseline / agents-md / mempal 矩阵;bub 无 mempal,如实少一对。 */
914
+ const matrixSnaps = () => {
915
+ const mk = (agent: string, memory?: string) =>
916
+ snap({
917
+ experimentId: `mem/${agent}${memory ? `--${memory}` : ""}`,
918
+ agent,
919
+ results: [res("q", "passed")],
920
+ experiment: {
921
+ runs: 1,
922
+ earlyExit: false,
923
+ selectedEvalIds: [],
924
+ ...(memory !== undefined ? { flags: { memory } } : {}),
925
+ },
926
+ });
927
+ return [
928
+ mk("bub"),
929
+ mk("bub", "agents-md"),
930
+ mk("codex"),
931
+ mk("codex", "agents-md"),
932
+ mk("codex", "mempal"),
933
+ mk("gemini"),
934
+ mk("gemini", "agents-md"),
935
+ mk("gemini", "mempal"),
936
+ ];
937
+ };
938
+
939
+ it("同可比组 + 删除该 flag 后配置深相等才配对;a 取 baseline(缺省=未声明),label 自动生成,按 (a 末段, 显示键) 字典序", async () => {
940
+ const data = await deltaTableData(matrixSnaps(), {
941
+ by: "experiment",
942
+ pairs: pairsByFlag("memory"),
943
+ metrics: [endToEndPassRate],
944
+ });
945
+ expect(data.rows.map((r) => r.label)).toEqual([
946
+ "bub · memory=agents-md",
947
+ "codex · memory=agents-md",
948
+ "codex · memory=mempal",
949
+ "gemini · memory=agents-md",
950
+ "gemini · memory=mempal",
951
+ ]);
952
+ expect(data.experiments).toBe(8);
1055
953
  });
1056
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
1057
- const data = await MetricTable.data([s], { rows: "agent", columns: [raw] });
1058
- expect(data.rows[0].cells["raw"].display).toBe("0.5 raw");
1059
- });
1060
- });
1061
954
 
1062
- // ───────────────────────── MetricMatrix.data(= MetricBars.data)─────────────────────────
955
+ it("可比性配置不同(model 不同)的两实验不配对", async () => {
956
+ const base = snap({
957
+ experimentId: "mm/a",
958
+ model: "gpt-a",
959
+ results: [res("q", "passed")],
960
+ experiment: { runs: 1, earlyExit: false, selectedEvalIds: [] },
961
+ });
962
+ const other = snap({
963
+ experimentId: "mm/b",
964
+ model: "gpt-b",
965
+ results: [res("q", "passed")],
966
+ experiment: { runs: 1, earlyExit: false, selectedEvalIds: [], flags: { memory: "on" } },
967
+ });
968
+ const data = await deltaTableData([base, other], {
969
+ by: "experiment",
970
+ pairs: pairsByFlag("memory"),
971
+ metrics: [endToEndPassRate],
972
+ });
973
+ expect(data.rows).toHaveLength(0);
974
+ expect(data.experiments).toBe(2);
975
+ });
1063
976
 
1064
- describe("MetricMatrix.data", () => {
1065
- it("稀疏:没有 attempt (row, column) 组合不出格", async () => {
1066
- const a = snap({
1067
- experimentId: "exp/a",
1068
- agent: "a1",
1069
- results: [res("A", "passed", { agent: "a1" }), res("B", "failed", { agent: "a1" })],
977
+ it("收窄到单实验时 0 对不是错误:空 rows + 配对域实验数;by 非 experiment 报完整用户反馈", async () => {
978
+ const single = snap({ experimentId: "solo/x", results: [res("q", "passed")] });
979
+ const data = await deltaTableData([single], {
980
+ by: "experiment",
981
+ pairs: pairsByFlag("memory"),
982
+ metrics: [endToEndPassRate],
983
+ });
984
+ expect(data.rows).toHaveLength(0);
985
+ expect(data.experiments).toBe(1);
986
+
987
+ await expect(
988
+ deltaTableData([single], { by: "agent", pairs: pairsByFlag("memory"), metrics: [endToEndPassRate] }),
989
+ ).rejects.toThrow(/by: "experiment"/);
1070
990
  });
1071
- const b = snap({
1072
- experimentId: "exp/b",
1073
- agent: "b1",
1074
- results: [res("A", "failed", { agent: "b1" })], // b1 没跑 B
991
+
992
+ it("baseline 显式声明时 a 侧取该 flag 值", async () => {
993
+ const data = await deltaTableData(matrixSnaps(), {
994
+ by: "experiment",
995
+ pairs: pairsByFlag("memory", { baseline: "agents-md" }),
996
+ metrics: [endToEndPassRate],
997
+ });
998
+ // a = *--agents-md;b = 未声明(显示键 (missing))与 mempal
999
+ expect(data.rows.map((r) => r.label)).toEqual([
1000
+ "bub--agents-md · memory=(missing)",
1001
+ "codex--agents-md · memory=(missing)",
1002
+ "codex--agents-md · memory=mempal",
1003
+ "gemini--agents-md · memory=(missing)",
1004
+ "gemini--agents-md · memory=mempal",
1005
+ ]);
1075
1006
  });
1076
- const data = await MetricMatrix.data([a, b], { rows: "eval", columns: "agent", cell: passRate });
1077
- expect(data.rows).toBe("eval");
1078
- expect(data.columns).toBe("agent");
1079
- expect(data.metric.key).toBe("pass-rate");
1080
- expect(data.cells).toHaveLength(3); // A×a1、B×a1、A×b1;B×b1 不出现
1081
- const find = (row: string, column: string) => data.cells.find((c) => c.row === row && c.column === column);
1082
- expect(find("A", "a1")?.cell.value).toBe(1);
1083
- expect(find("B", "a1")?.cell.value).toBe(0);
1084
- expect(find("A", "b1")?.cell.value).toBe(0);
1085
- expect(find("B", "b1")).toBeUndefined();
1086
1007
  });
1008
+ });
1009
+
1010
+ // ───────────────────────── examScore ─────────────────────────
1087
1011
 
1088
- it("MetricBars.data 就是 MetricMatrix.data 的别名(同一个函数)", () => {
1089
- expect(MetricBars.data).toBe(MetricMatrix.data);
1012
+ describe("examScore", () => {
1013
+ it("gate 决定能否得分,soft 给质量分;errored 交白卷是 0 分不是缺数据", async () => {
1014
+ const s = snap({
1015
+ experimentId: "exam/x",
1016
+ results: [
1017
+ res("soft", "passed", { assertions: [softAssertion("a", 0.5), softAssertion("b", 1)] }),
1018
+ res("allgate", "passed"),
1019
+ res("crashed", "errored", { error: erroredWith("boom") }),
1020
+ ],
1021
+ });
1022
+ const table = await metricTableData([s], { rows: "eval", columns: [examScore] });
1023
+ const cellOf = (key: string) => table.rows.find((r) => r.key === key)!.cells[examScore.name]!;
1024
+ expect(cellOf("soft").value).toBeCloseTo(0.75);
1025
+ expect(cellOf("allgate").value).toBe(1);
1026
+ expect(cellOf("crashed").value).toBe(0);
1027
+ expect(cellOf("crashed").samples).toBe(1); // 0 分是测得的事实,不是缺数据
1090
1028
  });
1091
1029
  });