niceeval 0.3.0 → 0.4.1

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 (102) hide show
  1. package/README.zh.md +11 -0
  2. package/docs/README.md +2 -1
  3. package/docs/adapters/collection.md +1 -1
  4. package/docs/adapters/contract.md +6 -5
  5. package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
  6. package/docs/assertions.md +0 -1
  7. package/docs/capabilities-by-construction.md +2 -2
  8. package/docs/cli.md +4 -2
  9. package/docs/concepts.md +2 -2
  10. package/docs/e2e-ci.md +135 -98
  11. package/docs/eval-authoring.md +3 -3
  12. package/docs/experiments.md +7 -4
  13. package/docs/observability.md +9 -5
  14. package/docs/origin-integration.md +18 -18
  15. package/docs/references.md +1 -1
  16. package/docs/reports.md +551 -0
  17. package/docs/results-format.md +3 -3
  18. package/docs/results-lib.md +191 -0
  19. package/docs/runner.md +1 -1
  20. package/docs/sandbox.md +1 -1
  21. package/docs/source-map.md +22 -2
  22. package/docs/tier-sync.md +74 -58
  23. package/docs/view.md +39 -5
  24. package/package.json +29 -3
  25. package/src/agents/ai-sdk.ts +3 -0
  26. package/src/agents/claude-code.ts +18 -1
  27. package/src/agents/codex.ts +3 -2
  28. package/src/agents/index.ts +16 -0
  29. package/src/agents/openai-compat.test.ts +56 -0
  30. package/src/agents/openai-compat.ts +151 -0
  31. package/src/agents/types.ts +3 -1
  32. package/src/cli.ts +4 -3
  33. package/src/context/context.test.ts +78 -1
  34. package/src/context/context.ts +19 -11
  35. package/src/context/session.ts +2 -0
  36. package/src/context/types.ts +11 -5
  37. package/src/i18n/en.ts +3 -4
  38. package/src/i18n/zh-CN.ts +2 -3
  39. package/src/o11y/cost.test.ts +40 -0
  40. package/src/o11y/cost.ts +27 -5
  41. package/src/o11y/derive.ts +2 -2
  42. package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
  43. package/src/o11y/otlp/mappers/claude-code.ts +24 -0
  44. package/src/o11y/otlp/mappers/index.ts +1 -0
  45. package/src/o11y/otlp/parse.test.ts +127 -0
  46. package/src/o11y/prices.json +176 -119
  47. package/src/o11y/types.ts +2 -1
  48. package/src/report/aggregate.ts +215 -0
  49. package/src/report/compute.ts +405 -0
  50. package/src/report/format.ts +47 -0
  51. package/src/report/index.ts +40 -0
  52. package/src/report/metrics.ts +96 -0
  53. package/src/report/react/CaseList.tsx +70 -0
  54. package/src/report/react/DeltaTable.tsx +79 -0
  55. package/src/report/react/MetricMatrix.tsx +75 -0
  56. package/src/report/react/MetricScatter.tsx +217 -0
  57. package/src/report/react/MetricTable.tsx +71 -0
  58. package/src/report/react/RunOverview.tsx +87 -0
  59. package/src/report/react/Scoreboard.tsx +87 -0
  60. package/src/report/react/cell.tsx +49 -0
  61. package/src/report/react/colors.ts +42 -0
  62. package/src/report/react/data.ts +17 -0
  63. package/src/report/react/fixtures.ts +236 -0
  64. package/src/report/react/format.ts +34 -0
  65. package/src/report/react/index.tsx +34 -0
  66. package/src/report/react/render.test.tsx +303 -0
  67. package/src/report/react/styles.css +302 -0
  68. package/src/report/report.test.ts +667 -0
  69. package/src/report/types.ts +210 -0
  70. package/src/results/copy.ts +200 -0
  71. package/src/results/format.ts +75 -0
  72. package/src/results/index.ts +27 -0
  73. package/src/results/open.ts +207 -0
  74. package/src/results/results.test.ts +359 -0
  75. package/src/results/select.ts +101 -0
  76. package/src/results/types.ts +98 -0
  77. package/src/runner/attempt.ts +3 -1
  78. package/src/runner/report.test.ts +111 -0
  79. package/src/runner/report.ts +52 -1
  80. package/src/runner/reporters/artifacts.ts +1 -1
  81. package/src/runner/reporters/braintrust.test.ts +106 -0
  82. package/src/runner/reporters/braintrust.ts +197 -0
  83. package/src/runner/reporters/index.ts +3 -1
  84. package/src/runner/run.ts +30 -19
  85. package/src/runner/types.ts +17 -0
  86. package/src/sandbox/types.ts +3 -3
  87. package/src/view/app/App.tsx +82 -17
  88. package/src/view/app/components/CostScoreChart.tsx +127 -0
  89. package/src/view/app/i18n.ts +10 -1
  90. package/src/view/app/lib/attempt-route.test.ts +89 -0
  91. package/src/view/app/lib/attempt-route.ts +52 -0
  92. package/src/view/app/lib/chart.ts +72 -0
  93. package/src/view/app/lib/outcome.ts +1 -1
  94. package/src/view/app/types.ts +4 -5
  95. package/src/view/client-dist/app.css +1 -1
  96. package/src/view/client-dist/app.js +18 -18
  97. package/src/view/index.ts +13 -19
  98. package/src/view/loader.test.ts +61 -0
  99. package/src/view/loader.ts +35 -12
  100. package/src/view/shared/types.ts +14 -1
  101. package/src/view/styles.css +43 -0
  102. package/src/view/template.html +0 -1
@@ -0,0 +1,667 @@
1
+ // niceeval/report 的单元测试:全部用内存 fake handles(SnapshotHandle 按 docs/results-lib.md
2
+ // 的形状手工构造),专门覆盖 docs/reports.md 点名的坑 —— 两级聚合 vs 平铺、pass@k、
3
+ // examScore 空真、skipped 稀释、scoreboard 固定分母与最长前缀、scatter/delta 的 null 语义、
4
+ // cases 的 redact/truncated、身份键去重。
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import type { AssertionResult, EvalResult, ResultOutcome, RunSummary } from "../types.ts";
9
+ import type { AttemptHandle, RunHandle, SnapshotHandle } from "../results/types.ts";
10
+ import type { Dimension } from "./types.ts";
11
+ import { costUSD, defineMetric, durationMs, examScore, passRate, tokens } from "./metrics.ts";
12
+ import { cases, delta, matrix, overview, scatter, scoreboard, table } from "./compute.ts";
13
+ import { formatMetricValue } from "./format.ts";
14
+
15
+ // ───────────────────────── fake handles ─────────────────────────
16
+
17
+ let seq = 0;
18
+
19
+ /** 造一条结果;默认给每条唯一 startedAt —— 身份键含 startedAt,免得普通样本被去重误伤。 */
20
+ function res(id: string, outcome: ResultOutcome, extra: Partial<EvalResult> = {}): EvalResult {
21
+ seq += 1;
22
+ return {
23
+ id,
24
+ agent: "agent-x",
25
+ outcome,
26
+ attempt: 0,
27
+ startedAt: `2026-07-01T00:00:00.${String(seq).padStart(6, "0")}Z`,
28
+ durationMs: 1000,
29
+ assertions: [],
30
+ ...extra,
31
+ };
32
+ }
33
+
34
+ function softAssertion(name: string, score: number, extra: Partial<AssertionResult> = {}): AssertionResult {
35
+ return { name, severity: "soft", score, passed: true, ...extra };
36
+ }
37
+
38
+ function wrap(run: RunHandle, result: EvalResult, index: number): AttemptHandle {
39
+ return {
40
+ run,
41
+ ref: { run: run.dir.split("/").pop()!, result: index },
42
+ result,
43
+ events: async () => null,
44
+ trace: async () => null,
45
+ o11y: async () => null,
46
+ diff: async () => null,
47
+ sources: async () => null,
48
+ };
49
+ }
50
+
51
+ interface SnapSpec {
52
+ experimentId: string;
53
+ results: EvalResult[];
54
+ agent?: string;
55
+ model?: string;
56
+ runStartedAt?: string;
57
+ }
58
+
59
+ let runSeq = 0;
60
+
61
+ /** 最小构造:一个 run 装一个快照。runStartedAt 决定去重时谁是「最新 run」。 */
62
+ function snap(spec: SnapSpec): SnapshotHandle {
63
+ runSeq += 1;
64
+ const startedAt = spec.runStartedAt ?? `2026-06-01T00:00:00.${String(runSeq).padStart(3, "0")}Z`;
65
+ const summary: RunSummary = {
66
+ agent: spec.agent ?? "agent-x",
67
+ startedAt,
68
+ completedAt: startedAt,
69
+ passed: 0,
70
+ failed: 0,
71
+ skipped: 0,
72
+ errored: 0,
73
+ durationMs: 0,
74
+ results: spec.results,
75
+ };
76
+ const run: RunHandle = { dir: `/results/run-${runSeq}`, summary, attempts: [] };
77
+ run.attempts = spec.results.map((r, i) => wrap(run, r, i));
78
+ const attempts = run.attempts;
79
+ return {
80
+ experimentId: spec.experimentId,
81
+ run,
82
+ startedAt,
83
+ agent: spec.agent ?? "agent-x",
84
+ model: spec.model,
85
+ attempts,
86
+ evalIds: [...new Set(spec.results.map((r) => r.id))],
87
+ };
88
+ }
89
+
90
+ // ───────────────────────── 两级聚合 ─────────────────────────
91
+
92
+ describe("两级聚合引擎", () => {
93
+ it("题内先折再跨题平均:A=[1]、B=[0,0,0] → 0.5,不是平铺的 0.25", async () => {
94
+ const s = snap({
95
+ experimentId: "exp/x",
96
+ results: [
97
+ res("A", "passed"),
98
+ res("B", "failed"),
99
+ res("B", "failed"),
100
+ res("B", "failed"),
101
+ ],
102
+ });
103
+ const data = await table([s], { rows: "agent", columns: [passRate] });
104
+ expect(data.dimension).toBe("agent");
105
+ expect(data.rows).toHaveLength(1);
106
+ const cell = data.rows[0].cells["pass-rate"];
107
+ expect(cell.value).toBe(0.5);
108
+ expect(cell.display).toBe("50%");
109
+ expect(cell.samples).toBe(4);
110
+ expect(cell.total).toBe(4);
111
+ expect(cell.refs).toHaveLength(4);
112
+ });
113
+
114
+ it("pass@k = perEval:max —— k 次里过一次的题算过", async () => {
115
+ const passAtK = defineMetric({
116
+ name: "pass@k",
117
+ better: "higher",
118
+ unit: "%",
119
+ value: (a) =>
120
+ a.result.outcome === "skipped" ? null : a.result.outcome === "passed" ? 1 : 0,
121
+ aggregate: { perEval: "max", across: "mean" },
122
+ });
123
+ const s = snap({
124
+ experimentId: "exp/x",
125
+ results: [
126
+ res("A", "failed"),
127
+ res("A", "failed"),
128
+ res("B", "failed"),
129
+ res("B", "passed"),
130
+ ],
131
+ });
132
+ const data = await table([s], { rows: "agent", columns: [passAtK, passRate] });
133
+ // A: max(0,0)=0;B: max(0,1)=1 → (0+1)/2
134
+ expect(data.rows[0].cells["pass@k"].value).toBe(0.5);
135
+ // 对照:默认 mean/mean 的 passRate = (0 + 0.5)/2
136
+ expect(data.rows[0].cells["pass-rate"].value).toBe(0.25);
137
+ });
138
+
139
+ it("skipped 是 null:不稀释均值,但计入 total(覆盖率如实)", async () => {
140
+ const s = snap({
141
+ experimentId: "exp/x",
142
+ results: [res("A", "passed"), res("B", "skipped")],
143
+ });
144
+ const data = await table([s], { rows: "agent", columns: [passRate] });
145
+ const cell = data.rows[0].cells["pass-rate"];
146
+ expect(cell.value).toBe(1); // B 整桶为 null,不参与 across,不是 0.5
147
+ expect(cell.samples).toBe(1);
148
+ expect(cell.total).toBe(2);
149
+ expect(cell.refs).toHaveLength(1);
150
+ });
151
+
152
+ it("全组 null → value null、display 兜底,不编 0", async () => {
153
+ const s = snap({ experimentId: "exp/x", results: [res("A", "skipped")] });
154
+ const data = await table([s], { rows: "agent", columns: [passRate] });
155
+ const cell = data.rows[0].cells["pass-rate"];
156
+ expect(cell.value).toBeNull();
157
+ expect(cell.display).toBe("—");
158
+ expect(cell.samples).toBe(0);
159
+ expect(cell.total).toBe(1);
160
+ });
161
+
162
+ it("where 不满足 → null,不进聚合", async () => {
163
+ const onlyPassed = defineMetric({
164
+ name: "only-passed",
165
+ where: (a) => a.result.outcome === "passed",
166
+ value: () => 5,
167
+ });
168
+ const s = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
169
+ const data = await table([s], { rows: "agent", columns: [onlyPassed] });
170
+ const cell = data.rows[0].cells["only-passed"];
171
+ expect(cell.value).toBe(5);
172
+ expect(cell.samples).toBe(1);
173
+ expect(cell.total).toBe(2);
174
+ });
175
+
176
+ it("自定义维度:第一级折叠发生在各组内部", async () => {
177
+ const byParity: Dimension = {
178
+ name: "parity",
179
+ of: (a) => (a.result.attempt % 2 === 0 ? "even" : "odd"),
180
+ };
181
+ const s = snap({
182
+ experimentId: "exp/x",
183
+ results: [
184
+ res("A", "passed", { attempt: 0 }),
185
+ res("A", "failed", { attempt: 1 }),
186
+ res("A", "passed", { attempt: 2 }),
187
+ ],
188
+ });
189
+ const data = await table([s], { rows: byParity, columns: [passRate] });
190
+ expect(data.dimension).toBe("parity");
191
+ const byKey = Object.fromEntries(data.rows.map((r) => [r.key, r.cells["pass-rate"].value]));
192
+ // 同一道题的 attempt 分进两组:even 组内 [1,1] 折成 1,odd 组内 [0] 折成 0
193
+ // (若第一级在分组前全局做,两组都会是 2/3)
194
+ expect(byKey).toEqual({ even: 1, odd: 0 });
195
+ });
196
+
197
+ it("同一次计算里指标重名是错误", async () => {
198
+ const dup = defineMetric({ name: "pass-rate", value: () => 1 });
199
+ const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
200
+ await expect(table([s], { rows: "agent", columns: [passRate, dup] })).rejects.toThrow(
201
+ /Duplicate metric name "pass-rate"/,
202
+ );
203
+ });
204
+
205
+ it("sort 方向随 better,缺数据行沉底", async () => {
206
+ const good = snap({
207
+ experimentId: "exp/good",
208
+ agent: "good",
209
+ results: [res("A", "passed", { agent: "good" })],
210
+ });
211
+ const bad = snap({
212
+ experimentId: "exp/bad",
213
+ agent: "bad",
214
+ results: [res("A", "failed", { agent: "bad" })],
215
+ });
216
+ const none = snap({
217
+ experimentId: "exp/none",
218
+ agent: "none",
219
+ results: [res("A", "skipped", { agent: "none" })],
220
+ });
221
+ const data = await table([none, bad, good], {
222
+ rows: "agent",
223
+ columns: [passRate],
224
+ sort: passRate,
225
+ });
226
+ expect(data.rows.map((r) => r.key)).toEqual(["good", "bad", "none"]);
227
+ });
228
+ });
229
+
230
+ // ───────────────────────── examScore ─────────────────────────
231
+
232
+ describe("examScore", () => {
233
+ it("errored(断言空数组)得 0 —— 不因「gate 全过」空真得满分", async () => {
234
+ const s = snap({
235
+ experimentId: "exp/x",
236
+ results: [res("A", "errored", { assertions: [], error: "adapter crashed" })],
237
+ });
238
+ const data = await table([s], { rows: "agent", columns: [examScore] });
239
+ const cell = data.rows[0].cells["exam-score"];
240
+ expect(cell.value).toBe(0); // 交白卷是 0 分,不是缺数据,更不是满分
241
+ expect(cell.samples).toBe(1);
242
+ });
243
+
244
+ it("failed 得 0,哪怕 soft 分不低(报告不重新判卷)", async () => {
245
+ const s = snap({
246
+ experimentId: "exp/x",
247
+ results: [
248
+ res("A", "failed", { assertions: [softAssertion("judge", 0.9)] }),
249
+ ],
250
+ });
251
+ const data = await table([s], { rows: "agent", columns: [examScore] });
252
+ expect(data.rows[0].cells["exam-score"].value).toBe(0);
253
+ });
254
+
255
+ it("passed:soft 均分;gate 不参与给分;无 soft 则满分 1", async () => {
256
+ const withSoft = snap({
257
+ experimentId: "exp/a",
258
+ results: [
259
+ res("A", "passed", {
260
+ assertions: [
261
+ softAssertion("judge-1", 0.5),
262
+ softAssertion("judge-2", 1),
263
+ { name: "includes", severity: "gate", score: 1, passed: true },
264
+ ],
265
+ }),
266
+ ],
267
+ });
268
+ const noSoft = snap({
269
+ experimentId: "exp/b",
270
+ results: [
271
+ res("B", "passed", {
272
+ assertions: [{ name: "includes", severity: "gate", score: 1, passed: true }],
273
+ }),
274
+ ],
275
+ });
276
+ const a = await table([withSoft], { rows: "agent", columns: [examScore] });
277
+ expect(a.rows[0].cells["exam-score"].value).toBe(0.75);
278
+ const b = await table([noSoft], { rows: "agent", columns: [examScore] });
279
+ expect(b.rows[0].cells["exam-score"].value).toBe(1);
280
+ });
281
+
282
+ it("skipped → null,不进聚合", async () => {
283
+ const s = snap({ experimentId: "exp/x", results: [res("A", "skipped")] });
284
+ const data = await table([s], { rows: "agent", columns: [examScore] });
285
+ expect(data.rows[0].cells["exam-score"].value).toBeNull();
286
+ });
287
+ });
288
+
289
+ // ───────────────────────── 内置指标口径 ─────────────────────────
290
+
291
+ describe("内置指标", () => {
292
+ it("tokens 只加 input+output,缓存读写不计入;无 usage → null", async () => {
293
+ const s = snap({
294
+ experimentId: "exp/x",
295
+ results: [
296
+ res("A", "passed", {
297
+ usage: { inputTokens: 1000, outputTokens: 200, cacheReadTokens: 999_999, cacheWriteTokens: 888 },
298
+ }),
299
+ res("B", "failed"), // 无 usage → null,不稀释
300
+ ],
301
+ });
302
+ const data = await table([s], { rows: "agent", columns: [tokens] });
303
+ const cell = data.rows[0].cells["tokens"];
304
+ expect(cell.value).toBe(1200);
305
+ expect(cell.display).toBe("1.2k tokens");
306
+ expect(cell.samples).toBe(1);
307
+ expect(cell.total).toBe(2);
308
+ });
309
+
310
+ it("costUSD:网关实测优先于估算;durationMs 对 errored 取实测", async () => {
311
+ const s = snap({
312
+ experimentId: "exp/x",
313
+ results: [
314
+ res("A", "passed", {
315
+ usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.5 },
316
+ estimatedCostUSD: 999, // 实测优先,不该被用到
317
+ }),
318
+ res("B", "errored", { durationMs: 3000 }),
319
+ ],
320
+ });
321
+ const cost = await table([s], { rows: "agent", columns: [costUSD] });
322
+ expect(cost.rows[0].cells["cost"].value).toBe(0.5);
323
+ const dur = await table([s], { rows: "agent", columns: [durationMs] });
324
+ expect(dur.rows[0].cells["duration"].value).toBe(2000); // (1000 + 3000)/2,errored 实测照算
325
+ });
326
+ });
327
+
328
+ // ───────────────────────── scoreboard ─────────────────────────
329
+
330
+ describe("scoreboard", () => {
331
+ it("固定分母 + missing 如实 + 权重最长前缀生效", async () => {
332
+ const alpha = snap({
333
+ experimentId: "exp/alpha",
334
+ agent: "alpha",
335
+ results: [
336
+ res("algebra/x", "passed", { agent: "alpha" }),
337
+ res("algebra/hard/y", "passed", { agent: "alpha" }),
338
+ res("geometry/z", "passed", { agent: "alpha" }),
339
+ ],
340
+ });
341
+ const beta = snap({
342
+ experimentId: "exp/beta",
343
+ agent: "beta",
344
+ results: [res("algebra/x", "passed", { agent: "beta" })],
345
+ });
346
+ const board = await scoreboard([alpha, beta], {
347
+ of: "agent",
348
+ subjects: "evalGroup",
349
+ weights: { "algebra/": 3, "algebra/hard/": 9 },
350
+ fullMarks: 100,
351
+ });
352
+ expect(board.of).toBe("agent");
353
+ // 生效权重表可审计:最长前缀在前(匹配顺序)
354
+ expect(board.weights).toEqual([
355
+ { prefix: "algebra/hard/", weight: 9 },
356
+ { prefix: "algebra/", weight: 3 },
357
+ ]);
358
+
359
+ const alphaRow = board.rows.find((r) => r.key === "alpha")!;
360
+ const betaRow = board.rows.find((r) => r.key === "beta")!;
361
+
362
+ // Σ分值 = 3(algebra/x)+ 9(algebra/hard/y,最长前缀 9 不是 3)+ 1(geometry/z,默认)= 13
363
+ expect(alphaRow.total.value).toBeCloseTo(100);
364
+ // beta 只答了 algebra/x:总分 = 100 × 3/13 ≈ 23.1。
365
+ // 若最长前缀错配成 "algebra/" 的 3,分母变 7,会得 100 × 3/7 ≈ 42.9 —— 钉死歧义。
366
+ expect(betaRow.total.value).toBeCloseTo((100 * 3) / 13);
367
+
368
+ // 固定分母:beta 没跑的题留在分母,missing 如实报在科目行
369
+ const betaAlgebra = betaRow.subjects.find((s) => s.key === "algebra")!;
370
+ expect(betaAlgebra.possible).toBe(12); // 3 + 9
371
+ expect(betaAlgebra.earned).toBe(3);
372
+ expect(betaAlgebra.evals).toBe(2);
373
+ expect(betaAlgebra.missing).toBe(1);
374
+ const betaGeometry = betaRow.subjects.find((s) => s.key === "geometry")!;
375
+ expect(betaGeometry.possible).toBe(1);
376
+ expect(betaGeometry.earned).toBe(0);
377
+ expect(betaGeometry.missing).toBe(1);
378
+
379
+ const alphaAlgebra = alphaRow.subjects.find((s) => s.key === "algebra")!;
380
+ expect(alphaAlgebra.earned).toBe(12);
381
+ expect(alphaAlgebra.missing).toBe(0);
382
+ });
383
+
384
+ it("默认 score 是 examScore:soft 分进总分;skipped 算 missing 而非 0 分入账", async () => {
385
+ const solo = snap({
386
+ experimentId: "exp/solo",
387
+ agent: "solo",
388
+ results: [
389
+ res("algebra/x", "passed", { agent: "solo", assertions: [softAssertion("judge", 0.5)] }),
390
+ res("algebra/y", "skipped", { agent: "solo" }), // 无有效样本 → missing(按 0 计但如实标注)
391
+ ],
392
+ });
393
+ const board = await scoreboard([solo], { of: "agent" });
394
+ const row = board.rows[0];
395
+ // 两题各 1 分:0.5 + 0(missing)→ 100 × 0.5/2 = 25
396
+ expect(row.total.value).toBeCloseTo(25);
397
+ expect(row.subjects[0].missing).toBe(1);
398
+ expect(row.subjects[0].evals).toBe(2);
399
+ });
400
+ });
401
+
402
+ // ───────────────────────── scatter ─────────────────────────
403
+
404
+ describe("scatter", () => {
405
+ it("任一轴 null 的点仍在 rows 里、可数;series 随组解析", async () => {
406
+ const withCost = snap({
407
+ experimentId: "exp/a",
408
+ agent: "a1",
409
+ results: [
410
+ res("A", "passed", { agent: "a1", usage: { inputTokens: 10, outputTokens: 5, costUSD: 0.5 } }),
411
+ ],
412
+ });
413
+ const noCost = snap({
414
+ experimentId: "exp/b",
415
+ agent: "b1",
416
+ results: [res("A", "passed", { agent: "b1" })],
417
+ });
418
+ const data = await scatter([withCost, noCost], {
419
+ points: "experiment",
420
+ series: "agent",
421
+ x: costUSD,
422
+ y: passRate,
423
+ });
424
+ expect(data.points).toBe("experiment");
425
+ expect(data.series).toBe("agent");
426
+ expect(data.rows).toHaveLength(2);
427
+
428
+ const a = data.rows.find((r) => r.key === "exp/a")!;
429
+ expect(a.series).toBe("a1");
430
+ expect(a.x.value).toBe(0.5);
431
+ expect(a.y.value).toBe(1);
432
+
433
+ const b = data.rows.find((r) => r.key === "exp/b")!;
434
+ expect(b.x.value).toBeNull(); // 没有成本数据:点在,值缺
435
+ expect(b.y.value).toBe(1);
436
+ // 注脚「n 个点缺数据」就从 rows 里数出来,不需要另一份数据
437
+ expect(data.rows.filter((r) => r.x.value === null || r.y.value === null)).toHaveLength(1);
438
+ });
439
+ });
440
+
441
+ // ───────────────────────── overview ─────────────────────────
442
+
443
+ describe("overview", () => {
444
+ it("costUSD 全缺为 null 不编 0;有实测/估算则求和;warnings 透传", async () => {
445
+ const bare = snap({ experimentId: "exp/x", results: [res("A", "passed"), res("B", "failed")] });
446
+ const bareOverview = await overview([bare]);
447
+ expect(bareOverview.totals.costUSD).toBeNull();
448
+ expect(bareOverview.totals.attempts).toBe(2);
449
+ expect(bareOverview.totals.passed).toBe(1);
450
+ expect(bareOverview.totals.failed).toBe(1);
451
+ expect(bareOverview.warnings).toEqual([]);
452
+
453
+ const priced = snap({
454
+ experimentId: "exp/y",
455
+ results: [
456
+ res("A", "passed", { estimatedCostUSD: 0.1 }),
457
+ res("B", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } }),
458
+ ],
459
+ });
460
+ const data = await overview([priced], { warnings: ["snapshot covers 1 of 50 evals"] });
461
+ expect(data.totals.costUSD).toBeCloseTo(0.3);
462
+ expect(data.totals.evals).toBe(2);
463
+ expect(data.warnings).toEqual(["snapshot covers 1 of 50 evals"]);
464
+ expect(data.snapshots).toEqual([
465
+ { experimentId: "exp/y", agent: "agent-x", model: undefined, startedAt: priced.startedAt },
466
+ ]);
467
+ });
468
+ });
469
+
470
+ // ───────────────────────── delta ─────────────────────────
471
+
472
+ describe("delta", () => {
473
+ it("任一侧 null → delta null 不硬算;双侧有值给带符号 display", async () => {
474
+ const base = snap({
475
+ experimentId: "exp/base",
476
+ results: [
477
+ res("A", "failed"),
478
+ res("B", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } }),
479
+ ],
480
+ });
481
+ const plus = snap({
482
+ experimentId: "exp/plus",
483
+ results: [res("A", "passed"), res("B", "passed")], // 无任何成本数据
484
+ });
485
+ const data = await delta([base, plus], {
486
+ pairs: [{ a: "exp/base", b: "exp/plus", label: "memory" }],
487
+ metrics: [passRate, costUSD],
488
+ });
489
+ expect(data.rows).toHaveLength(1);
490
+ const row = data.rows[0];
491
+ expect(row.key).toBe("memory");
492
+ expect(row.a).toEqual({ experimentId: "exp/base" });
493
+ expect(row.b).toEqual({ experimentId: "exp/plus" });
494
+
495
+ const pass = row.cells["pass-rate"];
496
+ expect(pass.a.value).toBe(0.5);
497
+ expect(pass.b.value).toBe(1);
498
+ expect(pass.delta).toBeCloseTo(0.5);
499
+ expect(pass.display).toBe("+50%");
500
+
501
+ const cost = row.cells["cost"];
502
+ expect(cost.a.value).toBeCloseTo(0.2);
503
+ expect(cost.b.value).toBeNull();
504
+ expect(cost.delta).toBeNull(); // 单侧缺数据:不硬算
505
+ expect(cost.display).toBe("—");
506
+ });
507
+ });
508
+
509
+ // ───────────────────────── cases ─────────────────────────
510
+
511
+ describe("cases", () => {
512
+ it("默认只列 failed+errored;redact 作用于 error/detail/evidence;truncated 如实", async () => {
513
+ const s = snap({
514
+ experimentId: "exp/x",
515
+ results: [
516
+ res("A", "failed", {
517
+ assertions: [
518
+ {
519
+ name: "includes",
520
+ severity: "gate",
521
+ score: 0,
522
+ passed: false,
523
+ detail: "missing text under /Users/me/repo/src",
524
+ evidence: "checked /Users/me/repo/src/app.ts",
525
+ },
526
+ { name: "ok", severity: "gate", score: 1, passed: true }, // 通过的断言不列
527
+ ],
528
+ }),
529
+ res("B", "errored", { error: "ENOENT /Users/me/repo/tool" }),
530
+ res("C", "failed"),
531
+ res("D", "passed"),
532
+ res("E", "skipped"),
533
+ ],
534
+ });
535
+ const data = await cases([s], {
536
+ limit: 2,
537
+ redact: (text) => text.replaceAll("/Users/me/repo", "<repo>"),
538
+ });
539
+ expect(data.rows).toHaveLength(2);
540
+ expect(data.truncated).toBe(1); // C 被截;D/E 本就不在默认 outcomes 里
541
+
542
+ const [first, second] = data.rows;
543
+ expect(first.eval).toBe("A");
544
+ expect(first.outcome).toBe("failed");
545
+ expect(first.failedAssertions).toHaveLength(1);
546
+ expect(first.failedAssertions[0].detail).toBe("missing text under <repo>/src");
547
+ expect(first.failedAssertions[0].evidence).toBe("checked <repo>/src/app.ts");
548
+ expect(first.ref).toEqual({ run: s.run.dir.split("/").pop(), result: 0 });
549
+
550
+ expect(second.eval).toBe("B");
551
+ expect(second.outcome).toBe("errored");
552
+ expect(second.error).toBe("ENOENT <repo>/tool");
553
+ expect(second.ref.result).toBe(1);
554
+ });
555
+
556
+ it("outcomes 可收窄;不传 limit 不截断", async () => {
557
+ const s = snap({
558
+ experimentId: "exp/x",
559
+ results: [res("A", "failed"), res("B", "errored")],
560
+ });
561
+ const onlyErrored = await cases([s], { outcomes: ["errored"] });
562
+ expect(onlyErrored.rows.map((r) => r.eval)).toEqual(["B"]);
563
+ expect(onlyErrored.truncated).toBe(0);
564
+ });
565
+ });
566
+
567
+ // ───────────────────────── 身份键去重 ─────────────────────────
568
+
569
+ describe("身份键去重", () => {
570
+ it("同 (experimentId, evalId, attempt, startedAt) 两份 → 保留最新 run 的那份", async () => {
571
+ const identity = {
572
+ experimentId: "exp/x",
573
+ attempt: 0,
574
+ startedAt: "2026-07-01T08:00:00Z",
575
+ };
576
+ // --resume 场景:旧 run 里 failed,新 run 合入同身份键的 passed
577
+ const older = snap({
578
+ experimentId: "exp/x",
579
+ runStartedAt: "2026-07-01T08:00:00Z",
580
+ results: [res("A", "failed", identity)],
581
+ });
582
+ const newer = snap({
583
+ experimentId: "exp/x",
584
+ runStartedAt: "2026-07-02T08:00:00Z",
585
+ results: [res("A", "passed", identity)],
586
+ });
587
+
588
+ for (const order of [[older, newer], [newer, older]]) {
589
+ const data = await table(order, { rows: "agent", columns: [passRate] });
590
+ const cell = data.rows[0].cells["pass-rate"];
591
+ expect(cell.total).toBe(1); // 两份只算一份
592
+ expect(cell.value).toBe(1); // 留的是最新 run 里的 passed,与快照传入顺序无关
593
+
594
+ const ov = await overview(order);
595
+ expect(ov.totals.attempts).toBe(1);
596
+ expect(ov.totals.passed).toBe(1);
597
+ expect(ov.totals.failed).toBe(0);
598
+ }
599
+ });
600
+
601
+ it("startedAt 缺失:宁可不去重也不误删", async () => {
602
+ const identity = { experimentId: "exp/x", attempt: 0, startedAt: undefined };
603
+ const one = snap({ experimentId: "exp/x", results: [res("A", "passed", identity)] });
604
+ const two = snap({ experimentId: "exp/x", results: [res("A", "passed", identity)] });
605
+ const ov = await overview([one, two]);
606
+ expect(ov.totals.attempts).toBe(2);
607
+ });
608
+ });
609
+
610
+ // ───────────────────────── 格式化 ─────────────────────────
611
+
612
+ describe("unit 驱动格式化", () => {
613
+ it('"%" / "ms" / "$" / 其余缩写', () => {
614
+ expect(formatMetricValue(0.87, "%")).toBe("87%");
615
+ expect(formatMetricValue(0.875, "%")).toBe("87.5%");
616
+ expect(formatMetricValue(-0.008, "%")).toBe("-0.8%");
617
+ expect(formatMetricValue(850, "ms")).toBe("850ms");
618
+ expect(formatMetricValue(1234, "ms")).toBe("1.2s");
619
+ expect(formatMetricValue(125_000, "ms")).toBe("2m 5s");
620
+ expect(formatMetricValue(0.31, "$")).toBe("$0.31");
621
+ expect(formatMetricValue(0.0042, "$")).toBe("$0.0042");
622
+ expect(formatMetricValue(-0.8, "$")).toBe("-$0.80");
623
+ expect(formatMetricValue(1234, "lines")).toBe("1.2k lines");
624
+ expect(formatMetricValue(3_400_000, "tokens")).toBe("3.4M tokens");
625
+ expect(formatMetricValue(42)).toBe("42");
626
+ });
627
+
628
+ it("metric.display 覆盖内置格式化", async () => {
629
+ const raw = defineMetric({
630
+ name: "raw",
631
+ unit: "%",
632
+ display: (v) => `${v} raw`,
633
+ value: () => 0.5,
634
+ });
635
+ const s = snap({ experimentId: "exp/x", results: [res("A", "passed")] });
636
+ const data = await table([s], { rows: "agent", columns: [raw] });
637
+ expect(data.rows[0].cells["raw"].display).toBe("0.5 raw");
638
+ });
639
+ });
640
+
641
+ // ───────────────────────── matrix ─────────────────────────
642
+
643
+ describe("matrix", () => {
644
+ it("稀疏:没有 attempt 的 (row, column) 组合不出格", async () => {
645
+ const a = snap({
646
+ experimentId: "exp/a",
647
+ agent: "a1",
648
+ results: [res("A", "passed", { agent: "a1" }), res("B", "failed", { agent: "a1" })],
649
+ });
650
+ const b = snap({
651
+ experimentId: "exp/b",
652
+ agent: "b1",
653
+ results: [res("A", "failed", { agent: "b1" })], // b1 没跑 B
654
+ });
655
+ const data = await matrix([a, b], { rows: "eval", columns: "agent", cell: passRate });
656
+ expect(data.rows).toBe("eval");
657
+ expect(data.columns).toBe("agent");
658
+ expect(data.metric.key).toBe("pass-rate");
659
+ expect(data.cells).toHaveLength(3); // A×a1、B×a1、A×b1;B×b1 不出现
660
+ const find = (row: string, column: string) =>
661
+ data.cells.find((c) => c.row === row && c.column === column);
662
+ expect(find("A", "a1")?.cell.value).toBe(1);
663
+ expect(find("B", "a1")?.cell.value).toBe(0);
664
+ expect(find("A", "b1")?.cell.value).toBe(0);
665
+ expect(find("B", "b1")).toBeUndefined();
666
+ });
667
+ });