niceeval 0.5.3 → 0.6.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 (344) hide show
  1. package/INDEX.md +55 -0
  2. package/dist/agents/types.d.ts +317 -0
  3. package/dist/context/types.d.ts +335 -0
  4. package/dist/i18n/core.d.ts +11 -0
  5. package/dist/i18n/en.d.ts +181 -0
  6. package/dist/i18n/index.d.ts +5 -0
  7. package/dist/i18n/zh-CN.d.ts +183 -0
  8. package/dist/o11y/execution-tree.d.ts +103 -0
  9. package/dist/o11y/otlp/parse.d.ts +3 -0
  10. package/dist/o11y/otlp/receiver.d.ts +21 -0
  11. package/dist/o11y/otlp/select.d.ts +22 -0
  12. package/dist/o11y/otlp/turn-otel.d.ts +43 -0
  13. package/dist/o11y/types.d.ts +190 -0
  14. package/dist/report/aggregate.d.ts +57 -0
  15. package/dist/report/aggregate.js +212 -0
  16. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +1 -0
  17. package/dist/report/built-ins/cost-pass-rate-comparison.js +17 -0
  18. package/dist/report/built-ins/index.d.ts +1 -0
  19. package/dist/report/built-ins/index.js +2 -0
  20. package/dist/report/components.d.ts +178 -0
  21. package/dist/report/components.js +132 -0
  22. package/dist/report/compute.d.ts +106 -0
  23. package/dist/report/compute.js +618 -0
  24. package/dist/report/flag.d.ts +6 -0
  25. package/dist/report/flag.js +8 -0
  26. package/dist/report/format.d.ts +32 -0
  27. package/dist/report/format.js +126 -0
  28. package/dist/report/index.d.ts +19 -0
  29. package/dist/report/index.js +30 -0
  30. package/dist/report/load.d.ts +12 -0
  31. package/dist/report/load.js +56 -0
  32. package/dist/report/locale.d.ts +83 -0
  33. package/dist/report/locale.js +160 -0
  34. package/dist/report/metrics.d.ts +21 -0
  35. package/dist/report/metrics.js +115 -0
  36. package/dist/report/primitives.d.ts +64 -0
  37. package/dist/report/primitives.js +113 -0
  38. package/dist/report/react/AttemptList.d.ts +23 -0
  39. package/dist/report/react/AttemptList.js +20 -0
  40. package/dist/report/react/DeltaTable.d.ts +8 -0
  41. package/dist/report/react/DeltaTable.js +24 -0
  42. package/dist/report/react/EvalList.d.ts +10 -0
  43. package/dist/report/react/EvalList.js +0 -0
  44. package/dist/report/react/ExperimentList.d.ts +10 -0
  45. package/dist/report/react/ExperimentList.js +15 -0
  46. package/dist/report/react/GroupSummary.d.ts +8 -0
  47. package/dist/report/react/GroupSummary.js +8 -0
  48. package/dist/report/react/MetricBars.d.ts +10 -0
  49. package/dist/report/react/MetricBars.js +48 -0
  50. package/dist/report/react/MetricLine.d.ts +9 -0
  51. package/dist/report/react/MetricLine.js +68 -0
  52. package/dist/report/react/MetricMatrix.d.ts +10 -0
  53. package/dist/report/react/MetricMatrix.js +23 -0
  54. package/dist/report/react/MetricScatter.d.ts +9 -0
  55. package/dist/report/react/MetricScatter.js +100 -0
  56. package/dist/report/react/MetricTable.d.ts +11 -0
  57. package/dist/report/react/MetricTable.js +26 -0
  58. package/dist/report/react/RunOverview.d.ts +8 -0
  59. package/dist/report/react/RunOverview.js +12 -0
  60. package/dist/report/react/Scoreboard.d.ts +8 -0
  61. package/dist/report/react/Scoreboard.js +26 -0
  62. package/dist/report/react/cell.d.ts +9 -0
  63. package/dist/report/react/cell.js +9 -0
  64. package/dist/report/react/chart-math.d.ts +14 -0
  65. package/dist/report/react/chart-math.js +68 -0
  66. package/dist/report/react/colors.d.ts +14 -0
  67. package/dist/report/react/colors.js +45 -0
  68. package/dist/report/react/fixtures.d.ts +19 -0
  69. package/dist/report/react/fixtures.js +393 -0
  70. package/dist/report/react/format.d.ts +3 -0
  71. package/dist/report/react/format.js +7 -0
  72. package/dist/report/react/index.d.ts +6 -0
  73. package/dist/report/react/index.js +14 -0
  74. package/dist/report/report.d.ts +24 -0
  75. package/dist/report/report.js +49 -0
  76. package/dist/report/text/faces.d.ts +29 -0
  77. package/dist/report/text/faces.js +503 -0
  78. package/dist/report/text/layout.d.ts +75 -0
  79. package/dist/report/text/layout.js +173 -0
  80. package/dist/report/text/plot.d.ts +37 -0
  81. package/dist/report/text/plot.js +81 -0
  82. package/dist/report/text/table.d.ts +8 -0
  83. package/dist/report/text/table.js +86 -0
  84. package/dist/report/tree.d.ts +99 -0
  85. package/dist/report/tree.js +228 -0
  86. package/dist/report/types.d.ts +420 -0
  87. package/dist/report/types.js +4 -0
  88. package/dist/report/web.d.ts +15 -0
  89. package/dist/report/web.js +37 -0
  90. package/dist/results/annotated-source.d.ts +61 -0
  91. package/dist/results/attempt-evidence.d.ts +69 -0
  92. package/dist/results/attempt-source.d.ts +15 -0
  93. package/dist/results/locator.d.ts +75 -0
  94. package/dist/results/locator.js +0 -0
  95. package/dist/results/select.d.ts +45 -0
  96. package/dist/results/select.js +230 -0
  97. package/dist/results/source-hash.d.ts +9 -0
  98. package/dist/results/types.d.ts +204 -0
  99. package/dist/runner/eval-source.d.ts +22 -0
  100. package/dist/runner/fingerprint.d.ts +23 -0
  101. package/dist/runner/types.d.ts +332 -0
  102. package/dist/sandbox/docker-stream.d.ts +24 -0
  103. package/dist/sandbox/docker.d.ts +84 -0
  104. package/dist/sandbox/e2b.d.ts +31 -0
  105. package/dist/sandbox/errors.d.ts +4 -0
  106. package/dist/sandbox/local-files.d.ts +6 -0
  107. package/dist/sandbox/paths.d.ts +4 -0
  108. package/dist/sandbox/registry.d.ts +13 -0
  109. package/dist/sandbox/resolve.d.ts +40 -0
  110. package/dist/sandbox/retry.d.ts +10 -0
  111. package/dist/sandbox/shell.d.ts +11 -0
  112. package/dist/sandbox/source-files.d.ts +13 -0
  113. package/dist/sandbox/types.d.ts +203 -0
  114. package/dist/sandbox/vercel.d.ts +37 -0
  115. package/dist/scoring/types.d.ts +70 -0
  116. package/dist/shared/aggregate.d.ts +29 -0
  117. package/dist/shared/aggregate.js +55 -0
  118. package/dist/shared/types.d.ts +27 -0
  119. package/dist/shared/verdict.d.ts +27 -0
  120. package/dist/shared/verdict.js +34 -0
  121. package/dist/tty-line.d.ts +6 -0
  122. package/dist/types.d.ts +7 -0
  123. package/dist/util.d.ts +22 -0
  124. package/docs-site/zh/concepts/adapter.mdx +9 -4
  125. package/docs-site/zh/concepts/assert.mdx +8 -8
  126. package/docs-site/zh/concepts/evals.mdx +5 -5
  127. package/docs-site/zh/concepts/experiment.mdx +3 -1
  128. package/docs-site/zh/concepts/judge.mdx +9 -9
  129. package/docs-site/zh/concepts/overview.mdx +2 -2
  130. package/docs-site/zh/concepts/tier.mdx +6 -6
  131. package/docs-site/zh/example/claude-code-codex-plugin.mdx +9 -5
  132. package/docs-site/zh/example/claude-code-codex-skill.mdx +12 -7
  133. package/docs-site/zh/guides/agent-feedback-loop.mdx +235 -78
  134. package/docs-site/zh/guides/connect-otel.mdx +3 -3
  135. package/docs-site/zh/guides/connect-your-agent.mdx +2 -2
  136. package/docs-site/zh/guides/custom-reports.mdx +196 -36
  137. package/docs-site/zh/guides/experiments.mdx +7 -0
  138. package/docs-site/zh/guides/fixtures.mdx +1 -1
  139. package/docs-site/zh/guides/official-adapters.mdx +31 -9
  140. package/docs-site/zh/guides/publish-report.mdx +4 -4
  141. package/docs-site/zh/guides/report-components.mdx +194 -55
  142. package/docs-site/zh/guides/reporters.mdx +2 -2
  143. package/docs-site/zh/guides/results-data.mdx +35 -34
  144. package/docs-site/zh/guides/runner.mdx +2 -2
  145. package/docs-site/zh/guides/sandbox-agent.mdx +10 -5
  146. package/docs-site/zh/guides/sandbox-providers.mdx +102 -0
  147. package/docs-site/zh/guides/viewing-results.mdx +199 -79
  148. package/docs-site/zh/guides/write-experiment.mdx +23 -1
  149. package/docs-site/zh/guides/write-send.mdx +2 -2
  150. package/docs-site/zh/index.mdx +4 -3
  151. package/docs-site/zh/introduction.mdx +2 -1
  152. package/docs-site/zh/quickstart.mdx +5 -3
  153. package/docs-site/zh/reference/builtin-agents.mdx +38 -13
  154. package/docs-site/zh/reference/capabilities.mdx +1 -1
  155. package/docs-site/zh/reference/cli.mdx +36 -13
  156. package/docs-site/zh/reference/define-agent.mdx +24 -12
  157. package/docs-site/zh/reference/define-config.mdx +1 -1
  158. package/docs-site/zh/reference/define-eval.mdx +3 -3
  159. package/docs-site/zh/reference/events.mdx +7 -1
  160. package/docs-site/zh/reference/expect.mdx +3 -3
  161. package/package.json +10 -6
  162. package/src/agents/bub.ts +134 -43
  163. package/src/agents/claude-code.test.ts +152 -0
  164. package/src/agents/claude-code.ts +127 -13
  165. package/src/agents/codex.test.ts +192 -0
  166. package/src/agents/codex.ts +151 -12
  167. package/src/agents/index.ts +11 -3
  168. package/src/agents/manifest.ts +41 -0
  169. package/src/agents/plugin-config.test.ts +94 -0
  170. package/src/agents/shared.ts +33 -2
  171. package/src/agents/skills.test.ts +214 -0
  172. package/src/agents/skills.ts +304 -0
  173. package/src/agents/streaming.ts +5 -5
  174. package/src/agents/types.ts +62 -2
  175. package/src/cli.ts +74 -47
  176. package/src/context/context.test.ts +20 -2
  177. package/src/context/context.ts +3 -0
  178. package/src/context/control-flow.ts +1 -1
  179. package/src/context/session.test.ts +92 -1
  180. package/src/context/session.ts +26 -1
  181. package/src/context/types.ts +4 -4
  182. package/src/define.test.ts +82 -0
  183. package/src/define.ts +72 -11
  184. package/src/expect/index.ts +5 -5
  185. package/src/i18n/en.ts +36 -15
  186. package/src/i18n/zh-CN.ts +39 -21
  187. package/src/index.ts +1 -1
  188. package/src/o11y/execution-tree.test.ts +452 -0
  189. package/src/o11y/execution-tree.ts +367 -0
  190. package/src/o11y/otlp/receiver.ts +1 -1
  191. package/src/o11y/otlp/select.ts +7 -3
  192. package/src/o11y/parsers/claude-code.test.ts +175 -0
  193. package/src/o11y/parsers/claude-code.ts +32 -0
  194. package/src/o11y/types.ts +6 -1
  195. package/src/report/aggregate.ts +26 -20
  196. package/src/report/built-in-user-parity.test.tsx +643 -0
  197. package/src/report/built-ins/cost-pass-rate-comparison.tsx +23 -0
  198. package/src/report/built-ins/index.ts +2 -0
  199. package/src/report/components.tsx +223 -50
  200. package/src/report/compute.ts +358 -53
  201. package/src/report/dual-render.test.tsx +1125 -0
  202. package/src/report/flag.ts +1 -1
  203. package/src/report/format.ts +50 -2
  204. package/src/report/index.ts +48 -14
  205. package/src/report/load.ts +1 -1
  206. package/src/report/locale.ts +206 -0
  207. package/src/report/metrics.ts +39 -15
  208. package/src/report/primitives.tsx +100 -2
  209. package/src/report/react/AttemptList.tsx +115 -0
  210. package/src/report/react/DeltaTable.tsx +9 -6
  211. package/src/report/react/EvalList.tsx +0 -0
  212. package/src/report/react/ExperimentList.tsx +108 -0
  213. package/src/report/react/GroupSummary.tsx +66 -0
  214. package/src/report/react/MetricBars.tsx +24 -13
  215. package/src/report/react/MetricLine.tsx +21 -21
  216. package/src/report/react/MetricMatrix.tsx +8 -4
  217. package/src/report/react/MetricScatter.tsx +136 -96
  218. package/src/report/react/MetricTable.tsx +115 -15
  219. package/src/report/react/RunOverview.tsx +48 -26
  220. package/src/report/react/Scoreboard.tsx +15 -7
  221. package/src/report/react/cell.tsx +18 -9
  222. package/src/{view/app/lib/chart.ts → report/react/chart-math.ts} +2 -6
  223. package/src/report/react/colors.ts +24 -15
  224. package/src/report/react/enhance.js +129 -0
  225. package/src/report/react/fixtures.ts +163 -34
  226. package/src/report/react/format.ts +1 -1
  227. package/src/report/react/index.tsx +22 -6
  228. package/src/report/react/render.test.tsx +164 -31
  229. package/src/report/react/styles.css +405 -213
  230. package/src/report/report.test.ts +403 -94
  231. package/src/report/report.ts +29 -11
  232. package/src/report/text/faces.ts +303 -95
  233. package/src/report/text/layout.ts +76 -11
  234. package/src/report/text/table.ts +98 -0
  235. package/src/report/tree.ts +103 -20
  236. package/src/report/types.ts +196 -25
  237. package/src/report/web.ts +46 -19
  238. package/src/results/annotated-source.test.ts +146 -0
  239. package/src/results/annotated-source.ts +128 -0
  240. package/src/results/attempt-evidence.test.ts +158 -0
  241. package/src/results/attempt-evidence.ts +158 -0
  242. package/src/results/attempt-source.ts +48 -0
  243. package/src/results/copy.ts +154 -139
  244. package/src/results/format.ts +54 -47
  245. package/src/results/host-equivalence.test.ts +527 -0
  246. package/src/results/index.ts +56 -14
  247. package/src/results/locator.test.ts +198 -0
  248. package/src/results/locator.ts +0 -0
  249. package/src/results/open.ts +392 -175
  250. package/src/results/results.test.ts +943 -494
  251. package/src/results/select.ts +129 -23
  252. package/src/results/skipped-notice.ts +0 -0
  253. package/src/results/source-hash.ts +28 -0
  254. package/src/results/types.ts +98 -62
  255. package/src/results/writer.ts +304 -176
  256. package/src/runner/attempt.test.ts +170 -0
  257. package/src/runner/attempt.ts +97 -15
  258. package/src/runner/discover.test.ts +65 -0
  259. package/src/runner/discover.ts +6 -2
  260. package/src/runner/eval-source.test.ts +121 -0
  261. package/src/runner/eval-source.ts +45 -0
  262. package/src/runner/fingerprint.ts +54 -1
  263. package/src/runner/remote-sandbox.ts +1 -1
  264. package/src/runner/report.test.ts +6 -8
  265. package/src/runner/report.ts +5 -4
  266. package/src/runner/reporters/artifacts.ts +27 -41
  267. package/src/runner/reporters/braintrust.test.ts +3 -3
  268. package/src/runner/reporters/braintrust.ts +1 -1
  269. package/src/runner/reporters/console.ts +6 -6
  270. package/src/runner/reporters/json.ts +4 -4
  271. package/src/runner/reporters/live.test.ts +56 -0
  272. package/src/runner/reporters/live.ts +36 -10
  273. package/src/runner/reporters/quiet.test.ts +66 -0
  274. package/src/runner/reporters/quiet.ts +49 -0
  275. package/src/runner/reporters/shared.ts +5 -5
  276. package/src/runner/reporters/table.ts +17 -17
  277. package/src/runner/run.ts +61 -102
  278. package/src/runner/types.ts +43 -32
  279. package/src/sandbox/checkpoint.ts +1 -1
  280. package/src/sandbox/docker.ts +15 -2
  281. package/src/sandbox/e2b.ts +11 -5
  282. package/src/sandbox/errors.ts +13 -0
  283. package/src/sandbox/index.ts +3 -3
  284. package/src/sandbox/local-files.ts +1 -1
  285. package/src/sandbox/registry.ts +6 -5
  286. package/src/sandbox/resolve.ts +38 -23
  287. package/src/sandbox/retry.test.ts +68 -0
  288. package/src/sandbox/retry.ts +44 -0
  289. package/src/sandbox/types.ts +69 -28
  290. package/src/sandbox/vercel.ts +17 -3
  291. package/src/scoring/collector.ts +3 -3
  292. package/src/scoring/judge.ts +2 -2
  293. package/src/scoring/scoped.ts +13 -1
  294. package/src/scoring/types.ts +7 -7
  295. package/src/scoring/verdict.ts +4 -4
  296. package/src/shared/aggregate.ts +23 -3
  297. package/src/shared/types.ts +2 -2
  298. package/src/shared/{outcome.ts → verdict.ts} +15 -15
  299. package/src/show/compose.ts +12 -132
  300. package/src/show/index.ts +125 -94
  301. package/src/show/render.ts +660 -160
  302. package/src/show/show.test.ts +472 -253
  303. package/src/source-loc.ts +1 -1
  304. package/src/tty-line.ts +32 -0
  305. package/src/types.ts +2 -2
  306. package/src/util.test.ts +21 -1
  307. package/src/util.ts +5 -1
  308. package/src/view/app/App.tsx +33 -198
  309. package/src/view/app/components/AttemptModal.tsx +6 -6
  310. package/src/view/app/components/CodeView.tsx +2 -2
  311. package/src/view/app/components/CopyControls.tsx +32 -36
  312. package/src/view/app/components/SkippedRunsBanner.tsx +140 -0
  313. package/src/view/app/components/ui/badge.tsx +1 -1
  314. package/src/view/app/components/ui/dialog.tsx +2 -2
  315. package/src/view/app/i18n.ts +40 -31
  316. package/src/view/app/lib/artifact-url.ts +1 -1
  317. package/src/view/app/lib/attempt-route.test.ts +37 -47
  318. package/src/view/app/lib/attempt-route.ts +26 -26
  319. package/src/view/app/lib/rows.ts +9 -112
  320. package/src/view/app/lib/verdict.ts +25 -0
  321. package/src/view/app/main.tsx +9 -12
  322. package/src/view/app/pages/RunsPage.tsx +4 -4
  323. package/src/view/app/pages/TracesPage.tsx +3 -3
  324. package/src/view/app/shared.ts +0 -1
  325. package/src/view/app/types.ts +6 -51
  326. package/src/view/artifact-serving.test.ts +136 -0
  327. package/src/view/client-dist/app.css +1 -1
  328. package/src/view/client-dist/app.js +21 -21
  329. package/src/view/data.test.ts +214 -143
  330. package/src/view/data.ts +166 -120
  331. package/src/view/index.ts +23 -11
  332. package/src/view/server.ts +52 -17
  333. package/src/view/shared/types.ts +22 -31
  334. package/src/view/styles.css +117 -327
  335. package/src/view/view-report.test.ts +146 -98
  336. package/docs-site/zh/guides/sandbox-backends.mdx +0 -75
  337. package/src/report/default-report.tsx +0 -222
  338. package/src/report/dual-face.test.tsx +0 -527
  339. package/src/report/react/CaseList.tsx +0 -70
  340. package/src/view/app/components/CostScoreChart.tsx +0 -147
  341. package/src/view/app/components/ExperimentTable.tsx +0 -284
  342. package/src/view/app/components/GroupSelector.tsx +0 -61
  343. package/src/view/app/components/primitives.tsx +0 -43
  344. package/src/view/app/lib/outcome.ts +0 -82
@@ -1,20 +1,23 @@
1
1
  // niceeval/report 计算层的单元测试:全部用内存 fake(Snapshot / AttemptHandle 按
2
- // niceeval/results 的读取契约手工构造),专门覆盖 docs/reports.md 点名的坑 ——
2
+ // niceeval/results 的读取契约手工构造),专门覆盖 docs/feature/reports/architecture.md 点名的坑 ——
3
3
  // 两级聚合 vs 平铺、pass@k、examScore 空真、skipped 稀释、scoreboard 固定分母与
4
4
  // 最长前缀、scatter/delta 的 null 语义、快照键对比、flag 维度与轴、
5
5
  // cases 的 redact/truncated、身份键去重、Selection warnings 随行。
6
6
 
7
7
  import { describe, expect, it } from "vitest";
8
8
 
9
- import type { AssertionResult, EvalResult, ResultOutcome, RunSummary } from "../types.ts";
10
- import type { AttemptHandle, RunDir, Selection, SelectionWarning, Snapshot } from "../results/index.ts";
9
+ import type { AssertionResult, EvalResult, O11ySummary, Verdict } from "../types.ts";
10
+ import type { AttemptHandle, Selection, SelectionWarning, Snapshot } from "../results/index.ts";
11
11
  import type { Dimension, MetricCell } from "./types.ts";
12
- import { costUSD, defineMetric, durationMs, examScore, passRate, tokens } from "./metrics.ts";
12
+ import { costUSD, defineMetric, durationMs, examScore, passRate, tokens, turns } from "./metrics.ts";
13
13
  import { flag } from "./flag.ts";
14
14
  import { formatMetricValue } from "./format.ts";
15
15
  import {
16
- CaseList,
16
+ AttemptList,
17
17
  DeltaTable,
18
+ EvalList,
19
+ ExperimentList,
20
+ GroupSummary,
18
21
  MetricBars,
19
22
  MetricLine,
20
23
  MetricMatrix,
@@ -23,18 +26,19 @@ import {
23
26
  RunOverview,
24
27
  Scoreboard,
25
28
  } from "./components.tsx";
29
+ import { reasonFor } from "./compute.ts";
26
30
 
27
31
  // ───────────────────────── fake 数据(按 results 读取契约造)─────────────────────────
28
32
 
29
33
  let seq = 0;
30
34
 
31
35
  /** 造一条结果;默认给每条唯一 startedAt —— 身份键含 startedAt,免得普通样本被去重误伤。 */
32
- function res(id: string, outcome: ResultOutcome, extra: Partial<EvalResult> = {}): EvalResult {
36
+ function res(id: string, verdict: Verdict, extra: Partial<EvalResult> = {}): EvalResult {
33
37
  seq += 1;
34
38
  return {
35
39
  id,
36
40
  agent: "agent-x",
37
- outcome,
41
+ verdict,
38
42
  attempt: 0,
39
43
  startedAt: `2026-07-01T00:00:00.${String(seq).padStart(6, "0")}Z`,
40
44
  durationMs: 1000,
@@ -47,6 +51,24 @@ function softAssertion(name: string, score: number, extra: Partial<AssertionResu
47
51
  return { name, severity: "soft", score, passed: true, ...extra };
48
52
  }
49
53
 
54
+ /** 最小合规 O11ySummary,只有 totalTurns 会变;供 turns 指标测试内联挂到 EvalResult.o11y。 */
55
+ function o11ySummary(totalTurns: number): O11ySummary {
56
+ return {
57
+ totalTurns,
58
+ toolCalls: {},
59
+ totalToolCalls: 0,
60
+ filesRead: [],
61
+ filesModified: [],
62
+ shellCommands: [],
63
+ webFetches: [],
64
+ errors: [],
65
+ thinkingBlocks: 0,
66
+ compactions: 0,
67
+ durationMs: 0,
68
+ usage: { inputTokens: 0, outputTokens: 0 },
69
+ };
70
+ }
71
+
50
72
  interface SnapSpec {
51
73
  experimentId: string;
52
74
  results: EvalResult[];
@@ -58,55 +80,46 @@ interface SnapSpec {
58
80
 
59
81
  let runSeq = 0;
60
82
 
61
- /** 最小构造:一个 run 装一个快照。runStartedAt 决定去重时谁是「最新 run」。 */
83
+ /** 最小构造:一个快照目录装一个快照。runStartedAt 决定去重时谁是「最新快照」。 */
62
84
  function snap(spec: SnapSpec): Snapshot {
63
85
  runSeq += 1;
64
86
  const startedAt = spec.runStartedAt ?? `2026-06-01T00:00:00.${String(runSeq).padStart(3, "0")}Z`;
65
- const summary: RunSummary = {
66
- agent: spec.agent ?? "agent-x",
87
+ const dir = `/results/exp/snap-${runSeq}`;
88
+ const snapshot = {
89
+ experimentId: spec.experimentId,
67
90
  startedAt,
68
91
  completedAt: startedAt,
69
- passed: 0,
70
- failed: 0,
71
- skipped: 0,
72
- errored: 0,
73
- durationMs: 0,
74
- results: spec.results,
75
- };
76
- const runDir: RunDir = { dir: `/results/run-${runSeq}`, summary, attempts: [] };
77
- const attempts: AttemptHandle[] = spec.results.map((r, i) => ({
92
+ agent: spec.agent ?? "agent-x",
93
+ model: spec.model,
94
+ schemaVersion: 1,
95
+ dir,
96
+ knownEvalIds: spec.knownEvalIds,
97
+ } as Snapshot;
98
+ const attempts: AttemptHandle[] = spec.results.map((r) => ({
78
99
  evalId: r.id,
79
100
  experimentId: r.experimentId ?? spec.experimentId,
80
101
  result: r,
81
- ref: { run: `run-${runSeq}`, result: i },
82
- runDir,
102
+ ref: { snapshot: `exp/snap-${runSeq}`, attempt: `${r.id}/a${r.attempt}` },
103
+ snapshot,
83
104
  events: async () => null,
84
105
  trace: async () => null,
85
- o11y: async () => null,
106
+ o11y: async () => r.o11y ?? null,
107
+ agentSetup: async () => null,
86
108
  diff: async () => null,
87
109
  sources: async () => null,
88
110
  }));
89
- runDir.attempts = attempts;
90
111
  const evals = new Map<string, AttemptHandle[]>();
91
112
  for (const attempt of attempts) {
92
113
  const list = evals.get(attempt.evalId);
93
114
  if (list) list.push(attempt);
94
115
  else evals.set(attempt.evalId, [attempt]);
95
116
  }
96
- return {
97
- experimentId: spec.experimentId,
98
- startedAt,
99
- agent: spec.agent ?? "agent-x",
100
- model: spec.model,
101
- schemaVersion: 1,
102
- evals: [...evals.entries()].map(([id, list]) => ({ id, attempts: list })),
103
- attempts,
104
- runDir,
105
- knownEvalIds: spec.knownEvalIds,
106
- };
117
+ snapshot.evals = [...evals.entries()].map(([id, list]) => ({ id, attempts: list }));
118
+ snapshot.attempts = attempts;
119
+ return snapshot;
107
120
  }
108
121
 
109
- /** 手工造一个选集(warnings 随行的形状,filter 语义与 results 一致)。 */
122
+ /** 手工造一个 Selection(warnings 随行的形状,filter 语义与 results 一致)。 */
110
123
  function selection(snapshots: Snapshot[], warnings: SelectionWarning[]): Selection {
111
124
  return {
112
125
  snapshots,
@@ -122,6 +135,187 @@ function selection(snapshots: Snapshot[], warnings: SelectionWarning[]): Selecti
122
135
  };
123
136
  }
124
137
 
138
+ describe("ExperimentList.data", () => {
139
+ it("身份、flags、Eval 判定构成、官方两级聚合汇总指标,展开到 Eval 携带 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
+ expect(evalA.attempts[0]!.capabilities.execution).toBe(true); // attempt0 的 hasEvents:true
185
+ expect(evalA.attempts[1]!.capabilities.execution).toBe(false);
186
+ // 每次 attempt 独立编码,即使同一道题也不撞车
187
+ expect(evalA.attempts[0]!.locator).not.toBe(evalA.attempts[1]!.locator);
188
+
189
+ const evalB = item.evalRows.find((e) => e.evalId === "memory/b")!;
190
+ expect(evalB.verdict).toBe("errored");
191
+ expect(evalB.reason).toBe("timeout");
192
+ });
193
+
194
+ it("展开数组按 evalId 升序,顶层数组按 experimentId 升序", async () => {
195
+ const s1 = snap({ experimentId: "exp/b", results: [res("z", "passed"), res("a", "passed")] });
196
+ const s2 = snap({ experimentId: "exp/a", results: [res("x", "passed")] });
197
+ const items = await ExperimentList.data([s1, s2]);
198
+ expect(items.map((i) => i.experimentId)).toEqual(["exp/a", "exp/b"]);
199
+ expect(items.find((i) => i.experimentId === "exp/b")!.evalRows.map((e) => e.evalId)).toEqual(["a", "z"]);
200
+ });
201
+ });
202
+
203
+ describe("EvalList.data", () => {
204
+ it("每项一个 experimentId + evalId,判定/分数/成本耗时均值/失败原因与展开到 Attempt 都在", async () => {
205
+ const s = snap({
206
+ experimentId: "compare/codex",
207
+ results: [
208
+ res("weather/brooklyn", "failed", {
209
+ attempt: 0,
210
+ durationMs: 40_000,
211
+ usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.02 },
212
+ assertions: [{ name: 'calledTool("get_weather")', severity: "gate", passed: false, score: 0, detail: "tool was never called" }],
213
+ }),
214
+ res("weather/brooklyn", "failed", {
215
+ attempt: 1,
216
+ durationMs: 42_000,
217
+ usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.06 },
218
+ assertions: [{ name: 'calledTool("get_weather")', severity: "gate", passed: false, score: 0, detail: "tool was never called" }],
219
+ }),
220
+ ],
221
+ });
222
+ const items = await EvalList.data([s]);
223
+ expect(items).toHaveLength(1);
224
+ const item = items[0]!;
225
+ expect(item.evalId).toBe("weather/brooklyn");
226
+ expect(item.experimentId).toBe("compare/codex");
227
+ expect(item.verdict).toBe("failed");
228
+ expect(item.reason).toBe('calledTool("get_weather"): tool was never called');
229
+ expect(item.score.value).toBe(0); // examScore: failed → 0
230
+ expect(item.duration.value).toBeCloseTo(41_000); // 平均耗时
231
+ expect(item.cost.value).toBeCloseTo(0.04); // 平均成本
232
+ expect(item.attempts.map((a) => a.attempt)).toEqual([0, 1]);
233
+ expect(item.attempts[0]!.locator).not.toBe(item.attempts[1]!.locator);
234
+ });
235
+
236
+ it("同一个 eval id 出现在两个 experiment 上是两条不同结果,不合并", async () => {
237
+ const s1 = snap({ experimentId: "exp/a", results: [res("shared", "passed")] });
238
+ const s2 = snap({ experimentId: "exp/b", results: [res("shared", "failed")] });
239
+ const items = await EvalList.data([s1, s2]);
240
+ expect(items).toHaveLength(2);
241
+ expect(items.map((i) => `${i.experimentId}:${i.verdict}`).sort()).toEqual(["exp/a:passed", "exp/b:failed"]);
242
+ });
243
+ });
244
+
245
+ describe("AttemptList.data", () => {
246
+ it("每项一个 Attempt:身份、判定、assertions、耗时、成本、locator 与证据能力标记", async () => {
247
+ const s = snap({
248
+ experimentId: "exp/x",
249
+ results: [
250
+ res("A", "failed", {
251
+ durationMs: 5_000,
252
+ usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.05 },
253
+ assertions: [{ name: "gate", severity: "gate", passed: false, score: 0, detail: "nope" }],
254
+ hasEvents: true,
255
+ hasSources: true,
256
+ }),
257
+ ],
258
+ });
259
+ const items = await AttemptList.data([s]);
260
+ expect(items).toHaveLength(1);
261
+ const item = items[0]!;
262
+ expect(item).toMatchObject({
263
+ evalId: "A",
264
+ experimentId: "exp/x",
265
+ attempt: 0,
266
+ agent: "agent-x",
267
+ verdict: "failed",
268
+ durationMs: 5_000,
269
+ costUSD: 0.05,
270
+ });
271
+ expect(item.assertions).toHaveLength(1);
272
+ expect(item.locator).toMatch(/^@1[0-9a-z]{7}$/);
273
+ // 证据能力:cheap 计算,不调用完整 loadAttemptEvidence——execution 来自 hasEvents,
274
+ // eval 来自 hasSources,diff 懒加载(fixture 的 diff() 恒返回 null → false)。
275
+ expect(item.capabilities).toEqual({ eval: true, execution: true, timing: false, diff: false });
276
+ });
277
+
278
+ it("redact 作用于 error 与断言 detail/evidence,不作用于身份字段", async () => {
279
+ const s = snap({
280
+ experimentId: "exp/x",
281
+ results: [
282
+ res("A", "errored", { error: "ENOENT /Users/me/repo/tool" }),
283
+ res("B", "failed", {
284
+ assertions: [
285
+ {
286
+ name: "includes",
287
+ severity: "gate",
288
+ score: 0,
289
+ passed: false,
290
+ detail: "missing text under /Users/me/repo/src",
291
+ evidence: "checked /Users/me/repo/src/app.ts",
292
+ },
293
+ ],
294
+ }),
295
+ ],
296
+ });
297
+ const items = await AttemptList.data([s], { redact: (text) => text.replaceAll("/Users/me/repo", "<repo>") });
298
+ const a = items.find((i) => i.evalId === "A")!;
299
+ expect(a.error).toBe("ENOENT <repo>/tool");
300
+ const b = items.find((i) => i.evalId === "B")!;
301
+ expect(b.assertions[0]!.detail).toBe("missing text under <repo>/src");
302
+ expect(b.assertions[0]!.evidence).toBe("checked <repo>/src/app.ts");
303
+ // 身份字段不经 redact
304
+ expect(items.every((i) => i.experimentId === "exp/x")).toBe(true);
305
+ });
306
+
307
+ it("不预设只看失败:passed 的 attempt 同样列出,原样携带 assertions", async () => {
308
+ const s = snap({
309
+ experimentId: "exp/x",
310
+ results: [res("A", "passed", { assertions: [{ name: "ok", severity: "gate", passed: true, score: 1 }] })],
311
+ });
312
+ const items = await AttemptList.data([s]);
313
+ expect(items).toHaveLength(1);
314
+ expect(items[0]).toMatchObject({ verdict: "passed" });
315
+ expect(items[0]!.assertions).toHaveLength(1);
316
+ });
317
+ });
318
+
125
319
  // ───────────────────────── 两级聚合 ─────────────────────────
126
320
 
127
321
  describe("两级聚合引擎", () => {
@@ -146,7 +340,7 @@ describe("两级聚合引擎", () => {
146
340
  name: "pass@k",
147
341
  better: "higher",
148
342
  unit: "%",
149
- value: (a) => (a.result.outcome === "skipped" ? null : a.result.outcome === "passed" ? 1 : 0),
343
+ value: (a) => (a.result.verdict === "skipped" ? null : a.result.verdict === "passed" ? 1 : 0),
150
344
  aggregate: { perEval: "max", across: "mean" },
151
345
  });
152
346
  const s = snap({
@@ -184,7 +378,7 @@ describe("两级聚合引擎", () => {
184
378
  it("where 不满足 → null,不进聚合", async () => {
185
379
  const onlyPassed = defineMetric({
186
380
  name: "only-passed",
187
- where: (a) => a.result.outcome === "passed",
381
+ where: (a) => a.result.verdict === "passed",
188
382
  value: () => 5,
189
383
  });
190
384
  const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
@@ -339,6 +533,22 @@ describe("内置指标", () => {
339
533
  const dur = await MetricTable.data([s], { rows: "agent", columns: [durationMs] });
340
534
  expect(dur.rows[0].cells["duration"].value).toBe(2000); // (1000 + 3000)/2,errored 实测照算
341
535
  });
536
+
537
+ it("turns:读 o11y.totalTurns;o11y 缺失(未随发布带上)→ null,不是 0;skipped 不进聚合", async () => {
538
+ const s = snap({
539
+ experimentId: "exp/x",
540
+ results: [
541
+ res("A", "passed", { o11y: o11ySummary(12) }),
542
+ res("B", "failed"), // 没带 o11y(如 copySnapshots 漏选 artifact)→ null,不稀释成 0
543
+ res("C", "skipped", { o11y: o11ySummary(3) }), // skipped 恒 null,哪怕 o11y 在场
544
+ ],
545
+ });
546
+ const data = await MetricTable.data([s], { rows: "agent", columns: [turns] });
547
+ const cell = data.rows[0].cells["turns"];
548
+ expect(cell.value).toBe(12);
549
+ expect(cell.samples).toBe(1); // 只有 A 测得了(B 缺 o11y、C 是 skipped)
550
+ expect(cell.total).toBe(3); // total 是组内全部 attempt 数,不是「本该测得」的分母
551
+ });
342
552
  });
343
553
 
344
554
  // ───────────────────────── Scoreboard.data ─────────────────────────
@@ -468,10 +678,10 @@ describe("MetricScatter.data", () => {
468
678
  // ───────────────────────── flag():维度与轴 ─────────────────────────
469
679
 
470
680
  describe("flag()", () => {
471
- const withFlags = (id: string, flags: Record<string, unknown> | undefined, outcome: ResultOutcome) =>
681
+ const withFlags = (id: string, flags: Record<string, unknown> | undefined, verdict: Verdict) =>
472
682
  snap({
473
683
  experimentId: id,
474
- results: [res("A", outcome, { experimentId: id, experiment: { id, flags } })],
684
+ results: [res("A", verdict, { experimentId: id, experiment: { id, flags } })],
475
685
  });
476
686
 
477
687
  it("MetricLine.data:x 收 flag、按 experiment 聚合;未声明的作轴 x=null 报数", async () => {
@@ -512,6 +722,63 @@ describe("flag()", () => {
512
722
  });
513
723
  });
514
724
 
725
+ // ───────────────────────── MetricTable.data:没有实体下钻 ─────────────────────────
726
+
727
+ describe("MetricTable.data", () => {
728
+ it("rows: \"experiment\" 的 meta 不带 subRows —— MetricTable 只表达维度 × 指标,没有 expand", async () => {
729
+ const s = snap({
730
+ experimentId: "exp/x",
731
+ results: [res("A", "passed"), res("B", "failed")],
732
+ });
733
+ const data = await MetricTable.data([s], { rows: "experiment", columns: [passRate] });
734
+ expect(data.rows).toHaveLength(1);
735
+ expect(data.rows[0]!.meta).not.toHaveProperty("subRows");
736
+ // 展开到 Eval 是 ExperimentList 的职责,不在这里验证 —— 见 ExperimentList.data 的测试组。
737
+ });
738
+ });
739
+
740
+ // ───────────────────────── reasonFor(原因优先级)─────────────────────────
741
+ // docs/feature/reports/show.md 裸 `show` 的 RESULT 列口径:error → skipReason → 未通过的 gate 断言。
742
+ // ExperimentList / EvalList 的 evalRows.reason、AttemptList 的 attemptItemReason 都port这同一份材料,
743
+ // 这里直接测 reasonFor 本身,不必再经 MetricTable 之类的中间组件绕一圈。
744
+
745
+ describe("reasonFor", () => {
746
+ it("同一 result 同时含 error、失败 gate、失败 soft → 只显示 error", () => {
747
+ const result = res("A", "errored", {
748
+ error: "adapter crashed",
749
+ assertions: [
750
+ { name: "includes", severity: "gate", score: 0, passed: false, detail: "missing text" },
751
+ softAssertion("judge", 0.2, { passed: false }),
752
+ ],
753
+ });
754
+ expect(reasonFor(result)).toBe("adapter crashed");
755
+ });
756
+
757
+ it("skipReason 优先于 gate 断言,但让位给 error", () => {
758
+ const result = res("A", "skipped", {
759
+ skipReason: "missing fixture",
760
+ assertions: [{ name: "includes", severity: "gate", score: 0, passed: false }],
761
+ });
762
+ expect(reasonFor(result)).toBe("missing fixture");
763
+ });
764
+
765
+ it("多个失败 gate 按原始声明顺序全部保留,拼接成一行;失败 soft 不出现在 reason 里", () => {
766
+ const result = res("A", "failed", {
767
+ assertions: [
768
+ softAssertion("judge-first", 0.1, { passed: false }), // 声明顺序第一,但 soft 不进 reason
769
+ { name: "includes", severity: "gate", score: 0, passed: false, detail: "missing text" },
770
+ { name: "matches", severity: "gate", score: 0, passed: false }, // 无 detail,只有 name
771
+ softAssertion("judge-last", 0.3, { passed: false }), // soft 依旧不进 reason
772
+ ],
773
+ });
774
+ expect(reasonFor(result)).toBe("includes: missing text, matches");
775
+ });
776
+
777
+ it("都缺席 → 无原因", () => {
778
+ expect(reasonFor(res("A", "passed"))).toBeUndefined();
779
+ });
780
+ });
781
+
515
782
  // ───────────────────────── RunOverview.data ─────────────────────────
516
783
 
517
784
  describe("RunOverview.data", () => {
@@ -553,6 +820,103 @@ describe("RunOverview.data", () => {
553
820
  });
554
821
  });
555
822
 
823
+ // ───────────────────────── GroupSummary.data ─────────────────────────
824
+
825
+ describe("GroupSummary.data", () => {
826
+ it("多 experiment、多 attempt、部分成本缺失、不同 startedAt:eval 级折叠计票(完整身份键)、总成本 null-safe 求和、lastRunAt 取组内最大值", async () => {
827
+ const expA = snap({
828
+ experimentId: "exp/a",
829
+ runStartedAt: "2026-07-01T08:00:00Z",
830
+ results: [
831
+ // "shared" 两轮:一败一过 → 折叠成 passed(与 exp/b 同名的 "shared" 各算各的,见下方独立测试)
832
+ res("shared", "failed", { attempt: 0 }),
833
+ res("shared", "passed", { attempt: 1, usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.5 } }),
834
+ // "only-a" 只有 exp/a 跑过,失败、没报成本
835
+ res("only-a", "failed"),
836
+ ],
837
+ });
838
+ const expB = snap({
839
+ experimentId: "exp/b",
840
+ runStartedAt: "2026-07-03T08:00:00Z", // 比 exp/a 晚 —— lastRunAt 应取这个
841
+ results: [
842
+ // 与 exp/a 同名的 "shared":不同 experiment,必须独立计一票,不能被误合并
843
+ res("shared", "errored", { estimatedCostUSD: 0.2 }),
844
+ ],
845
+ });
846
+
847
+ const data = await GroupSummary.data([expA, expB]);
848
+
849
+ expect(data.experiments).toBe(2);
850
+ // 3 道题:exp/a 的 shared、exp/a 的 only-a、exp/b 的 shared —— 完整身份键各算各的
851
+ expect(data.evals).toBe(3);
852
+ expect(data.attempts).toBe(4); // 原始 attempt 计数:exp/a 2(shared)+1(only-a)+ exp/b 1(shared)
853
+ expect(data.verdicts).toEqual({ passed: 1, failed: 1, errored: 1, skipped: 0 });
854
+ // eval 级折叠通过率:1 / (1 + 1 + 1) = 1/3,不是 attempt 原始占比(1 passed / 4 attempts = 25%)
855
+ expect(data.passRate.value).toBeCloseTo(1 / 3, 10);
856
+ expect(data.passRate.display).toBe("33.3%");
857
+ expect(data.passRate.samples).toBe(3);
858
+ expect(data.passRate.total).toBe(3);
859
+ // 总成本 null-safe 求和:0.5(实测)+ 0.2(估算)= 0.7;only-a 的失败 attempt 没报成本,不计 0
860
+ expect(data.totalCostUSD).toBeCloseTo(0.7, 10);
861
+ // 最后运行时间:组内两个快照的 startedAt 取最大值(exp/b 更晚)
862
+ expect(data.lastRunAt).toBe(expB.startedAt);
863
+ });
864
+
865
+ it('跨 experiment 同名 eval 不合并:两个 experiment 各自的 "x" 独立计一票,不是被折成一票', async () => {
866
+ const a = snap({ experimentId: "exp/a", results: [res("x", "passed")] });
867
+ const b = snap({ experimentId: "exp/b", results: [res("x", "failed")] });
868
+ const data = await GroupSummary.data([a, b]);
869
+ expect(data.experiments).toBe(2);
870
+ expect(data.evals).toBe(2); // 只按 eval id 折叠(误把两个 experiment 的 "x" 当一道题)会变成 1
871
+ expect(data.verdicts).toEqual({ passed: 1, failed: 1, errored: 0, skipped: 0 });
872
+ expect(data.passRate.value).toBeCloseTo(0.5, 10); // 误合并会因为「任一轮过即过」变成 100%
873
+ });
874
+
875
+ it("全组没有任何 attempt 报成本 → totalCostUSD null,不编 0", async () => {
876
+ const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
877
+ const data = await GroupSummary.data([s]);
878
+ expect(data.totalCostUSD).toBeNull();
879
+ });
880
+
881
+ it("分母为 0(组内 eval 全部 skipped)→ passRate.value 为 null,不编 0%", async () => {
882
+ const s = snap({ experimentId: "exp/x", results: [res("A", "skipped"), res("B", "skipped")] });
883
+ const data = await GroupSummary.data([s]);
884
+ expect(data.verdicts).toEqual({ passed: 0, failed: 0, errored: 0, skipped: 2 });
885
+ expect(data.passRate.value).toBeNull();
886
+ expect(data.passRate.display).toBe("—");
887
+ expect(data.passRate.samples).toBe(0);
888
+ expect(data.passRate.total).toBe(2);
889
+ });
890
+
891
+ it("lastRunAt 只从传入的组 Selection 取值,不读全局最新 —— 组外快照更晚也不影响", async () => {
892
+ const inGroup = snap({
893
+ experimentId: "exp/a",
894
+ runStartedAt: "2026-07-01T08:00:00Z",
895
+ results: [res("A", "passed")],
896
+ });
897
+ const outOfGroup = snap({
898
+ experimentId: "exp/b",
899
+ runStartedAt: "2026-07-09T08:00:00Z",
900
+ results: [res("B", "passed")],
901
+ });
902
+ const sel = selection([inGroup, outOfGroup], []);
903
+ const scoped = sel.filter((s) => s.experimentId === "exp/a"); // 只留组内快照,模拟按实验组收窄的 Selection
904
+ const data = await GroupSummary.data(scoped);
905
+ expect(data.lastRunAt).toBe(inGroup.startedAt);
906
+ expect(data.lastRunAt).not.toBe(outOfGroup.startedAt);
907
+ });
908
+
909
+ it('MetricTable rows: "experiment" 的 verdicts meta 复用同一份 eval 级统计,行为不变(回归)', async () => {
910
+ const s = snap({
911
+ experimentId: "exp/x",
912
+ results: [res("A", "passed", { attempt: 0 }), res("A", "failed", { attempt: 1 }), res("B", "failed")],
913
+ });
914
+ const data = await MetricTable.data([s], { rows: "experiment", columns: [passRate] });
915
+ // A 两轮折成 passed(任一轮过即过),B 失败:1 passed / 1 failed
916
+ expect(data.rows[0].meta?.verdicts).toEqual({ passed: 1, failed: 1, errored: 0, skipped: 0 });
917
+ });
918
+ });
919
+
556
920
  // ───────────────────────── DeltaTable.data ─────────────────────────
557
921
 
558
922
  describe("DeltaTable.data", () => {
@@ -622,61 +986,6 @@ describe("DeltaTable.data", () => {
622
986
  });
623
987
  });
624
988
 
625
- // ───────────────────────── CaseList.data ─────────────────────────
626
-
627
- describe("CaseList.data", () => {
628
- it("默认只列 failed+errored;redact 作用于 error/detail/evidence;truncated 如实", async () => {
629
- const s = snap({
630
- experimentId: "exp/x",
631
- results: [
632
- res("A", "failed", {
633
- assertions: [
634
- {
635
- name: "includes",
636
- severity: "gate",
637
- score: 0,
638
- passed: false,
639
- detail: "missing text under /Users/me/repo/src",
640
- evidence: "checked /Users/me/repo/src/app.ts",
641
- },
642
- { name: "ok", severity: "gate", score: 1, passed: true }, // 通过的断言不列
643
- ],
644
- }),
645
- res("B", "errored", { error: "ENOENT /Users/me/repo/tool" }),
646
- res("C", "failed"),
647
- res("D", "passed"),
648
- res("E", "skipped"),
649
- ],
650
- });
651
- const data = await CaseList.data([s], {
652
- limit: 2,
653
- redact: (text) => text.replaceAll("/Users/me/repo", "<repo>"),
654
- });
655
- expect(data.rows).toHaveLength(2);
656
- expect(data.truncated).toBe(1); // C 被截;D/E 本就不在默认 outcomes 里
657
-
658
- const [first, second] = data.rows;
659
- expect(first.eval).toBe("A");
660
- expect(first.outcome).toBe("failed");
661
- expect(first.failedAssertions).toHaveLength(1);
662
- expect(first.failedAssertions[0].detail).toBe("missing text under <repo>/src");
663
- expect(first.failedAssertions[0].evidence).toBe("checked <repo>/src/app.ts");
664
- expect(first.ref.result).toBe(0);
665
-
666
- expect(second.eval).toBe("B");
667
- expect(second.outcome).toBe("errored");
668
- expect(second.error).toBe("ENOENT <repo>/tool");
669
- expect(second.ref.result).toBe(1);
670
- });
671
-
672
- it("outcomes 可收窄;不传 limit 不截断", async () => {
673
- const s = snap({ experimentId: "exp/x", results: [res("A", "failed"), res("B", "errored")] });
674
- const onlyErrored = await CaseList.data([s], { outcomes: ["errored"] });
675
- expect(onlyErrored.rows.map((r) => r.eval)).toEqual(["B"]);
676
- expect(onlyErrored.truncated).toBe(0);
677
- });
678
- });
679
-
680
989
  // ───────────────────────── 身份键去重 ─────────────────────────
681
990
 
682
991
  describe("身份键去重", () => {