niceeval 0.6.2 → 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 (216) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +2 -2
  4. package/dist/i18n/zh-CN.d.ts +3 -3
  5. package/dist/report/aggregate.d.ts +32 -26
  6. package/dist/report/aggregate.js +157 -76
  7. package/dist/report/built-in/index.d.ts +2 -0
  8. package/dist/report/built-in/index.js +8 -0
  9. package/dist/report/components.d.ts +91 -164
  10. package/dist/report/components.js +377 -114
  11. package/dist/report/compute.d.ts +86 -73
  12. package/dist/report/compute.js +592 -432
  13. package/dist/report/flag.d.ts +28 -17
  14. package/dist/report/flag.js +86 -16
  15. package/dist/report/format.d.ts +11 -11
  16. package/dist/report/format.js +17 -15
  17. package/dist/report/index.d.ts +16 -17
  18. package/dist/report/index.js +20 -22
  19. package/dist/report/load.js +3 -2
  20. package/dist/report/locale.d.ts +49 -34
  21. package/dist/report/locale.js +106 -58
  22. package/dist/report/metrics.d.ts +10 -3
  23. package/dist/report/metrics.js +46 -12
  24. package/dist/report/primitives.d.ts +42 -15
  25. package/dist/report/primitives.js +135 -26
  26. package/dist/report/react/AttemptList.d.ts +10 -8
  27. package/dist/report/react/AttemptList.js +18 -10
  28. package/dist/report/react/DeltaTable.js +19 -18
  29. package/dist/report/react/EvalList.d.ts +3 -3
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +4 -2
  32. package/dist/report/react/ExperimentComparison.js +5 -4
  33. package/dist/report/react/ExperimentList.d.ts +3 -3
  34. package/dist/report/react/ExperimentList.js +16 -15
  35. package/dist/report/react/MetricBars.js +5 -4
  36. package/dist/report/react/MetricLine.js +12 -5
  37. package/dist/report/react/MetricMatrix.js +1 -1
  38. package/dist/report/react/MetricScatter.js +54 -17
  39. package/dist/report/react/MetricTable.js +2 -12
  40. package/dist/report/react/ScopeSummary.d.ts +10 -0
  41. package/dist/report/react/ScopeSummary.js +17 -0
  42. package/dist/report/react/Scoreboard.js +6 -6
  43. package/dist/report/react/cell.js +2 -2
  44. package/dist/report/react/fixtures.d.ts +5 -9
  45. package/dist/report/react/fixtures.js +105 -149
  46. package/dist/report/react/index.d.ts +15 -5
  47. package/dist/report/react/index.js +18 -7
  48. package/dist/report/report.d.ts +137 -20
  49. package/dist/report/report.js +261 -34
  50. package/dist/report/text/faces.d.ts +17 -19
  51. package/dist/report/text/faces.js +225 -157
  52. package/dist/report/text/plot.js +1 -1
  53. package/dist/report/text/table.js +2 -2
  54. package/dist/report/tree.d.ts +90 -40
  55. package/dist/report/tree.js +252 -94
  56. package/dist/report/types.d.ts +245 -300
  57. package/dist/report/types.js +4 -3
  58. package/dist/report/web.d.ts +21 -5
  59. package/dist/report/web.js +42 -16
  60. package/dist/results/select.d.ts +38 -16
  61. package/dist/results/select.js +73 -25
  62. package/dist/results/types.d.ts +38 -14
  63. package/dist/shared/aggregate.d.ts +3 -2
  64. package/dist/shared/aggregate.js +5 -4
  65. package/docs-site/zh/README.md +44 -0
  66. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  67. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  68. package/docs-site/zh/examples/index.mdx +50 -0
  69. package/docs-site/zh/{concepts → explanation}/adapter.mdx +11 -11
  70. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  71. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  72. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  73. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  74. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  75. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  76. package/docs-site/zh/{concepts → explanation}/overview.mdx +5 -5
  77. package/docs-site/zh/{guides → explanation}/runner.mdx +1 -1
  78. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  79. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +7 -7
  80. package/docs-site/zh/{guides → how-to}/authoring.mdx +2 -2
  81. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  82. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +18 -18
  83. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +6 -6
  84. package/docs-site/zh/{guides → how-to}/experiments.mdx +3 -3
  85. package/docs-site/zh/{guides → how-to}/publish-report.mdx +2 -2
  86. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +2 -2
  87. package/docs-site/zh/{guides → how-to}/sandbox-providers.mdx +1 -1
  88. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +6 -6
  89. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +3 -3
  90. package/docs-site/zh/{guides → how-to}/write-send.mdx +13 -13
  91. package/docs-site/zh/index.mdx +23 -25
  92. package/docs-site/zh/introduction.mdx +8 -8
  93. package/docs-site/zh/reference/builtin-agents.mdx +5 -5
  94. package/docs-site/zh/reference/capabilities.mdx +6 -6
  95. package/docs-site/zh/reference/cli.mdx +9 -7
  96. package/docs-site/zh/reference/define-agent.mdx +1 -1
  97. package/docs-site/zh/reference/events.mdx +3 -3
  98. package/docs-site/zh/{guides → reference}/official-adapters.mdx +7 -7
  99. package/docs-site/zh/{guides → reference}/report-components.mdx +5 -5
  100. package/docs-site/zh/{guides → reference}/results-data.mdx +5 -5
  101. package/docs-site/zh/{guides → troubleshooting}/debug-sandbox.mdx +2 -2
  102. package/docs-site/zh/{guides → troubleshooting}/debugging.mdx +4 -2
  103. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  104. package/package.json +6 -2
  105. package/src/agents/index.ts +2 -2
  106. package/src/agents/openai-compat.ts +1 -1
  107. package/src/agents/streaming.ts +2 -2
  108. package/src/agents/types.ts +3 -3
  109. package/src/cli.ts +42 -23
  110. package/src/context/context.ts +1 -1
  111. package/src/context/session.test.ts +1 -1
  112. package/src/context/session.ts +1 -1
  113. package/src/i18n/en.ts +18 -16
  114. package/src/i18n/zh-CN.ts +16 -15
  115. package/src/report/aggregate.ts +175 -87
  116. package/src/report/built-in/index.tsx +9 -0
  117. package/src/report/components.tsx +625 -285
  118. package/src/report/compute.ts +717 -515
  119. package/src/report/dual-render.test.tsx +738 -1148
  120. package/src/report/flag.ts +97 -33
  121. package/src/report/format.ts +18 -22
  122. package/src/report/index.ts +113 -58
  123. package/src/report/load.ts +3 -2
  124. package/src/report/locale.ts +120 -69
  125. package/src/report/metrics.ts +42 -12
  126. package/src/report/primitives.tsx +190 -45
  127. package/src/report/react/AttemptList.tsx +32 -20
  128. package/src/report/react/DeltaTable.tsx +63 -45
  129. package/src/report/react/EvalList.tsx +0 -0
  130. package/src/report/react/ExperimentComparison.tsx +12 -7
  131. package/src/report/react/ExperimentList.tsx +38 -26
  132. package/src/report/react/MetricBars.tsx +5 -4
  133. package/src/report/react/MetricLine.tsx +13 -8
  134. package/src/report/react/MetricMatrix.tsx +2 -2
  135. package/src/report/react/MetricScatter.tsx +74 -20
  136. package/src/report/react/MetricTable.tsx +4 -76
  137. package/src/report/react/ScopeSummary.tsx +86 -0
  138. package/src/report/react/Scoreboard.tsx +28 -10
  139. package/src/report/react/cell.tsx +2 -2
  140. package/src/report/react/enhance.js +57 -5
  141. package/src/report/react/fixtures.ts +109 -156
  142. package/src/report/react/index.tsx +24 -39
  143. package/src/report/react/render.test.tsx +139 -104
  144. package/src/report/react/styles.css +181 -91
  145. package/src/report/report.test.ts +761 -1031
  146. package/src/report/report.ts +425 -47
  147. package/src/report/text/faces.ts +257 -164
  148. package/src/report/text/plot.ts +1 -1
  149. package/src/report/text/table.ts +2 -2
  150. package/src/report/tree.ts +362 -104
  151. package/src/report/types.ts +257 -287
  152. package/src/report/web.ts +63 -20
  153. package/src/results/attempt-evidence.test.ts +4 -4
  154. package/src/results/attempt-evidence.ts +5 -5
  155. package/src/results/copy.ts +6 -6
  156. package/src/results/host-equivalence.test.ts +26 -14
  157. package/src/results/index.ts +10 -4
  158. package/src/results/open.ts +8 -4
  159. package/src/results/results.test.ts +4 -3
  160. package/src/results/select.ts +104 -34
  161. package/src/results/types.ts +36 -14
  162. package/src/runner/feedback/human.test.ts +1 -1
  163. package/src/runner/run.ts +1 -1
  164. package/src/sandbox/cli-commands.ts +2 -2
  165. package/src/scoring/judge.test.ts +1 -1
  166. package/src/shared/aggregate.ts +5 -4
  167. package/src/show/compose.ts +50 -67
  168. package/src/show/index.ts +107 -56
  169. package/src/show/render.ts +43 -27
  170. package/src/show/report-host.test.ts +188 -0
  171. package/src/show/report-host.ts +375 -0
  172. package/src/show/show.test.ts +86 -36
  173. package/src/view/app/App.test.tsx +69 -0
  174. package/src/view/app/App.tsx +144 -48
  175. package/src/view/app/components/AttemptModal.tsx +324 -63
  176. package/src/view/app/components/CodeView.tsx +10 -4
  177. package/src/view/app/i18n.ts +31 -17
  178. package/src/view/app/main.tsx +13 -8
  179. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  180. package/src/view/app/types.ts +4 -1
  181. package/src/view/client-dist/app.css +1 -1
  182. package/src/view/client-dist/app.js +14 -14
  183. package/src/view/data.test.ts +9 -3
  184. package/src/view/data.ts +145 -49
  185. package/src/view/index.ts +48 -44
  186. package/src/view/server.ts +35 -15
  187. package/src/view/shared/types.ts +34 -5
  188. package/src/view/styles.css +224 -0
  189. package/src/view/view-report.test.ts +161 -57
  190. package/dist/report/built-ins/experiment-comparison.d.ts +0 -39
  191. package/dist/report/built-ins/experiment-comparison.js +0 -119
  192. package/dist/report/built-ins/index.d.ts +0 -2
  193. package/dist/report/built-ins/index.js +0 -2
  194. package/dist/report/react/GroupSummary.d.ts +0 -8
  195. package/dist/report/react/GroupSummary.js +0 -8
  196. package/dist/report/react/RunOverview.d.ts +0 -8
  197. package/dist/report/react/RunOverview.js +0 -12
  198. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  199. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  200. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  201. package/docs-site/zh/example/showcase.mdx +0 -39
  202. package/src/report/built-in-user-parity.test.tsx +0 -597
  203. package/src/report/built-ins/experiment-comparison.tsx +0 -179
  204. package/src/report/built-ins/index.ts +0 -7
  205. package/src/report/react/GroupSummary.tsx +0 -66
  206. package/src/report/react/RunOverview.tsx +0 -109
  207. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  208. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  209. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  210. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  211. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  212. /package/docs-site/zh/{guides → how-to}/ci-integration.mdx +0 -0
  213. /package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +0 -0
  214. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
  215. /package/docs-site/zh/{guides → how-to}/reporters.mdx +0 -0
  216. /package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +0 -0
@@ -1,51 +1,53 @@
1
1
  // cases: docs/engineering/unit-tests/reports/cases.md
2
2
  // niceeval/report 计算层的单元测试:全部用内存 fake(Snapshot / AttemptHandle 按
3
- // niceeval/results 的读取契约手工构造),专门覆盖 docs/feature/reports/architecture.md 点名的坑 ——
4
- // 两级聚合 vs 平铺、pass@kexamScore 空真、skipped 稀释、scoreboard 固定分母与
5
- // 最长前缀、scatter/delta null 语义、快照键对比、flag 维度与轴、
6
- // 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。
7
9
 
8
10
  import { describe, expect, it } from "vitest";
9
11
 
10
12
  import type { AssertionResult, AttemptError, EvalResult, O11ySummary, Verdict } from "../types.ts";
11
-
12
- /** 结构化 `AttemptError` 的最小构造(测试用):只关心一层 message,operation/code 取中性默认值。 */
13
- function erroredWith(message: string): AttemptError {
14
- return { code: "unexpected-error", message, phase: "eval.run" };
15
- }
16
- import type { AttemptHandle, Selection, SelectionWarning, Snapshot } from "../results/index.ts";
17
- import type { Dimension, MetricCell } 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";
18
16
  import {
17
+ assistantTurns,
19
18
  costUSD,
20
19
  defineMetric,
21
20
  durationMs,
22
21
  endToEndPassRate,
23
22
  examScore,
24
23
  executionReliability,
24
+ repeatedFailedCommands,
25
25
  taskPassRate,
26
- tokens,
27
- turns,
28
26
  } from "./metrics.ts";
29
- import { config, flag } from "./flag.ts";
30
- import { formatMetricValue } from "./format.ts";
27
+ import { flag, numericFlag } from "./flag.ts";
31
28
  import {
32
- AttemptList,
33
- DeltaTable,
34
- EvalList,
35
- ExperimentList,
36
- GroupSummary,
37
- MetricBars,
38
- MetricLine,
39
- MetricMatrix,
40
- MetricScatter,
41
- MetricTable,
42
- RunOverview,
43
- Scoreboard,
44
- } from "./components.tsx";
45
- import { reasonFor } from "./compute.ts";
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";
46
43
 
47
44
  // ───────────────────────── fake 数据(按 results 读取契约造)─────────────────────────
48
45
 
46
+ /** 结构化 `AttemptError` 的最小构造(测试用)。 */
47
+ function erroredWith(message: string): AttemptError {
48
+ return { code: "unexpected-error", message, phase: "eval.run" };
49
+ }
50
+
49
51
  let seq = 0;
50
52
 
51
53
  /** 造一条结果;默认给每条唯一 startedAt —— 身份键含 startedAt,免得普通样本被去重误伤。 */
@@ -67,10 +69,10 @@ function softAssertion(name: string, score: number, extra: Partial<AssertionResu
67
69
  return { name, severity: "soft", score, outcome: "passed" as const, ...extra } as AssertionResult;
68
70
  }
69
71
 
70
- /** 最小合规 O11ySummary,只有 totalTurns 会变;供 turns 指标测试内联挂到 EvalResult.o11y。 */
71
- function o11ySummary(totalTurns: number): O11ySummary {
72
+ /** 最小合规 O11ySummary;shellCommands / totalTurns 按需变。 */
73
+ function o11ySummary(partial: Partial<O11ySummary> = {}): O11ySummary {
72
74
  return {
73
- totalTurns,
75
+ totalTurns: 0,
74
76
  toolCalls: {},
75
77
  totalToolCalls: 0,
76
78
  filesRead: [],
@@ -82,7 +84,8 @@ function o11ySummary(totalTurns: number): O11ySummary {
82
84
  compactions: 0,
83
85
  durationMs: 0,
84
86
  usage: { inputTokens: 0, outputTokens: 0 },
85
- };
87
+ ...partial,
88
+ } as O11ySummary;
86
89
  }
87
90
 
88
91
  interface SnapSpec {
@@ -92,6 +95,7 @@ interface SnapSpec {
92
95
  model?: string;
93
96
  runStartedAt?: string;
94
97
  knownEvalIds?: string[];
98
+ experiment?: Snapshot["experiment"];
95
99
  }
96
100
 
97
101
  let runSeq = 0;
@@ -107,6 +111,7 @@ function snap(spec: SnapSpec): Snapshot {
107
111
  completedAt: startedAt,
108
112
  agent: spec.agent ?? "agent-x",
109
113
  model: spec.model,
114
+ experiment: spec.experiment,
110
115
  schemaVersion: 1,
111
116
  dir,
112
117
  knownEvalIds: spec.knownEvalIds,
@@ -135,1165 +140,890 @@ function snap(spec: SnapSpec): Snapshot {
135
140
  return snapshot;
136
141
  }
137
142
 
138
- /** 手工造一个 Selection(warnings 随行的形状,filter 语义与 results 一致) */
139
- function selection(snapshots: Snapshot[], warnings: SelectionWarning[]): Selection {
140
- return {
141
- snapshots,
142
- warnings,
143
- filter(predicate) {
144
- const kept = snapshots.filter(predicate);
145
- const survivors = new Set(kept.map((s) => s.experimentId));
146
- return selection(
147
- kept,
148
- warnings.filter((w) => typeof w.experimentId !== "string" || survivors.has(w.experimentId)),
149
- );
150
- },
151
- };
143
+ function scopeOf(snapshots: Snapshot[], warnings: ScopeWarning[] = []): Scope {
144
+ return makeScope("current-evals", snapshots, warnings);
152
145
  }
153
146
 
154
- describe("ExperimentList.data", () => {
155
- it("身份、flags、Eval 判定构成、官方两级聚合汇总指标,展开到 Eval 携带全部 Attempt locator", async () => {
156
- const s = snap({
157
- experimentId: "group/codex-e2b--mempal",
158
- agent: "codex",
159
- model: "gpt-5.4-mini",
160
- runStartedAt: "2026-07-10T22:44:00.000Z",
161
- results: [
162
- res("memory/a", "failed", {
163
- attempt: 0,
164
- durationMs: 1_000,
165
- assertions: [{ name: "gate", severity: "gate", outcome: "failed" as const, score: 0, detail: "wrong" }],
166
- usage: { inputTokens: 10, outputTokens: 5, costUSD: 0.1 },
167
- hasEvents: true,
168
- }),
169
- res("memory/a", "passed", {
170
- attempt: 1,
171
- durationMs: 2_000,
172
- usage: { inputTokens: 20, outputTokens: 10, costUSD: 0.2 },
173
- }),
174
- res("memory/b", "errored", { error: erroredWith("timeout"), durationMs: 3_000 }),
175
- ],
176
- });
177
- s.experiment = {
178
- runs: 2,
179
- earlyExit: false,
180
- sandbox: { provider: "e2b", params: { template: "fast" } },
181
- budget: 2,
182
- flags: { cache: true },
183
- selectedEvalIds: [],
184
- };
185
-
186
- const items = await ExperimentList.data([s]);
187
- expect(items).toHaveLength(1);
188
- const item = items[0]!;
189
- expect(item).toMatchObject({
190
- experimentId: "group/codex-e2b--mempal",
191
- agent: "codex",
192
- model: "gpt-5.4-mini",
193
- flags: { cache: true },
194
- verdicts: { passed: 1, failed: 0, errored: 1, skipped: 0 },
195
- evals: 2,
196
- attempts: 3,
197
- lastRunAt: "2026-07-10T22:44:00.000Z",
198
- });
199
- // 官方端到端两级聚合:memory/a 题内均值 (0+1)/2=0.5;
200
- // memory/b errored → 0 → 跨题均值 (0.5+0)/2=0.25
201
- expect(item.passRate.value).toBeCloseTo(0.25);
202
- expect(item.cost.samples).toBe(2); // 只有 memory/a 的两次 attempt 报了成本
203
-
204
- const evalA = item.evalRows.find((e) => e.evalId === "memory/a")!;
205
- expect(evalA.verdict).toBe("passed"); // 任一轮通过 → 该题通过
206
- expect(evalA.attempts.map((a) => a.attempt)).toEqual([0, 1]);
207
- expect(evalA.attempts[0]!.locator).toMatch(/^@1[0-9a-z]{7}$/);
208
- // 每次 attempt 独立编码,即使同一道题也不撞车
209
- expect(evalA.attempts[0]!.locator).not.toBe(evalA.attempts[1]!.locator);
210
-
211
- const evalB = item.evalRows.find((e) => e.evalId === "memory/b")!;
212
- expect(evalB.verdict).toBe("errored");
213
- expect(evalB).not.toHaveProperty("reason");
214
- expect(evalB.attempts[0]!.error?.message).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
+ ],
215
161
  });
216
162
 
217
- it("展开数组按 evalId 升序,顶层数组按 experimentId 升序", async () => {
218
- const s1 = snap({ experimentId: "exp/b", results: [res("z", "passed"), res("a", "passed")] });
219
- const s2 = snap({ experimentId: "exp/a", results: [res("x", "passed")] });
220
- const items = await ExperimentList.data([s1, s2]);
221
- expect(items.map((i) => i.experimentId)).toEqual(["exp/a", "exp/b"]);
222
- 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);
223
172
  });
224
173
 
225
- it("默认成功率包含 errored 并据此排序:2 passed / 5 errored = 2/7,不能以条件 100% 排第一", async () => {
226
- const unstable = snap({
227
- experimentId: "exp/unstable",
228
- results: [
229
- res("pass/a", "passed"),
230
- res("pass/b", "passed"),
231
- ...Array.from({ length: 5 }, (_, i) => res(`error/${i}`, "errored", { error: erroredWith("boom") })),
232
- ],
233
- });
234
- const stable = snap({
235
- experimentId: "exp/stable",
236
- results: [res("pass", "passed"), res("fail", "failed")],
174
+ it("taskPassRate 排除 errored,只能作为带限定名称的诊断指标:同 fixture 5/6", async () => {
175
+ const table = await metricTableData([discriminating], {
176
+ rows: "agent",
177
+ columns: [endToEndPassRate, taskPassRate, executionReliability],
237
178
  });
238
-
239
- const items = await ExperimentList.data([unstable, stable]);
240
- expect(items.find((item) => item.experimentId === "exp/unstable")!.passRate.value).toBeCloseTo(2 / 7, 10);
241
- expect(items.map((item) => item.experimentId)).toEqual(["exp/stable", "exp/unstable"]);
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);
242
184
  });
243
- });
244
185
 
245
- describe("EvalList.data", () => {
246
- it("每项一个 experimentId + evalId,父项保留判定与题级聚合并展开到含失败事实的 Attempt", async () => {
186
+ it("2 passed + 5 errored 的默认成功率是 2/7,不是 100%", async () => {
247
187
  const s = snap({
248
- experimentId: "compare/codex",
188
+ experimentId: "exp/err",
249
189
  results: [
250
- res("weather/brooklyn", "failed", {
251
- attempt: 0,
252
- durationMs: 40_000,
253
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.02 },
254
- assertions: [{ name: 'calledTool("get_weather")', severity: "gate", outcome: "failed" as const, score: 0, expected: "at least one matching call", received: "no tool calls" }],
255
- }),
256
- res("weather/brooklyn", "failed", {
257
- attempt: 1,
258
- durationMs: 42_000,
259
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.06 },
260
- assertions: [{ name: 'calledTool("get_weather")', severity: "gate", outcome: "failed" as const, score: 0, expected: "at least one matching call", received: "no tool calls" }],
261
- }),
190
+ res("q1", "passed"),
191
+ res("q2", "passed"),
192
+ ...[3, 4, 5, 6, 7].map((n) => res(`q${n}`, "errored", { error: erroredWith("x") })),
262
193
  ],
263
194
  });
264
- const items = await EvalList.data([s]);
265
- expect(items).toHaveLength(1);
266
- const item = items[0]!;
267
- expect(item.evalId).toBe("weather/brooklyn");
268
- expect(item.experimentId).toBe("compare/codex");
269
- expect(item.verdict).toBe("failed");
270
- expect(item).not.toHaveProperty("reason");
271
- expect(item.score.value).toBe(0); // examScore: failed → 0
272
- expect(item.duration.value).toBeCloseTo(41_000); // 平均耗时
273
- expect(item.cost.value).toBeCloseTo(0.04); // 平均成本
274
- expect(item.attempts.map((a) => a.attempt)).toEqual([0, 1]);
275
- const assertion = item.attempts[0]!.assertions[0]!;
276
- expect(assertion.outcome).toBe("failed");
277
- if (assertion.outcome !== "failed") throw new Error("expected failed assertion fixture");
278
- expect(assertion.received).toBe("no tool calls");
279
- expect(item.attempts[0]!.locator).not.toBe(item.attempts[1]!.locator);
195
+ const data = await scopeSummaryData([s]);
196
+ expect(data.endToEndPassRate.value).toBeCloseTo(2 / 7);
280
197
  });
281
198
 
282
- it("同一个 eval id 出现在两个 experiment 上是两条不同结果,不合并", async () => {
283
- const s1 = snap({ experimentId: "exp/a", results: [res("shared", "passed")] });
284
- const s2 = snap({ experimentId: "exp/b", results: [res("shared", "failed")] });
285
- const items = await EvalList.data([s1, s2]);
286
- expect(items).toHaveLength(2);
287
- expect(items.map((i) => `${i.experimentId}:${i.verdict}`).sort()).toEqual(["exp/a:passed", "exp/b:failed"]);
288
- });
289
- });
290
-
291
- describe("AttemptList.data", () => {
292
- it("每项一个 Attempt:身份、判定、assertions、耗时、成本与 locator", async () => {
199
+ it("skipped 对内置指标返回 null:不进有效样本但保留在 total,value 不受影响", async () => {
293
200
  const s = snap({
294
- experimentId: "exp/x",
295
- results: [
296
- res("A", "failed", {
297
- durationMs: 5_000,
298
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.05 },
299
- assertions: [{ name: "gate", severity: "gate", outcome: "failed" as const, score: 0, detail: "nope" }],
300
- hasEvents: true,
301
- hasSources: true,
302
- }),
303
- ],
201
+ experimentId: "exp/skip",
202
+ results: [res("a", "passed"), res("b", "skipped"), res("c", "failed")],
304
203
  });
305
- const items = await AttemptList.data([s]);
306
- expect(items).toHaveLength(1);
307
- const item = items[0]!;
308
- expect(item).toMatchObject({
309
- evalId: "A",
310
- experimentId: "exp/x",
311
- attempt: 0,
312
- agent: "agent-x",
313
- verdict: "failed",
314
- durationMs: 5_000,
315
- costUSD: 0.05,
316
- });
317
- expect(item.assertions).toHaveLength(1);
318
- expect(item.locator).toMatch(/^@1[0-9a-z]{7}$/);
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);
319
208
  });
320
209
 
321
- it("redact 作用于 error message/cause/stack、diagnosticmessage/data、断言 detail/evidence,不作用于身份与分类字段", async () => {
322
- const s = snap({
323
- experimentId: "exp/x",
324
- results: [
325
- res("A", "errored", {
326
- error: {
327
- code: "sandbox-provision-failed",
328
- message: "ENOENT /Users/me/repo/tool",
329
- phase: "sandbox.create",
330
- stack: "Error: ENOENT /Users/me/repo/tool\n at spawn (/Users/me/repo/src/run.ts:3:1)",
331
- cause: { name: "SystemError", code: "ENOENT", message: "no such file /Users/me/repo/tool" },
332
- },
333
- diagnostics: [
334
- {
335
- code: "teardown-failed",
336
- level: "warning",
337
- message: "cleanup left /Users/me/repo/tmp behind",
338
- phase: "sandbox.teardown",
339
- data: { path: "/Users/me/repo/tmp", attempts: 2 },
340
- },
341
- ],
342
- }),
343
- res("B", "failed", {
344
- assertions: [
345
- {
346
- name: "includes",
347
- severity: "gate",
348
- score: 0,
349
- outcome: "failed" as const,
350
- detail: "missing text under /Users/me/repo/src",
351
- evidence: "checked /Users/me/repo/src/app.ts",
352
- },
353
- ],
354
- }),
355
- ],
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,
356
219
  });
357
- const items = await AttemptList.data([s], { redact: (text) => text.replaceAll("/Users/me/repo", "<repo>") });
358
- const a = items.find((i) => i.evalId === "A")!;
359
- // error:自由文本(message/stack/cause.message)全部经钩子
360
- expect(a.error?.message).toBe("ENOENT <repo>/tool");
361
- expect(a.error?.stack).toBe("Error: ENOENT <repo>/tool\n at spawn (<repo>/src/run.ts:3:1)");
362
- expect(a.error?.cause?.message).toBe("no such file <repo>/tool");
363
- // 分类字段(code/operation/cause.name/cause.code)原样保留,不经钩子
364
- expect(a.error?.code).toBe("sandbox-provision-failed");
365
- expect(a.error?.phase).toBe("sandbox.create");
366
- expect(a.error?.cause?.name).toBe("SystemError");
367
- expect(a.error?.cause?.code).toBe("ENOENT");
368
- // diagnostics:message 与 data 的字符串值经钩子;code/operation/level 原样
369
- expect(a.diagnostics).toHaveLength(1);
370
- expect(a.diagnostics![0]!.message).toBe("cleanup left <repo>/tmp behind");
371
- expect(a.diagnostics![0]!.data).toEqual({ path: "<repo>/tmp", attempts: 2 });
372
- expect(a.diagnostics![0]!.code).toBe("teardown-failed");
373
- expect(a.diagnostics![0]!.phase).toBe("sandbox.teardown");
374
- const b = items.find((i) => i.evalId === "B")!;
375
- const bAssert = b.assertions[0]!;
376
- expect(bAssert.detail).toBe("missing text under <repo>/src");
377
- expect(bAssert.outcome !== "unavailable" ? bAssert.evidence : undefined).toBe("checked <repo>/src/app.ts");
378
- // 身份字段不经 redact
379
- expect(items.every((i) => i.experimentId === "exp/x")).toBe(true);
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);
380
223
  });
381
224
 
382
- it("不预设只看失败:passed attempt 同样列出,原样携带 assertions", async () => {
383
- const s = snap({
384
- experimentId: "exp/x",
385
- results: [res("A", "passed", { assertions: [{ name: "ok", severity: "gate", outcome: "passed" as const, score: 1 }] })],
386
- });
387
- const items = await AttemptList.data([s]);
388
- expect(items).toHaveLength(1);
389
- expect(items[0]).toMatchObject({ verdict: "passed" });
390
- expect(items[0]!.assertions).toHaveLength(1);
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);
391
233
  });
392
- });
393
234
 
394
- // ───────────────────────── 两级聚合 ─────────────────────────
395
-
396
- describe("两级聚合引擎", () => {
397
- it("题内先折再跨题平均:A=[1]、B=[0,0,0] → 0.5,不是平铺的 0.25", async () => {
235
+ it("自定义指标:where 是进入计算前的过滤;perEval + acrossEvals 两级分别生效", async () => {
398
236
  const s = snap({
399
- experimentId: "exp/x",
400
- 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
+ ],
401
244
  });
402
- const data = await MetricTable.data([s], { rows: "agent", columns: [taskPassRate] });
403
- expect(data.dimension).toBe("agent");
404
- expect(data.rows).toHaveLength(1);
405
- const cell = data.rows[0].cells["task-pass-rate"];
406
- expect(cell.value).toBe(0.5);
407
- expect(cell.display).toBe("50%");
408
- 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);
409
256
  expect(cell.total).toBe(4);
410
- expect(cell.refs).toHaveLength(4);
411
- });
412
257
 
413
- it("pass@k = perEval:max —— k 次里过一次的题算过", async () => {
414
- const passAtK = defineMetric({
415
- name: "pass@k",
416
- better: "higher",
417
- unit: "%",
418
- value: (a) => (a.result.verdict === "skipped" ? null : a.result.verdict === "passed" ? 1 : 0),
419
- aggregate: { perEval: "max", across: "mean" },
258
+ const allExcluded = defineMetric({
259
+ name: "none",
260
+ where: () => false,
261
+ value: () => 1,
420
262
  });
421
- const s = snap({
422
- experimentId: "exp/x",
423
- results: [res("A", "failed"), res("A", "failed"), res("B", "failed"), res("B", "passed")],
424
- });
425
- const data = await MetricTable.data([s], { rows: "agent", columns: [passAtK, taskPassRate] });
426
- // A: max(0,0)=0;B: max(0,1)=1 → (0+1)/2
427
- expect(data.rows[0].cells["pass@k"].value).toBe(0.5);
428
- // 对照:默认 mean/mean 的 taskPassRate = (0 + 0.5)/2
429
- expect(data.rows[0].cells["task-pass-rate"].value).toBe(0.25);
430
- });
431
-
432
- it("skipped 是 null:不稀释均值,但计入 total(覆盖率如实)", async () => {
433
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "skipped")] });
434
- const data = await MetricTable.data([s], { rows: "agent", columns: [taskPassRate] });
435
- const cell = data.rows[0].cells["task-pass-rate"];
436
- expect(cell.value).toBe(1); // B 整桶为 null,不参与 across,不是 0.5
437
- expect(cell.samples).toBe(1);
438
- expect(cell.total).toBe(2);
439
- expect(cell.refs).toHaveLength(1);
263
+ const empty = await metricTableData([s], { rows: "agent", columns: [allExcluded] });
264
+ expect(empty.rows[0]!.cells.none!.value).toBeNull();
440
265
  });
441
266
 
442
- it("全组 null value null、display 兜底,不编 0;refs 必填(空数组)", async () => {
443
- const s = snap({ experimentId: "exp/x", results: [res("A", "skipped")] });
444
- const data = await MetricTable.data([s], { rows: "agent", columns: [taskPassRate] });
445
- const cell = data.rows[0].cells["task-pass-rate"];
446
- expect(cell.value).toBeNull();
447
- expect(cell.display).toBe("");
448
- expect(cell.samples).toBe(0);
449
- expect(cell.total).toBe(1);
450
- expect(cell.refs).toEqual([]);
451
- });
452
-
453
- it("where 不满足 → null,不进聚合", async () => {
454
- const onlyPassed = defineMetric({
455
- name: "only-passed",
456
- where: (a) => a.result.verdict === "passed",
457
- 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")],
458
274
  });
459
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
460
- const data = await MetricTable.data([s], { rows: "agent", columns: [onlyPassed] });
461
- const cell = data.rows[0].cells["only-passed"];
462
- expect(cell.value).toBe(5);
463
- expect(cell.samples).toBe(1);
464
- 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"]);
465
278
  });
466
279
 
467
- it("自定义维度:第一级折叠发生在各组内部", async () => {
468
- const byParity: Dimension = {
469
- name: "parity",
470
- of: (a) => (a.result.attempt % 2 === 0 ? "even" : "odd"),
471
- };
280
+ it("报告消费落盘 verdict,不重新判卷:断言与 verdict 矛盾时以 verdict 为准", async () => {
472
281
  const s = snap({
473
- experimentId: "exp/x",
282
+ experimentId: "exp/v",
474
283
  results: [
475
- res("A", "passed", { attempt: 0 }),
476
- res("A", "failed", { attempt: 1 }),
477
- res("A", "passed", { attempt: 2 }),
284
+ res("a", "failed", {
285
+ // 断言看起来全过,但 verdict 是 failed(如 --strict 翻案):按 verdict 记 0
286
+ assertions: [softAssertion("s", 1)],
287
+ }),
478
288
  ],
479
289
  });
480
- const data = await MetricTable.data([s], { rows: byParity, columns: [taskPassRate] });
481
- expect(data.dimension).toBe("parity");
482
- const byKey = Object.fromEntries(data.rows.map((r) => [r.key, r.cells["task-pass-rate"].value]));
483
- // 同一道题的 attempt 分进两组:even 组内 [1,1] 折成 1,odd 组内 [0] 折成 0
484
- expect(byKey).toEqual({ even: 1, odd: 0 });
290
+ const data = await scopeSummaryData([s]);
291
+ expect(data.endToEndPassRate.value).toBe(0);
485
292
  });
293
+ });
486
294
 
487
- it("同一次计算里指标重名是错误", async () => {
488
- const dup = defineMetric({ name: "task-pass-rate", value: () => 1 });
489
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
490
- await expect(MetricTable.data([s], { rows: "agent", columns: [taskPassRate, dup] })).rejects.toThrow(
491
- /Duplicate metric name "task-pass-rate"/,
492
- );
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 });
493
329
  });
494
330
 
495
- it("sort 方向随 better,缺数据行沉底", async () => {
496
- const good = snap({ experimentId: "exp/good", agent: "good", results: [res("A", "passed", { agent: "good" })] });
497
- const bad = snap({ experimentId: "exp/bad", agent: "bad", results: [res("A", "failed", { agent: "bad" })] });
498
- const none = snap({ experimentId: "exp/none", agent: "none", results: [res("A", "skipped", { agent: "none" })] });
499
- const data = await MetricTable.data([none, bad, good], {
500
- rows: "agent",
501
- columns: [taskPassRate],
502
- sort: taskPassRate,
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",
503
343
  });
504
- 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" });
505
349
  });
506
350
 
507
- it("列键是字面量联合:拼错列名编译不过", async () => {
508
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
509
- const data = await MetricTable.data([s], { rows: "agent", columns: [taskPassRate, costUSD] });
510
- const cell: MetricCell = data.rows[0].cells[taskPassRate.name]; // 键锚在指标对象上
511
- expect(cell.value).toBe(1);
512
- // @ts-expect-error 列里没有这个键 —— 编译期挡住,不是运行时 undefined
513
- 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);
514
367
  });
515
368
  });
516
369
 
517
- // ───────────────────────── examScore ─────────────────────────
370
+ // ───────────────────────── MetricCell 与缺数据行为 ─────────────────────────
518
371
 
519
- describe("examScore", () => {
520
- it("errored(断言空数组)得 0 —— 不因「gate 全过」空真得满分", async () => {
521
- const s = snap({
522
- experimentId: "exp/x",
523
- results: [res("A", "errored", { assertions: [], error: erroredWith("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],
524
380
  });
525
- const data = await MetricTable.data([s], { rows: "agent", columns: [examScore] });
526
- const cell = data.rows[0].cells["exam-score"];
527
- expect(cell.value).toBe(0); // 交白卷是 0 分,不是缺数据,更不是满分
528
- 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);
529
389
  });
530
390
 
531
- it("failed 0,哪怕 soft 分不低(报告不重新判卷)", async () => {
532
- const s = snap({
533
- experimentId: "exp/x",
534
- 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],
535
396
  });
536
- const data = await MetricTable.data([s], { rows: "agent", columns: [examScore] });
537
- 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);
538
401
  });
539
402
 
540
- it("passed:soft 均分;gate 不参与给分;无 soft 则满分 1", async () => {
541
- const withSoft = snap({
542
- experimentId: "exp/a",
403
+ it("repeatedFailedCommands:同命令失败 3 次记 2;两条不同命令各失败 1 次记 0", async () => {
404
+ const repeat = snap({
405
+ experimentId: "exp/repeat",
543
406
  results: [
544
- res("A", "passed", {
545
- assertions: [
546
- softAssertion("judge-1", 0.5),
547
- softAssertion("judge-2", 1),
548
- { name: "includes", severity: "gate", score: 1, outcome: "passed" as const },
549
- ],
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
+ }),
550
416
  }),
551
417
  ],
552
418
  });
553
- const noSoft = snap({
554
- experimentId: "exp/b",
419
+ const distinct = snap({
420
+ experimentId: "exp/distinct",
555
421
  results: [
556
- res("B", "passed", {
557
- assertions: [{ name: "includes", severity: "gate", score: 1, outcome: "passed" as const }],
422
+ res("a", "failed", {
423
+ o11y: o11ySummary({
424
+ shellCommands: [
425
+ { command: "pnpm test", success: false },
426
+ { command: "pnpm build", success: false },
427
+ ],
428
+ }),
558
429
  }),
559
430
  ],
560
431
  });
561
- const a = await MetricTable.data([withSoft], { rows: "agent", columns: [examScore] });
562
- expect(a.rows[0].cells["exam-score"].value).toBe(0.75);
563
- const b = await MetricTable.data([noSoft], { rows: "agent", columns: [examScore] });
564
- expect(b.rows[0].cells["exam-score"].value).toBe(1);
565
- });
566
-
567
- it("skipped → null,不进聚合", async () => {
568
- const s = snap({ experimentId: "exp/x", results: [res("A", "skipped")] });
569
- const data = await MetricTable.data([s], { rows: "agent", columns: [examScore] });
570
- expect(data.rows[0].cells["exam-score"].value).toBeNull();
432
+ const table = await metricTableData([repeat, distinct], {
433
+ rows: "experiment",
434
+ columns: [repeatedFailedCommands],
435
+ });
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);
571
439
  });
572
- });
573
440
 
574
- // ───────────────────────── 三个通过率指标(docs/feature/reports/library.md「内置指标」)─────────────────────────
575
- //
576
- // 同一份 passed / failed / errored / skipped 混合 fixture 上,三个口径必须给出三个不同的数,
577
- // 任何一处偷懒复用另一个公式都会在这里露馅:
578
- // taskPassRate:errored → null 不进分母 → (1 + 0) / 2 = 50%
579
- // executionReliability:errored → 0,passed/failed → 1 → (1 + 1 + 0) / 3 ≈ 66.7%
580
- // endToEndPassRate:failed/errored → 0 → (1 + 0 + 0) / 3 ≈ 33.3%
581
-
582
- describe("taskPassRate / executionReliability / endToEndPassRate", () => {
583
- const mixed = () =>
584
- snap({
585
- experimentId: "exp/mixed",
441
+ it("value display 分别可断言;display 由 unit 或自定义 display(value, locale) 驱动", async () => {
442
+ const s = snap({
443
+ experimentId: "exp/display",
586
444
  results: [
587
- res("A", "passed"),
588
- res("B", "failed"),
589
- res("C", "errored", { error: erroredWith("adapter crashed") }),
590
- res("D", "skipped"),
445
+ ...[1, 1, 1, 1, 1].map((_, i) => res(`q${i}`, "passed")),
446
+ res("q5", "failed"),
591
447
  ],
592
448
  });
593
-
594
- it("taskPassRate:errored null 不进分母 —— 基建故障不伪装成 Agent 答错", async () => {
595
- const data = await MetricTable.data([mixed()], { rows: "agent", columns: [taskPassRate] });
596
- const cell = data.rows[0].cells["task-pass-rate"];
597
- expect(cell.value).toBeCloseTo(0.5, 10); // (1 + 0) / 2:errored 与 skipped 都不在分母
598
- expect(cell.samples).toBe(2); // errored、skipped 都是 null,不算有效样本
599
- expect(cell.total).toBe(4);
600
- });
601
-
602
- it("executionReliability:跑到可判定(passed/failed)= 1,errored = 0,skipped null", async () => {
603
- const data = await MetricTable.data([mixed()], { rows: "agent", columns: [executionReliability] });
604
- const cell = data.rows[0].cells["execution-reliability"];
605
- expect(cell.value).toBeCloseTo(2 / 3, 10);
606
- expect(cell.samples).toBe(3); // errored 测得了(= 0),skipped 测不了
607
- expect(cell.total).toBe(4);
608
- });
609
-
610
- it("endToEndPassRate:failed / errored 都记 0 —— 端到端合成,哪边拖累用前两个拆开看", async () => {
611
- const data = await MetricTable.data([mixed()], { rows: "agent", columns: [endToEndPassRate] });
612
- const cell = data.rows[0].cells["end-to-end-pass-rate"];
613
- expect(cell.value).toBeCloseTo(1 / 3, 10);
614
- expect(cell.samples).toBe(3);
615
- expect(cell.total).toBe(4);
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 个" });
616
460
  });
617
461
 
618
- it("全部 errored:taskPassRate 缺数据(null),executionReliability 如实 0 —— 两个指标不互相冒充", async () => {
619
- const s = snap({
620
- experimentId: "exp/broken",
621
- results: [res("A", "errored", { error: erroredWith("boom") })],
462
+ it("value() 抛错时整个计算失败,错误带 metric name 与 attempt locator,不伪装成测不了", async () => {
463
+ const bad = defineMetric({
464
+ name: "explode",
465
+ value: () => {
466
+ throw new Error("boom");
467
+ },
622
468
  });
623
- const task = await MetricTable.data([s], { rows: "agent", columns: [taskPassRate] });
624
- expect(task.rows[0].cells["task-pass-rate"].value).toBeNull(); // 没有一道题真正被答过
625
- const reliability = await MetricTable.data([s], { rows: "agent", columns: [executionReliability] });
626
- expect(reliability.rows[0].cells["execution-reliability"].value).toBe(0);
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);
627
471
  });
628
472
  });
629
473
 
630
- // ───────────────────────── 内置指标口径 ─────────────────────────
474
+ // ───────────────────────── Scoreboard ─────────────────────────
631
475
 
632
- describe("内置指标", () => {
633
- it("tokens 只加 input+output,缓存读写不计入;无 usage null", async () => {
634
- const s = snap({
635
- experimentId: "exp/x",
636
- results: [
637
- res("A", "passed", {
638
- usage: { inputTokens: 1000, outputTokens: 200, cacheReadTokens: 999_999, cacheWriteTokens: 888 },
639
- }),
640
- res("B", "failed"), // 无 usage → null,不稀释
641
- ],
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),
642
481
  });
643
- const data = await MetricTable.data([s], { rows: "agent", columns: [tokens] });
644
- const cell = data.rows[0].cells["tokens"];
645
- expect(cell.value).toBe(1200);
646
- expect(cell.display).toBe("1.2k tokens");
647
- expect(cell.samples).toBe(1);
648
- expect(cell.total).toBe(2);
649
- });
650
-
651
- it("costUSD:网关实测优先于估算;durationMs 对 errored 取实测", async () => {
652
482
  const s = snap({
653
- experimentId: "exp/x",
654
- results: [
655
- res("A", "passed", {
656
- usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.5 },
657
- estimatedCostUSD: 999, // 实测优先,不该被用到
658
- }),
659
- res("B", "errored", { durationMs: 3000 }),
660
- ],
483
+ experimentId: "exp/board",
484
+ results: [res("s/ran", "passed"), res("s/unscorable", "passed")],
661
485
  });
662
- const cost = await MetricTable.data([s], { rows: "agent", columns: [costUSD] });
663
- expect(cost.rows[0].cells["cost"].value).toBe(0.5);
664
- const dur = await MetricTable.data([s], { rows: "agent", columns: [durationMs] });
665
- expect(dur.rows[0].cells["duration"].value).toBe(2000); // (1000 + 3000)/2,errored 实测照算
666
- });
667
-
668
- it("turns:读 o11y.totalTurns;o11y 缺失(未随发布带上)→ null,不是 0;skipped 不进聚合", async () => {
669
- const s = snap({
670
- experimentId: "exp/x",
671
- results: [
672
- res("A", "passed", { o11y: o11ySummary(12) }),
673
- res("B", "failed"), // 没带 o11y(如 copySnapshots 漏选 artifact)→ null,不稀释成 0
674
- res("C", "skipped", { o11y: o11ySummary(3) }), // skipped 恒 null,哪怕 o11y 在场
675
- ],
486
+ const data = await scoreboardData([s], {
487
+ rows: "agent",
488
+ questions: ["s/ran", "s/unscorable", "s/never-1", "s/never-2"],
489
+ score: nullScore,
676
490
  });
677
- const data = await MetricTable.data([s], { rows: "agent", columns: [turns] });
678
- const cell = data.rows[0].cells["turns"];
679
- expect(cell.value).toBe(12);
680
- expect(cell.samples).toBe(1); // 只有 A 测得了(B 缺 o11y、C 是 skipped)
681
- expect(cell.total).toBe(3); // total 是组内全部 attempt 数,不是「本该测得」的分母
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);
682
497
  });
683
- });
684
498
 
685
- // ───────────────────────── Scoreboard.data ─────────────────────────
686
-
687
- describe("Scoreboard.data", () => {
688
- it("固定分母 + missing 如实 + 权重最长前缀生效", async () => {
689
- const alpha = snap({
690
- experimentId: "exp/alpha",
691
- agent: "alpha",
692
- results: [
693
- res("algebra/x", "passed", { agent: "alpha" }),
694
- res("algebra/hard/y", "passed", { agent: "alpha" }),
695
- res("geometry/z", "passed", { agent: "alpha" }),
696
- ],
697
- });
698
- const beta = snap({
699
- experimentId: "exp/beta",
700
- agent: "beta",
701
- results: [res("algebra/x", "passed", { agent: "beta" })],
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")],
702
503
  });
703
- const board = await Scoreboard.data([alpha, beta], {
504
+ const data = await scoreboardData([s], {
704
505
  rows: "agent",
705
- subjects: "evalGroup",
706
- weights: { "algebra/": 3, "algebra/hard/": 9 },
506
+ questions: ["security/auth/a", "security/b", "misc"],
507
+ weights: { "security/": 2, "security/auth/": 4 },
707
508
  fullMarks: 100,
708
509
  });
709
- expect(board.dimension).toBe("agent");
710
- // 生效权重表可审计:最长前缀在前(匹配顺序)
711
- expect(board.weights).toEqual([
712
- { prefix: "algebra/hard/", weight: 9 },
713
- { prefix: "algebra/", weight: 3 },
714
- ]);
715
-
716
- const alphaRow = board.rows.find((r) => r.key === "alpha")!;
717
- const betaRow = board.rows.find((r) => r.key === "beta")!;
718
-
719
- // Σ分值 = 3(algebra/x)+ 9(algebra/hard/y,最长前缀 9 不是 3)+ 1(geometry/z,默认)= 13
720
- expect(alphaRow.total.value).toBeCloseTo(100);
721
- // beta 只答了 algebra/x:总分 = 100 × 3/13 ≈ 23.1。
722
- // 若最长前缀错配成 "algebra/" 的 3,分母变 7,会得 100 × 3/7 ≈ 42.9 —— 钉死歧义。
723
- expect(betaRow.total.value).toBeCloseTo((100 * 3) / 13);
724
-
725
- // 固定分母:beta 没跑的题留在分母,missing 如实报在科目行
726
- const betaAlgebra = betaRow.subjects.find((s) => s.key === "algebra")!;
727
- expect(betaAlgebra.possible).toBe(12); // 3 + 9
728
- expect(betaAlgebra.earned).toBe(3);
729
- expect(betaAlgebra.evals).toBe(2);
730
- expect(betaAlgebra.missing).toBe(1);
731
- const betaGeometry = betaRow.subjects.find((s) => s.key === "geometry")!;
732
- expect(betaGeometry.possible).toBe(1);
733
- expect(betaGeometry.earned).toBe(0);
734
- expect(betaGeometry.missing).toBe(1);
735
-
736
- const alphaAlgebra = alphaRow.subjects.find((s) => s.key === "algebra")!;
737
- expect(alphaAlgebra.earned).toBe(12);
738
- expect(alphaAlgebra.missing).toBe(0);
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 }); // 最长前缀在前
739
514
  });
740
515
 
741
- it("默认 score examScore:soft 分进总分;skipped missing 而非 0 分入账", async () => {
742
- const solo = snap({
743
- experimentId: "exp/solo",
744
- agent: "solo",
745
- results: [
746
- res("algebra/x", "passed", { agent: "solo", assertions: [softAssertion("judge", 0.5)] }),
747
- res("algebra/y", "skipped", { agent: "solo" }), // 无有效样本 → missing(按 0 计但如实标注)
748
- ],
516
+ it("subject 缺省与 evalGroup 同一条规则(完整父路径);题集外的 eval 忽略并计入 ignoredEvals", async () => {
517
+ const s = snap({
518
+ experimentId: "exp/subject",
519
+ results: [res("a/b/c", "passed"), res("outside", "passed")],
749
520
  });
750
- const board = await Scoreboard.data([solo], { rows: "agent" });
751
- const row = board.rows[0];
752
- // 两题各 1 分:0.5 + 0(missing)→ 100 × 0.5/2 = 25
753
- expect(row.total.value).toBeCloseTo(25);
754
- expect(row.subjects[0].missing).toBe(1);
755
- expect(row.subjects[0].evals).toBe(2);
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);
756
524
  });
757
525
 
758
- it("快照携带的 knownEvalIds 进固定分母(发布目录上的残缺不消失)", async () => {
759
- const s = snap({
760
- experimentId: "exp/x",
761
- agent: "solo",
762
- results: [res("algebra/x", "passed", { agent: "solo" })],
763
- knownEvalIds: ["algebra/x", "algebra/y"],
764
- });
765
- const board = await Scoreboard.data([s], { rows: "agent" });
766
- expect(board.rows[0].subjects[0].evals).toBe(2);
767
- expect(board.rows[0].subjects[0].missing).toBe(1);
768
- expect(board.rows[0].total.value).toBeCloseTo(50);
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/);
769
540
  });
770
541
  });
771
542
 
772
- // ───────────────────────── MetricScatter.data ─────────────────────────
773
-
774
- describe("MetricScatter.data", () => {
775
- it("任一轴 null 的点仍在 rows 里、可数;series 随组解析", async () => {
776
- const withCost = snap({
777
- experimentId: "exp/a",
778
- agent: "a1",
779
- results: [res("A", "passed", { agent: "a1", usage: { inputTokens: 10, outputTokens: 5, costUSD: 0.5 } })],
780
- });
781
- const noCost = snap({
782
- experimentId: "exp/b",
783
- agent: "b1",
784
- results: [res("A", "passed", { agent: "b1" })],
785
- });
786
- const data = await MetricScatter.data([withCost, noCost], {
787
- points: "experiment",
788
- series: "agent",
789
- x: costUSD,
790
- y: taskPassRate,
791
- });
792
- expect(data.points).toBe("experiment");
793
- expect(data.series).toBe("agent");
794
- expect(data.rows).toHaveLength(2);
795
-
796
- const a = data.rows.find((r) => r.key === "exp/a")!;
797
- expect(a.series).toBe("a1");
798
- expect(a.x.value).toBe(0.5);
799
- expect(a.y.value).toBe(1);
800
-
801
- const b = data.rows.find((r) => r.key === "exp/b")!;
802
- expect(b.x.value).toBeNull(); // 没有成本数据:点在,值缺
803
- expect(b.y.value).toBe(1);
804
- // 注脚「n 个点缺数据」就从 rows 里数出来,不需要另一份数据
805
- expect(data.rows.filter((r) => r.x.value === null || r.y.value === null)).toHaveLength(1);
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 },
560
+ });
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
+ },
568
+ });
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();
806
584
  });
807
- });
808
585
 
809
- // ───────────────────────── flag():维度与轴 ─────────────────────────
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();
596
+ });
810
597
 
811
- describe("flag()", () => {
812
- const withFlags = (id: string, flags: Record<string, unknown> | undefined, verdict: Verdict) =>
813
- snap({
814
- experimentId: id,
815
- results: [res("A", verdict, { experimentId: id, experiment: { flags, runs: 1, earlyExit: true, selectedEvalIds: [] } as import("../types.ts").ExperimentRunInfo })],
816
- });
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]");
614
+ });
817
615
 
818
- it("MetricLine.data:x flag、按 experiment 聚合;未声明的作轴 x=null 报数", async () => {
819
- const s1 = withFlags("ultra/lat-100", { latencyMs: 100, agents: 1 }, "passed");
820
- const s2 = withFlags("ultra/lat-300", { latencyMs: 300, agents: 1 }, "failed");
821
- const legacy = withFlags("ultra/legacy", undefined, "passed");
822
- const data = await MetricLine.data([s1, s2, legacy], {
823
- x: flag("latencyMs", { label: "Simulated latency", unit: "ms" }),
824
- series: flag("agents", { label: (v) => `${v} agents` }),
825
- y: taskPassRate,
826
- });
827
- expect(data.x).toEqual({ key: "latencyMs", label: "Simulated latency", unit: "ms" });
828
- expect(data.series).toBe("agents");
829
- expect(data.rows).toHaveLength(3);
830
-
831
- const p100 = data.rows.find((r) => r.key === "ultra/lat-100")!;
832
- expect(p100.x).toBe(100);
833
- expect(p100.xDisplay).toBe("100ms");
834
- expect(p100.series).toBe("1 agents");
835
- expect(p100.y.value).toBe(1);
836
-
837
- // 未声明 flag 的 experiment 不猜:作轴 x=null(组件不画、注脚报数),分组归 (unset)
838
- const legacyRow = data.rows.find((r) => r.key === "ultra/legacy")!;
839
- expect(legacyRow.x).toBeNull();
840
- expect(legacyRow.xDisplay).toBe("");
841
- expect(legacyRow.series).toBe("(unset)");
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);
842
624
  });
843
625
 
844
- it("flag 当维度用:按声明值分组,label 函数折组名", async () => {
845
- const s1 = withFlags("exp/a", { agents: 1 }, "passed");
846
- const s2 = withFlags("exp/b", { agents: 16 }, "failed");
847
- const data = await MetricTable.data([s1, s2], {
848
- rows: flag("agents", { label: (v) => `${v} agents` }),
849
- columns: [taskPassRate],
850
- });
851
- expect(data.dimension).toBe("agents");
852
- expect(data.rows.map((r) => r.key)).toEqual(["1 agents", "16 agents"]);
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);
853
633
  });
854
634
  });
855
635
 
856
- // ───────────────────────── config():顶层运行配置当维度与轴 ─────────────────────────
857
- // flag() 只读 ExperimentDef.flags 的显式声明;model / reasoningEffort / budget / runs 这类
858
- // 顶层运行配置用 config() 读快照的 ExperimentRunInfo 投影,外加桥接 model / agent 两个键
859
- // (docs/feature/reports/library.md「维度与 flags」)
860
-
861
- describe("config()", () => {
862
- /** 快照声明 ExperimentRunInfo 投影;字段全集以 docs 的接口为准,这里按键名读值。 */
863
- const withConfig = (id: string, info: Record<string, unknown> | undefined, verdict: Verdict, model?: string) => {
864
- const s = snap({ experimentId: id, model, results: [res("A", verdict, { experimentId: id })] });
865
- if (info) s.experiment = info as unknown as (typeof s)["experiment"];
866
- return s;
867
- };
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
+ });
868
665
 
869
- it("config 当维度用:按投影值分组;未投影的 experiment 如实归 (unset)", async () => {
870
- const high = withConfig("effort/high", { reasoningEffort: "high" }, "passed");
871
- const low = withConfig("effort/low", { reasoningEffort: "low" }, "failed");
872
- const legacy = withConfig("effort/legacy", undefined, "passed");
873
- const data = await MetricTable.data([high, low, legacy], {
874
- rows: config("reasoningEffort", { label: "Reasoning effort" }),
875
- columns: [taskPassRate],
666
+ it("totalCostUSD attempt 求和;一次成本都没报时 value 为 null,不伪造 0", async () => {
667
+ const withCost = snap({
668
+ experimentId: "exp/cost",
669
+ results: [
670
+ res("a", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.25 } }),
671
+ res("b", "failed", { estimatedCostUSD: 0.05 }),
672
+ res("c", "passed"),
673
+ ],
876
674
  });
877
- expect(data.dimension).toBe("reasoningEffort");
878
- expect(data.rows.map((r) => r.key)).toEqual(["high", "low", "(unset)"]);
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();
879
679
  });
880
680
 
881
- it("config 当数值轴用(MetricLine.data x):非数值或未投影 → x=null 不伪造,注脚可数", async () => {
882
- const b1 = withConfig("budget/one", { budget: 1 }, "passed");
883
- const b2 = withConfig("budget/two", { budget: 2 }, "failed");
884
- const none = withConfig("budget/none", { reasoningEffort: "high" }, "passed"); // 没投影 budget
885
- const data = await MetricLine.data([b1, b2, none], {
886
- x: config("budget", { label: "Budget", unit: "USD" }),
887
- y: taskPassRate,
888
- });
889
- expect(data.x).toEqual({ key: "budget", label: "Budget", unit: "USD" });
890
- const one = data.rows.find((r) => r.key === "budget/one")!;
891
- expect(one.x).toBe(1);
892
- expect(one.y.value).toBe(1);
893
- const missing = data.rows.find((r) => r.key === "budget/none")!;
894
- expect(missing.x).toBeNull();
895
- expect(missing.xDisplay).toBe("");
896
- expect(data.rows.filter((r) => r.x === null)).toHaveLength(1);
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);
897
685
  });
686
+ });
898
687
 
899
- it("model / agent 两个键桥接到快照顶层权威字段,ExperimentRunInfo 不复制这两个事实", async () => {
900
- const mini = withConfig("m/mini", { budget: 1 }, "passed", "gpt-mini");
901
- const maxi = withConfig("m/maxi", { budget: 1 }, "failed", "gpt-maxi");
902
- const data = await MetricTable.data([mini, maxi], {
903
- rows: config("model"),
904
- columns: [taskPassRate],
905
- });
906
- expect(data.rows.map((r) => r.key)).toEqual(["gpt-mini", "gpt-maxi"]);
688
+ // ───────────────────────── experimentComparisonData ─────────────────────────
907
689
 
908
- const byAgent = await MetricTable.data([mini, maxi], { rows: config("agent"), columns: [taskPassRate] });
909
- expect(byAgent.rows.map((r) => r.key)).toEqual(["agent-x"]); // res() 默认 agent-x
910
- });
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") })] });
911
696
 
912
- it("label 函数折组名,与 flag() 同一套规则", async () => {
913
- const s1 = withConfig("runs/3", { runs: 3 }, "passed");
914
- const s2 = withConfig("runs/5", { runs: 5 }, "passed");
915
- const data = await MetricTable.data([s1, s2], {
916
- rows: config("runs", { label: (v) => `${v} runs` }),
917
- columns: [taskPassRate],
918
- });
919
- expect(data.rows.map((r) => r.key)).toEqual(["3 runs", "5 runs"]);
920
- });
697
+ const data = await experimentComparisonData([g1a, g1b, g2, solo]);
698
+ expect(data.groups.map((g) => g.key)).toEqual(["bench/long", "compare", "standalone"]);
921
699
 
922
- it("空 name 直接报错,与 flag() 同一门槛", () => {
923
- expect(() => config("")).toThrow(/non-empty string/);
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);
924
714
  });
925
715
  });
926
716
 
927
- // ───────────────────────── MetricTable.data:没有实体下钻 ─────────────────────────
717
+ // ───────────────────────── metricScatterData / metricMatrixData ─────────────────────────
928
718
 
929
- describe("MetricTable.data", () => {
930
- it("rows: \"experiment\" meta 不带 subRows —— MetricTable 只表达维度 × 指标,没有 expand", async () => {
931
- const s = snap({
932
- experimentId: "exp/x",
933
- results: [res("A", "passed"), res("B", "failed")],
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 } })],
934
725
  });
935
- const data = await MetricTable.data([s], { rows: "experiment", columns: [taskPassRate] });
936
- expect(data.rows).toHaveLength(1);
937
- expect(data.rows[0]!.meta).not.toHaveProperty("subRows");
938
- // 展开到 Eval 是 ExperimentList 的职责,不在这里验证 —— 见 ExperimentList.data 的测试组。
939
- });
940
- });
941
-
942
- // ───────────────────────── reasonFor(原因优先级)─────────────────────────
943
- // docs/feature/reports/show.md 裸 `show` 的 RESULT 列口径:error → skipReason → 未通过的 gate 断言。
944
- // ExperimentList / EvalList 的 evalRows.reason、AttemptList 的 attemptItemReason 都port这同一份材料,
945
- // 这里直接测 reasonFor 本身,不必再经 MetricTable 之类的中间组件绕一圈。
946
-
947
- describe("reasonFor", () => {
948
- it("同一 result 同时含 error、失败 gate、失败 soft → 只显示 error", () => {
949
- const result = res("A", "errored", {
950
- error: erroredWith("adapter crashed"),
951
- assertions: [
952
- { name: "includes", severity: "gate", score: 0, outcome: "failed" as const, detail: "missing text" },
953
- softAssertion("judge", 0.2, { outcome: "failed" as const }),
954
- ],
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,
955
732
  });
956
- expect(reasonFor(result)).toBe("adapter crashed");
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");
957
746
  });
958
747
 
959
- it("skipReason 优先于 gate 断言,但让位给 error", () => {
960
- const result = res("A", "skipped", {
961
- skipReason: "missing fixture",
962
- assertions: [{ name: "includes", severity: "gate", score: 0, outcome: "failed" as const }],
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],
963
758
  });
964
- expect(reasonFor(result)).toBe("missing fixture");
759
+ expect(table.rows.map((r) => r.key).sort()).toEqual(["(missing)", "mempal"]);
965
760
  });
966
761
 
967
- it("只展示第一条失败 gate,其余 gate 计数;失败 soft 不进入 gate 计数", () => {
968
- const result = res("A", "failed", {
969
- assertions: [
970
- softAssertion("judge-first", 0.1, { outcome: "failed" as const }), // 声明顺序第一,但 soft 不进 reason
971
- { name: "includes", groupPath: ["answer contains required text"], severity: "gate", score: 0, outcome: "failed" as const, expected: "contains Brooklyn", received: "Manhattan" },
972
- { name: "matches", severity: "gate", score: 0, outcome: "failed" as const }, // 无 detail,只有 name
973
- softAssertion("judge-last", 0.3, { outcome: "failed" as const }), // soft 依旧不进 reason
974
- ],
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,
770
+ });
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,
975
779
  });
976
- expect(reasonFor(result)).toBe("gate: answer contains required text · includes · expected contains Brooklyn · received Manhattan · +1 more failures");
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/);
977
789
  });
978
790
 
979
- it("都缺席 无原因", () => {
980
- expect(reasonFor(res("A", "passed"))).toBeUndefined();
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"]);
981
796
  });
982
797
  });
983
798
 
984
- // ───────────────────────── RunOverview.data ─────────────────────────
799
+ // ───────────────────────── metricLineData ─────────────────────────
985
800
 
986
- describe("RunOverview.data", () => {
987
- it("costUSD 全缺为 null 不编 0;有实测/估算则求和;Snapshot[] 输入无警告", async () => {
988
- const bare = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
989
- const bareOverview = await RunOverview.data([bare]);
990
- expect(bareOverview.totals.costUSD).toBeNull();
991
- expect(bareOverview.totals.attempts).toBe(2);
992
- expect(bareOverview.totals.passed).toBe(1);
993
- expect(bareOverview.totals.failed).toBe(1);
994
- expect(bareOverview.warnings).toEqual([]);
995
-
996
- const priced = snap({
997
- experimentId: "exp/y",
998
- results: [
999
- res("A", "passed", { estimatedCostUSD: 0.1 }),
1000
- res("B", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } }),
1001
- ],
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
+ },
1002
812
  });
1003
- const data = await RunOverview.data([priced]);
1004
- expect(data.totals.costUSD).toBeCloseTo(0.3);
1005
- expect(data.totals.evals).toBe(2);
1006
- expect(data.snapshots).toEqual([
1007
- { experimentId: "exp/y", agent: "agent-x", model: undefined, startedAt: priced.startedAt },
1008
- ]);
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);
1009
822
  });
1010
823
 
1011
- it(" Selection warnings 随行进数据 —— 诚实不靠使用者记得透传", async () => {
1012
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
1013
- const warning: SelectionWarning = {
1014
- kind: "partial-coverage",
1015
- experimentId: "exp/x",
1016
- covered: 1,
1017
- total: 50,
1018
- message: "snapshot covers 1 of 50 evals seen in history",
1019
- };
1020
- const data = await RunOverview.data(selection([s], [warning]));
1021
- expect(data.warnings).toEqual([warning]);
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
+ );
830
+ expect(data.rows).toHaveLength(1);
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);
1022
834
  });
1023
835
 
1024
- it("默认成功率把 errored 0:2 passed / 5 errored = 2/7,不是 100% 2/7", async () => {
836
+ it("自定义 NumericAxis.of 在同一 experiment × eval 内不恒定时报完整用户反馈,不静默取首值", async () => {
1025
837
  const s = snap({
1026
- experimentId: "exp/unstable",
1027
- results: [
1028
- res("pass/a", "passed"),
1029
- res("pass/b", "passed"),
1030
- ...Array.from({ length: 5 }, (_, i) => res(`error/${i}`, "errored", { error: erroredWith("boom") })),
1031
- ],
838
+ experimentId: "l/vary",
839
+ results: [res("q", "passed", { attempt: 0, durationMs: 10 }), res("q", "passed", { attempt: 1, durationMs: 20 })],
1032
840
  });
1033
- const data = await RunOverview.data([s]);
1034
- expect(data.totals.passRate.value).toBeCloseTo(2 / 7, 10);
1035
- expect(data.totals.passRate.display).toBe("28.6%");
1036
- expect(data.totals.passRate.samples).toBe(7);
1037
- expect(data.totals.passRate.total).toBe(7);
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/);
1038
846
  });
1039
847
  });
1040
848
 
1041
- // ───────────────────────── GroupSummary.data ─────────────────────────
849
+ // ───────────────────────── deltaTableData 与 pairsByFlag ─────────────────────────
1042
850
 
1043
- describe("GroupSummary.data", () => {
1044
- it(" experiment、多 attempt、部分成本缺失、不同 startedAt:eval 级折叠计票(完整身份键)、总成本 null-safe 求和、lastRunAt 取组内最大值", async () => {
1045
- const expA = snap({
1046
- experimentId: "exp/a",
1047
- runStartedAt: "2026-07-01T08:00:00Z",
1048
- results: [
1049
- // "shared" 两轮:一败一过 → 折叠成 passed(与 exp/b 同名的 "shared" 各算各的,见下方独立测试)
1050
- res("shared", "failed", { attempt: 0 }),
1051
- res("shared", "passed", { attempt: 1, usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.5 } }),
1052
- // "only-a" 只有 exp/a 跑过,失败、没报成本
1053
- res("only-a", "failed"),
1054
- ],
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 } })],
1055
856
  });
1056
- const expB = snap({
1057
- experimentId: "exp/b",
1058
- runStartedAt: "2026-07-03T08:00:00Z", // exp/a —— lastRunAt 应取这个
1059
- results: [
1060
- // 与 exp/a 同名的 "shared":不同 experiment,必须独立计一票,不能被误合并
1061
- res("shared", "errored", { estimatedCostUSD: 0.2 }),
1062
- ],
857
+ const b = snap({
858
+ experimentId: "d/next",
859
+ results: [res("q", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
1063
860
  });
1064
-
1065
- const data = await GroupSummary.data([expA, expB]);
1066
-
1067
- expect(data.experiments).toBe(2);
1068
- // 3 道题:exp/a 的 shared、exp/a 的 only-a、exp/b 的 shared —— 完整身份键各算各的
1069
- expect(data.evals).toBe(3);
1070
- expect(data.attempts).toBe(4); // 原始 attempt 计数:exp/a 2(shared)+1(only-a)+ exp/b 1(shared)
1071
- expect(data.verdicts).toEqual({ passed: 1, failed: 1, errored: 1, skipped: 0 });
1072
- // eval 级折叠通过率:1 / (1 + 1 + 1) = 1/3,不是 attempt 原始占比(1 passed / 4 attempts = 25%)
1073
- expect(data.passRate.value).toBeCloseTo(1 / 3, 10);
1074
- expect(data.passRate.display).toBe("33.3%");
1075
- expect(data.passRate.samples).toBe(3);
1076
- expect(data.passRate.total).toBe(3);
1077
- // 总成本 null-safe 求和:0.5(实测)+ 0.2(估算)= 0.7;only-a 的失败 attempt 没报成本,不计 0
1078
- expect(data.totalCostUSD).toBeCloseTo(0.7, 10);
1079
- // 最后运行时间:组内两个快照的 startedAt 取最大值(exp/b 更晚)
1080
- expect(data.lastRunAt).toBe(expB.startedAt);
1081
- });
1082
-
1083
- it("全组没有任何 attempt 报成本 → totalCostUSD null,不编 0", async () => {
1084
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
1085
- const data = await GroupSummary.data([s]);
1086
- expect(data.totalCostUSD).toBeNull();
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" });
1087
873
  });
1088
874
 
1089
- it("分母为 0(组内 eval 全部 skipped)→ passRate.value 为 null,不编 0%", async () => {
1090
- const s = snap({ experimentId: "exp/x", results: [res("A", "skipped"), res("B", "skipped")] });
1091
- const data = await GroupSummary.data([s]);
1092
- expect(data.verdicts).toEqual({ passed: 0, failed: 0, errored: 0, skipped: 2 });
1093
- expect(data.passRate.value).toBeNull();
1094
- expect(data.passRate.display).toBe("—");
1095
- expect(data.passRate.samples).toBe(0);
1096
- expect(data.passRate.total).toBe(2);
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" }] });
1097
884
  });
1098
885
 
1099
- it('MetricTable rows: "experiment" verdicts meta 复用同一份 eval 级统计,行为不变(回归)', async () => {
1100
- const s = snap({
1101
- experimentId: "exp/x",
1102
- results: [res("A", "passed", { attempt: 0 }), res("A", "failed", { attempt: 1 }), res("B", "failed")],
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],
1103
905
  });
1104
- const data = await MetricTable.data([s], { rows: "experiment", columns: [taskPassRate] });
1105
- // A 两轮折成 passed(任一轮过即过),B 失败:1 passed / 1 failed
1106
- expect(data.rows[0].meta?.verdicts).toEqual({ passed: 1, failed: 1, errored: 0, skipped: 0 });
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();
1107
910
  });
1108
- });
1109
911
 
1110
- // ───────────────────────── DeltaTable.data ─────────────────────────
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
+ };
1111
938
 
1112
- describe("DeltaTable.data", () => {
1113
- it("任一侧 null delta null 不硬算;双侧有值给带符号 display;Δ=0 是 ±0", async () => {
1114
- const base = snap({
1115
- experimentId: "exp/base",
1116
- results: [res("A", "failed"), res("B", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
1117
- });
1118
- const plus = snap({
1119
- experimentId: "exp/plus",
1120
- results: [res("A", "passed"), res("B", "passed")], // 无任何成本数据
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);
1121
953
  });
1122
- const data = await DeltaTable.data([base, plus], {
1123
- pairs: [{ a: "exp/base", b: "exp/plus", label: "memory" }],
1124
- metrics: [taskPassRate, costUSD],
1125
- });
1126
- expect(data.rows).toHaveLength(1);
1127
- const row = data.rows[0];
1128
- expect(row.key).toBe("memory");
1129
- expect(row.a).toEqual({ experimentId: "exp/base" });
1130
- expect(row.b).toEqual({ experimentId: "exp/plus" });
1131
-
1132
- const pass = row.cells["task-pass-rate"];
1133
- expect(pass.a.value).toBe(0.5);
1134
- expect(pass.b.value).toBe(1);
1135
- expect(pass.delta).toBeCloseTo(0.5);
1136
- expect(pass.display).toBe("+50%");
1137
-
1138
- const cost = row.cells["cost"];
1139
- expect(cost.a.value).toBeCloseTo(0.2);
1140
- expect(cost.b.value).toBeNull();
1141
- expect(cost.delta).toBeNull(); // 单侧缺数据:不硬算
1142
- expect(cost.display).toBe("—");
1143
-
1144
- const flat = await DeltaTable.data([base, base], {
1145
- pairs: [{ a: "exp/base", b: "exp/base", label: "same" }],
1146
- metrics: [taskPassRate],
1147
- });
1148
- expect(flat.rows[0].cells["task-pass-rate"].display).toBe("±0");
1149
- });
1150
954
 
1151
- it("时间轴对比:pairs 的 a/b 收快照键 <experimentId> @ <startedAt>", async () => {
1152
- const older = snap({
1153
- experimentId: "exp/x",
1154
- runStartedAt: "2026-07-01T08:00:00Z",
1155
- results: [res("A", "failed")],
1156
- });
1157
- const newer = snap({
1158
- experimentId: "exp/x",
1159
- runStartedAt: "2026-07-02T08:00:00Z",
1160
- results: [res("A", "passed")],
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);
1161
975
  });
1162
- const data = await DeltaTable.data([older, newer], {
1163
- pairs: [
1164
- {
1165
- a: "exp/x @ 2026-07-01T08:00:00Z",
1166
- b: "exp/x @ 2026-07-02T08:00:00Z",
1167
- label: "this week vs last",
1168
- },
1169
- ],
1170
- metrics: [taskPassRate],
1171
- });
1172
- const cell = data.rows[0].cells["task-pass-rate"];
1173
- expect(cell.a.value).toBe(0); // 旧快照那份
1174
- expect(cell.b.value).toBe(1); // 新快照那份
1175
- expect(cell.display).toBe("+100%");
1176
- });
1177
- });
1178
976
 
1179
- // ───────────────────────── 身份键去重 ─────────────────────────
1180
-
1181
- describe("身份键去重", () => {
1182
- it("同 (experimentId, evalId, attempt, startedAt) 两份 → 保留最新 run 的那份", async () => {
1183
- const identity = {
1184
- experimentId: "exp/x",
1185
- attempt: 0,
1186
- startedAt: "2026-07-01T08:00:00Z",
1187
- };
1188
- // --resume 场景:旧 run 里 failed,新 run 合入同身份键的 passed
1189
- const older = snap({
1190
- experimentId: "exp/x",
1191
- runStartedAt: "2026-07-01T08:00:00Z",
1192
- results: [res("A", "failed", identity)],
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"/);
1193
990
  });
1194
- const newer = snap({
1195
- experimentId: "exp/x",
1196
- runStartedAt: "2026-07-02T08:00:00Z",
1197
- results: [res("A", "passed", identity)],
1198
- });
1199
-
1200
- for (const order of [
1201
- [older, newer],
1202
- [newer, older],
1203
- ]) {
1204
- const data = await MetricTable.data(order, { rows: "agent", columns: [taskPassRate] });
1205
- const cell = data.rows[0].cells["task-pass-rate"];
1206
- expect(cell.total).toBe(1); // 两份只算一份
1207
- expect(cell.value).toBe(1); // 留的是最新 run 里的 passed,与快照传入顺序无关
1208
-
1209
- const ov = await RunOverview.data(order);
1210
- expect(ov.totals.attempts).toBe(1);
1211
- expect(ov.totals.passed).toBe(1);
1212
- expect(ov.totals.failed).toBe(0);
1213
- }
1214
- });
1215
-
1216
- it("startedAt 缺失:不去重、如实保留重复,不透出警告", async () => {
1217
- const identity = { experimentId: "exp/x", attempt: 0, startedAt: undefined };
1218
- const one = snap({ experimentId: "exp/x", results: [res("A", "passed", identity)] });
1219
- const two = snap({ experimentId: "exp/x", results: [res("A", "passed", identity)] });
1220
- const ov = await RunOverview.data([one, two]);
1221
- expect(ov.totals.attempts).toBe(2);
1222
- expect(ov.warnings).toEqual([]); // missing-startedAt 不透出到组件数据(裁决记录 7)
1223
- });
1224
- });
1225
-
1226
- // ───────────────────────── 格式化 ─────────────────────────
1227
-
1228
- describe("unit 驱动格式化", () => {
1229
- it('"%" / "ms" / "$" / 其余缩写', () => {
1230
- expect(formatMetricValue(0.87, "%")).toBe("87%");
1231
- expect(formatMetricValue(0.875, "%")).toBe("87.5%");
1232
- expect(formatMetricValue(-0.008, "%")).toBe("-0.8%");
1233
- expect(formatMetricValue(850, "ms")).toBe("850ms");
1234
- expect(formatMetricValue(1234, "ms")).toBe("1.2s");
1235
- expect(formatMetricValue(125_000, "ms")).toBe("2m 5s");
1236
- expect(formatMetricValue(0.31, "$")).toBe("$0.31");
1237
- expect(formatMetricValue(0.0042, "$")).toBe("$0.0042");
1238
- expect(formatMetricValue(-0.8, "$")).toBe("-$0.80");
1239
- expect(formatMetricValue(1234, "lines")).toBe("1.2k lines");
1240
- expect(formatMetricValue(3_400_000, "tokens")).toBe("3.4M tokens");
1241
- expect(formatMetricValue(42)).toBe("42");
1242
- });
1243
991
 
1244
- it("metric.display 覆盖内置格式化", async () => {
1245
- const raw = defineMetric({
1246
- name: "raw",
1247
- unit: "%",
1248
- display: (v) => `${v} raw`,
1249
- value: () => 0.5,
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
+ ]);
1250
1006
  });
1251
- const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
1252
- const data = await MetricTable.data([s], { rows: "agent", columns: [raw] });
1253
- expect(data.rows[0].cells["raw"].display).toBe("0.5 raw");
1254
1007
  });
1255
1008
  });
1256
1009
 
1257
- // ───────────────────────── MetricMatrix.data(= MetricBars.data)─────────────────────────
1258
-
1259
- describe("MetricMatrix.data", () => {
1260
- it("稀疏:没有 attempt 的 (row, column) 组合不出格", async () => {
1261
- const a = snap({
1262
- experimentId: "exp/a",
1263
- agent: "a1",
1264
- results: [res("A", "passed", { agent: "a1" }), res("B", "failed", { agent: "a1" })],
1265
- });
1266
- const b = snap({
1267
- experimentId: "exp/b",
1268
- agent: "b1",
1269
- results: [res("A", "failed", { agent: "b1" })], // b1 没跑 B
1270
- });
1271
- const data = await MetricMatrix.data([a, b], { rows: "eval", columns: "agent", cell: taskPassRate });
1272
- expect(data.rows).toBe("eval");
1273
- expect(data.columns).toBe("agent");
1274
- expect(data.metric.key).toBe("task-pass-rate");
1275
- expect(data.cells).toHaveLength(3); // A×a1、B×a1、A×b1;B×b1 不出现
1276
- const find = (row: string, column: string) => data.cells.find((c) => c.row === row && c.column === column);
1277
- expect(find("A", "a1")?.cell.value).toBe(1);
1278
- expect(find("B", "a1")?.cell.value).toBe(0);
1279
- expect(find("A", "b1")?.cell.value).toBe(0);
1280
- expect(find("B", "b1")).toBeUndefined();
1281
- });
1010
+ // ───────────────────────── examScore ─────────────────────────
1282
1011
 
1283
- it("MetricBars.data 与 MetricMatrix.data 同一输入给出相同数据(别名契约,不锁函数引用)", async () => {
1284
- const a = snap({
1285
- experimentId: "exp/a",
1286
- agent: "a1",
1287
- results: [res("A", "passed", { agent: "a1" }), res("B", "failed", { agent: "a1" })],
1288
- });
1289
- const b = snap({
1290
- experimentId: "exp/b",
1291
- agent: "b1",
1292
- results: [res("A", "failed", { agent: "b1" })],
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
+ ],
1293
1021
  });
1294
- const options = { rows: "eval", columns: "agent", cell: taskPassRate } as const;
1295
- const bars = await MetricBars.data([a, b], options);
1296
- const matrix = await MetricMatrix.data([a, b], options);
1297
- expect(bars).toEqual(matrix);
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 分是测得的事实,不是缺数据
1298
1028
  });
1299
1029
  });