niceeval 0.2.1 → 0.4.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 (113) hide show
  1. package/README.md +34 -34
  2. package/README.zh.md +29 -15
  3. package/docs/README.md +2 -1
  4. package/docs/adapters/collection.md +1 -1
  5. package/docs/adapters/contract.md +6 -5
  6. package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
  7. package/docs/architecture.md +20 -20
  8. package/docs/assertions.md +0 -1
  9. package/docs/capabilities-by-construction.md +2 -2
  10. package/docs/cli.md +2 -2
  11. package/docs/concepts.md +2 -2
  12. package/docs/e2e-ci.md +135 -98
  13. package/docs/eval-authoring.md +3 -3
  14. package/docs/experiments.md +7 -4
  15. package/docs/observability.md +9 -5
  16. package/docs/origin-integration.md +18 -18
  17. package/docs/references.md +1 -1
  18. package/docs/reports.md +551 -0
  19. package/docs/results-format.md +3 -3
  20. package/docs/results-lib.md +191 -0
  21. package/docs/runner.md +1 -1
  22. package/docs/sandbox.md +1 -1
  23. package/docs/source-map.md +22 -2
  24. package/docs/tier-sync.md +74 -58
  25. package/docs/view.md +41 -4
  26. package/package.json +29 -3
  27. package/src/agents/ai-sdk.ts +3 -0
  28. package/src/agents/claude-code.ts +18 -1
  29. package/src/agents/codex.ts +3 -2
  30. package/src/agents/index.ts +16 -0
  31. package/src/agents/openai-compat.test.ts +56 -0
  32. package/src/agents/openai-compat.ts +151 -0
  33. package/src/agents/types.ts +3 -1
  34. package/src/cli.ts +2 -1
  35. package/src/context/context.test.ts +78 -1
  36. package/src/context/context.ts +19 -11
  37. package/src/context/session.ts +2 -0
  38. package/src/context/types.ts +11 -5
  39. package/src/i18n/en.ts +8 -4
  40. package/src/i18n/zh-CN.ts +6 -3
  41. package/src/o11y/cost.test.ts +40 -0
  42. package/src/o11y/cost.ts +27 -5
  43. package/src/o11y/derive.ts +2 -2
  44. package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
  45. package/src/o11y/otlp/mappers/claude-code.ts +24 -0
  46. package/src/o11y/otlp/mappers/index.ts +1 -0
  47. package/src/o11y/otlp/parse.test.ts +127 -0
  48. package/src/o11y/prices.json +176 -119
  49. package/src/o11y/types.ts +2 -1
  50. package/src/report/aggregate.ts +215 -0
  51. package/src/report/compute.ts +405 -0
  52. package/src/report/format.ts +47 -0
  53. package/src/report/index.ts +40 -0
  54. package/src/report/metrics.ts +96 -0
  55. package/src/report/react/CaseList.tsx +70 -0
  56. package/src/report/react/DeltaTable.tsx +79 -0
  57. package/src/report/react/MetricMatrix.tsx +75 -0
  58. package/src/report/react/MetricScatter.tsx +217 -0
  59. package/src/report/react/MetricTable.tsx +71 -0
  60. package/src/report/react/RunOverview.tsx +87 -0
  61. package/src/report/react/Scoreboard.tsx +87 -0
  62. package/src/report/react/cell.tsx +49 -0
  63. package/src/report/react/colors.ts +42 -0
  64. package/src/report/react/data.ts +17 -0
  65. package/src/report/react/fixtures.ts +236 -0
  66. package/src/report/react/format.ts +34 -0
  67. package/src/report/react/index.tsx +34 -0
  68. package/src/report/react/render.test.tsx +303 -0
  69. package/src/report/react/styles.css +302 -0
  70. package/src/report/report.test.ts +667 -0
  71. package/src/report/types.ts +210 -0
  72. package/src/results/copy.ts +200 -0
  73. package/src/results/format.ts +75 -0
  74. package/src/results/index.ts +27 -0
  75. package/src/results/open.ts +207 -0
  76. package/src/results/results.test.ts +359 -0
  77. package/src/results/select.ts +101 -0
  78. package/src/results/types.ts +98 -0
  79. package/src/runner/attempt.ts +3 -1
  80. package/src/runner/report.test.ts +111 -0
  81. package/src/runner/report.ts +52 -1
  82. package/src/runner/reporters/artifacts.ts +1 -1
  83. package/src/runner/reporters/braintrust.test.ts +106 -0
  84. package/src/runner/reporters/braintrust.ts +197 -0
  85. package/src/runner/reporters/console.ts +3 -3
  86. package/src/runner/reporters/index.ts +3 -1
  87. package/src/runner/reporters/live.ts +17 -5
  88. package/src/runner/reporters/shared.ts +3 -0
  89. package/src/runner/reporters/table.ts +1 -0
  90. package/src/runner/run.ts +31 -18
  91. package/src/runner/types.ts +20 -1
  92. package/src/sandbox/types.ts +3 -3
  93. package/src/shared/format.ts +8 -2
  94. package/src/view/app/App.tsx +82 -17
  95. package/src/view/app/components/AttemptModal.tsx +9 -2
  96. package/src/view/app/components/CodeView.tsx +2 -2
  97. package/src/view/app/components/CostScoreChart.tsx +127 -0
  98. package/src/view/app/components/LazyArtifact.tsx +2 -1
  99. package/src/view/app/i18n.ts +13 -1
  100. package/src/view/app/lib/artifact-url.ts +6 -0
  101. package/src/view/app/lib/attempt-route.test.ts +89 -0
  102. package/src/view/app/lib/attempt-route.ts +52 -0
  103. package/src/view/app/lib/chart.ts +72 -0
  104. package/src/view/app/lib/outcome.ts +1 -1
  105. package/src/view/app/types.ts +4 -5
  106. package/src/view/client-dist/app.css +1 -1
  107. package/src/view/client-dist/app.js +18 -18
  108. package/src/view/index.ts +36 -7
  109. package/src/view/loader.ts +14 -7
  110. package/src/view/server.ts +7 -0
  111. package/src/view/shared/types.ts +14 -1
  112. package/src/view/styles.css +43 -0
  113. package/src/view/template.html +0 -1
@@ -0,0 +1,215 @@
1
+ // 两级聚合引擎:去重 → 按维度分组 → 组内按 (eval × 快照) 折叠(perEval)→ 跨题折叠(across)→ MetricCell。
2
+ //
3
+ // 为什么是两级:earlyExit 默认开,失败的题天然比通过的题样本多;平铺求均值会把分数
4
+ // 和重试策略纠缠在一起(eval A=[1]、eval B=[0,0,0] 平铺 = 0.25,两级宏平均 = 0.5)。
5
+ // 自定义维度把同一道题的 attempt 分进不同组时,第一级折叠发生在各组内部。
6
+
7
+ import type { EvalResult } from "../types.ts";
8
+ import type { AttemptHandle, SnapshotHandle } from "../results/types.ts";
9
+ import type {
10
+ Aggregator,
11
+ AttemptRef,
12
+ Dimension,
13
+ Metric,
14
+ MetricCell,
15
+ MetricColumn,
16
+ } from "./types.ts";
17
+ import { formatMetricValue } from "./format.ts";
18
+
19
+ // 复合键分隔符:NUL 不会出现在 eval id / experimentId / ISO 时间里,拼接键不会串味
20
+ const KEY_SEP = "\u0000";
21
+
22
+ /** 展平后的一条样本:attempt + 它所属的快照(维度解析与题级折叠都需要快照身份)。 */
23
+ export interface Item {
24
+ snapshot: SnapshotHandle;
25
+ attempt: AttemptHandle;
26
+ }
27
+
28
+ export function experimentIdOf(item: Item): string {
29
+ return item.attempt.result.experimentId ?? item.snapshot.experimentId;
30
+ }
31
+
32
+ /** 快照身份:与 view Compare 同口径的 (experimentId, startedAt)。 */
33
+ function snapshotIdentity(snapshot: SnapshotHandle): string {
34
+ return `${snapshot.experimentId}${KEY_SEP}${snapshot.startedAt}`;
35
+ }
36
+
37
+ /** 身份键 (experimentId, evalId, attempt, startedAt);startedAt 缺失 → null(宁可不去重也不误删)。 */
38
+ function identityKey(snapshot: SnapshotHandle, result: EvalResult): string | null {
39
+ if (!result.startedAt) return null;
40
+ return [
41
+ result.experimentId ?? snapshot.experimentId,
42
+ result.id,
43
+ result.attempt,
44
+ result.startedAt,
45
+ ].join(KEY_SEP);
46
+ }
47
+
48
+ /**
49
+ * 跨快照聚合前按身份键去重:--resume 会把上一轮已通过的结果原样合入新 run 的 summary,
50
+ * 同一 attempt 因此存在于多份落盘。重复时保留最新 run(按 run.summary.startedAt)里的那份。
51
+ *
52
+ * TODO(results-lib):这是「去重是消费方义务」的本地实现(docs/results-lib.md「身份键与去重」);
53
+ * writer 给合入结果打标之后,按 niceeval/results 的规则收编,本函数随 handles.ts 一起换掉。
54
+ */
55
+ export function dedupeAttempts(snapshots: SnapshotHandle[]): Item[] {
56
+ // 第一遍:按身份键选赢家(run.startedAt 是 ISO 字符串,字典序即时间序;并列保留后出现的)。
57
+ const winner = new Map<string, { attempt: AttemptHandle; runStartedAt: string }>();
58
+ for (const snapshot of snapshots) {
59
+ for (const attempt of snapshot.attempts) {
60
+ const key = identityKey(snapshot, attempt.result);
61
+ if (key === null) continue;
62
+ const runStartedAt = snapshot.run.summary.startedAt ?? "";
63
+ const prev = winner.get(key);
64
+ if (!prev || runStartedAt >= prev.runStartedAt) winner.set(key, { attempt, runStartedAt });
65
+ }
66
+ }
67
+ // 第二遍:按原始遍历顺序输出,只放行赢家;无 startedAt 的一律放行(不去重)。
68
+ const items: Item[] = [];
69
+ for (const snapshot of snapshots) {
70
+ for (const attempt of snapshot.attempts) {
71
+ const key = identityKey(snapshot, attempt.result);
72
+ if (key === null || winner.get(key)?.attempt === attempt) items.push({ snapshot, attempt });
73
+ }
74
+ }
75
+ return items;
76
+ }
77
+
78
+ /**
79
+ * eval id 前缀过滤,同 CLI 位置参数的分段语义(src/runner/discover.ts):
80
+ * "algebra" 匹配自身与 "algebra/..." 子级,不误配 "algebra2";允许 "algebra/" 尾斜杠写法,等价。
81
+ */
82
+ export function evalPrefixPredicate(evals?: string | string[]): (id: string) => boolean {
83
+ if (evals === undefined) return () => true;
84
+ const prefixes = (Array.isArray(evals) ? evals : [evals]).map((p) => p.replace(/\/+$/, ""));
85
+ return (id) => prefixes.some((prefix) => id === prefix || id.startsWith(prefix + "/"));
86
+ }
87
+
88
+ export function filterItems(items: Item[], evals?: string | string[]): Item[] {
89
+ if (evals === undefined) return items;
90
+ const match = evalPrefixPredicate(evals);
91
+ return items.filter((item) => match(item.attempt.result.id));
92
+ }
93
+
94
+ // ───────────────────────── 维度 ─────────────────────────
95
+
96
+ export function dimensionName(dimension: Dimension): string {
97
+ return typeof dimension === "string" ? dimension : dimension.name;
98
+ }
99
+
100
+ /** eval id 的第一段:"algebra/quadratic" → "algebra";没有 "/" 时就是 id 本身。 */
101
+ export function evalGroupOf(id: string): string {
102
+ const slash = id.indexOf("/");
103
+ return slash === -1 ? id : id.slice(0, slash);
104
+ }
105
+
106
+ export function dimensionKey(dimension: Dimension, item: Item): string {
107
+ if (typeof dimension !== "string") return dimension.of(item.attempt);
108
+ const result = item.attempt.result;
109
+ switch (dimension) {
110
+ case "agent":
111
+ return result.agent;
112
+ case "model":
113
+ return result.model ?? item.snapshot.model ?? "(none)";
114
+ case "experiment":
115
+ return experimentIdOf(item);
116
+ case "eval":
117
+ return result.id;
118
+ case "evalGroup":
119
+ return evalGroupOf(result.id);
120
+ case "snapshot":
121
+ return `${item.snapshot.experimentId} @ ${item.snapshot.startedAt}`;
122
+ default: {
123
+ // 穷尽检查:新增内置维度而漏改这里时编译期报错
124
+ const exhausted: never = dimension;
125
+ throw new Error(`Unknown dimension: ${String(exhausted)}`);
126
+ }
127
+ }
128
+ }
129
+
130
+ /** 按维度分组,保持首次出现顺序(无 sort 时表格行序即此序)。 */
131
+ export function groupItems(items: Item[], dimension: Dimension): Map<string, Item[]> {
132
+ const groups = new Map<string, Item[]>();
133
+ for (const item of items) {
134
+ const key = dimensionKey(dimension, item);
135
+ const list = groups.get(key);
136
+ if (list) list.push(item);
137
+ else groups.set(key, [item]);
138
+ }
139
+ return groups;
140
+ }
141
+
142
+ // ───────────────────────── 聚合 ─────────────────────────
143
+
144
+ export function applyAggregator(aggregator: Aggregator, values: number[]): number {
145
+ if (typeof aggregator === "function") return aggregator(values);
146
+ switch (aggregator) {
147
+ case "mean":
148
+ return values.reduce((a, b) => a + b, 0) / values.length;
149
+ case "sum":
150
+ return values.reduce((a, b) => a + b, 0);
151
+ case "min":
152
+ return Math.min(...values);
153
+ case "max":
154
+ return Math.max(...values);
155
+ }
156
+ }
157
+
158
+ /** where 不满足 → null,语义等价于 value 开头 return null。 */
159
+ export async function evaluateMetric(metric: Metric, attempt: AttemptHandle): Promise<number | null> {
160
+ if (metric.where && !metric.where(attempt)) return null;
161
+ return metric.value(attempt);
162
+ }
163
+
164
+ export function displayValue(metric: Metric, value: number | null): string {
165
+ // null 的纯文本兜底;组件把 null 渲染成「缺数据」,绝不画 0
166
+ if (value === null) return "—";
167
+ return metric.display ? metric.display(value) : formatMetricValue(value, metric.unit);
168
+ }
169
+
170
+ /**
171
+ * 一个格子:组内 attempt → 两级聚合 → 终值。
172
+ * null 值不进聚合但计入 total(覆盖率经 samples/total 如实暴露);全 null → value null。
173
+ */
174
+ export async function computeCell(metric: Metric, items: Item[]): Promise<MetricCell> {
175
+ // 第一级桶:同一 (eval × 快照) 的 attempt 折成一个题级值
176
+ const buckets = new Map<string, number[]>();
177
+ const refs: AttemptRef[] = [];
178
+ let samples = 0;
179
+ for (const item of items) {
180
+ const value = await evaluateMetric(metric, item.attempt);
181
+ if (value === null) continue;
182
+ samples += 1;
183
+ refs.push(item.attempt.ref); // 证据引用由 niceeval/results 的句柄直供,不反查下标
184
+ const bucketKey = `${item.attempt.result.id}${KEY_SEP}${snapshotIdentity(item.snapshot)}`;
185
+ const bucket = buckets.get(bucketKey);
186
+ if (bucket) bucket.push(value);
187
+ else buckets.set(bucketKey, [value]);
188
+ }
189
+ const perEval = metric.aggregate?.perEval ?? "mean";
190
+ const across = metric.aggregate?.across ?? "mean";
191
+ const evalValues = [...buckets.values()].map((values) => applyAggregator(perEval, values));
192
+ const value = evalValues.length === 0 ? null : applyAggregator(across, evalValues);
193
+ return { value, display: displayValue(metric, value), samples, total: items.length, refs };
194
+ }
195
+
196
+ export function toColumn(metric: Metric): MetricColumn {
197
+ return {
198
+ key: metric.name,
199
+ label: metric.label ?? metric.name,
200
+ unit: metric.unit,
201
+ better: metric.better,
202
+ };
203
+ }
204
+
205
+ export function assertUniqueMetricNames(metrics: Metric[], where: string): void {
206
+ const seen = new Set<string>();
207
+ for (const metric of metrics) {
208
+ if (seen.has(metric.name)) {
209
+ throw new Error(
210
+ `Duplicate metric name "${metric.name}" in ${where}. Metric names must be unique within one computation; rename one via defineMetric.`,
211
+ );
212
+ }
213
+ seen.add(metric.name);
214
+ }
215
+ }
@@ -0,0 +1,405 @@
1
+ // 计算函数:快照 → 一份组件数据。跑在 Node 侧,产物是算好的、可序列化的普通 JSON
2
+ // (终值 + 渲染提示,不含公式);前端只做渲染。
3
+ //
4
+ // 共同约定(docs/reports.md「边界与不变量」):
5
+ // - 聚合前按身份键去重(dedupeAttempts);
6
+ // - null ≠ 0:缺数据不编数,覆盖率经 samples/total 如实暴露;
7
+ // - core 中立:只认 Metric / Dimension 接口,不出现具体 agent 名的分支。
8
+
9
+ import type { SnapshotHandle } from "../results/types.ts";
10
+ import type {
11
+ CaseListData,
12
+ DeltaData,
13
+ Dimension,
14
+ MatrixData,
15
+ Metric,
16
+ MetricCell,
17
+ OverviewData,
18
+ ScatterData,
19
+ ScoreboardData,
20
+ TableData,
21
+ } from "./types.ts";
22
+ import {
23
+ applyAggregator,
24
+ assertUniqueMetricNames,
25
+ computeCell,
26
+ dedupeAttempts,
27
+ dimensionKey,
28
+ dimensionName,
29
+ displayValue,
30
+ evalGroupOf,
31
+ evalPrefixPredicate,
32
+ evaluateMetric,
33
+ experimentIdOf,
34
+ filterItems,
35
+ groupItems,
36
+ toColumn,
37
+ type Item,
38
+ } from "./aggregate.ts";
39
+ import { attemptCostUSD, examScore } from "./metrics.ts";
40
+ import { formatPlainNumber } from "./format.ts";
41
+
42
+ // ───────────────────────── table ─────────────────────────
43
+
44
+ export interface TableOptions {
45
+ /** 行维度。 */
46
+ rows: Dimension;
47
+ /** 每列一个指标。 */
48
+ columns: Metric[];
49
+ /** 构建时排序,方向随 better(higher 降序,「好」的一头在上);缺数据行沉底。 */
50
+ sort?: Metric;
51
+ /** eval id 前缀过滤,同 CLI 位置参数语义。 */
52
+ evals?: string | string[];
53
+ }
54
+
55
+ export async function table(snapshots: SnapshotHandle[], opts: TableOptions): Promise<TableData> {
56
+ assertUniqueMetricNames(opts.columns, "table columns");
57
+ const items = filterItems(dedupeAttempts(snapshots), opts.evals);
58
+ const groups = groupItems(items, opts.rows);
59
+ const rows: TableData["rows"] = [];
60
+ const sortCells = new Map<string, MetricCell>();
61
+ for (const [key, group] of groups) {
62
+ const cells: Record<string, MetricCell> = {};
63
+ for (const metric of opts.columns) cells[metric.name] = await computeCell(metric, group);
64
+ if (opts.sort) {
65
+ // sort 指标不在 columns 里时单独算一遍,只用于排序、不进输出
66
+ sortCells.set(key, cells[opts.sort.name] ?? (await computeCell(opts.sort, group)));
67
+ }
68
+ rows.push({ key, cells });
69
+ }
70
+ if (opts.sort) {
71
+ const better = opts.sort.better ?? "higher";
72
+ rows.sort((a, b) => {
73
+ const va = sortCells.get(a.key)?.value ?? null;
74
+ const vb = sortCells.get(b.key)?.value ?? null;
75
+ if (va === null && vb === null) return 0;
76
+ if (va === null) return 1; // 缺数据沉底
77
+ if (vb === null) return -1;
78
+ return better === "lower" ? va - vb : vb - va;
79
+ });
80
+ }
81
+ return { dimension: dimensionName(opts.rows), columns: opts.columns.map(toColumn), rows };
82
+ }
83
+
84
+ // ───────────────────────── matrix ─────────────────────────
85
+
86
+ export interface MatrixOptions {
87
+ rows: Dimension;
88
+ columns: Dimension;
89
+ cell: Metric;
90
+ }
91
+
92
+ export async function matrix(snapshots: SnapshotHandle[], opts: MatrixOptions): Promise<MatrixData> {
93
+ const items = dedupeAttempts(snapshots);
94
+ // 稀疏分组:只有真有 attempt 的 (row, column) 组合成格;没有样本的格子不出现
95
+ const groups = new Map<string, { row: string; column: string; items: Item[] }>();
96
+ for (const item of items) {
97
+ const row = dimensionKey(opts.rows, item);
98
+ const column = dimensionKey(opts.columns, item);
99
+ const key = JSON.stringify([row, column]);
100
+ const group = groups.get(key);
101
+ if (group) group.items.push(item);
102
+ else groups.set(key, { row, column, items: [item] });
103
+ }
104
+ const cells: MatrixData["cells"] = [];
105
+ for (const group of groups.values()) {
106
+ cells.push({ row: group.row, column: group.column, cell: await computeCell(opts.cell, group.items) });
107
+ }
108
+ return {
109
+ rows: dimensionName(opts.rows),
110
+ columns: dimensionName(opts.columns),
111
+ metric: toColumn(opts.cell),
112
+ cells,
113
+ };
114
+ }
115
+
116
+ // ───────────────────────── scoreboard ─────────────────────────
117
+
118
+ export interface ScoreboardOptions {
119
+ /** 给谁打分(被打分的维度)。 */
120
+ of: Dimension;
121
+ /** 按什么分科;默认 "evalGroup"(考试里的「科目」)。 */
122
+ subjects?: Dimension;
123
+ /** eval id 前缀 → 每题分值;未列默认 1;前缀重叠时最长的生效。 */
124
+ weights?: Record<string, number>;
125
+ /** 折算满分;默认 100。 */
126
+ fullMarks?: number;
127
+ /** 每题得分指标;缺省即 examScore,可换自定义(如「答对但超预算扣分」)。 */
128
+ score?: Metric;
129
+ /** 选中范围:eval id 前缀过滤;题集(分母)只遍历这个范围。 */
130
+ evals?: string | string[];
131
+ }
132
+
133
+ /**
134
+ * 逐题分值制,分母对所有被打分者恒定:
135
+ * 题分值 = 命中的权重(默认 1) 题得分 = score 指标的题级值(perEval 折叠后)
136
+ * 总分 = fullMarks × Σ(题得分 × 题分值) / Σ(题分值) Σ 遍历选中范围内全部题
137
+ * 没跑到的题挣 0 分但留在分母里,missing 如实报 —— 这是显式的考试契约,不是「null ≠ 0」的例外。
138
+ */
139
+ export async function scoreboard(
140
+ snapshots: SnapshotHandle[],
141
+ opts: ScoreboardOptions,
142
+ ): Promise<ScoreboardData> {
143
+ const fullMarks = opts.fullMarks ?? 100;
144
+ const scoreMetric = opts.score ?? examScore;
145
+ const subjectsDim: Dimension = opts.subjects ?? "evalGroup";
146
+ const match = evalPrefixPredicate(opts.evals);
147
+ const items = filterItems(dedupeAttempts(snapshots), opts.evals);
148
+
149
+ // 题集(固定分母):选中范围内、任一快照声明覆盖或实际出现过的全部题
150
+ const universe = new Set<string>();
151
+ for (const snapshot of snapshots) {
152
+ for (const id of snapshot.evalIds) if (match(id)) universe.add(id);
153
+ }
154
+ for (const item of items) universe.add(item.attempt.result.id);
155
+ const sortedUniverse = [...universe].sort();
156
+
157
+ // 每题的科目:先从任一 attempt 解析(自定义 subjects 维度也能算);
158
+ // 全程无 attempt 的题按内置规则兜底,自定义维度无从计算时如实标 "(unknown)"
159
+ const subjectByEval = new Map<string, string>();
160
+ for (const item of items) {
161
+ const id = item.attempt.result.id;
162
+ if (!subjectByEval.has(id)) subjectByEval.set(id, dimensionKey(subjectsDim, item));
163
+ }
164
+ const subjectOf = (id: string): string => {
165
+ const known = subjectByEval.get(id);
166
+ if (known !== undefined) return known;
167
+ if (subjectsDim === "eval") return id;
168
+ if (subjectsDim === "evalGroup") return evalGroupOf(id);
169
+ return "(unknown)";
170
+ };
171
+
172
+ // 权重:最长前缀生效(排序后线性找第一个命中即最长)
173
+ const weights = Object.entries(opts.weights ?? {})
174
+ .map(([prefix, weight]) => ({ prefix, weight }))
175
+ .sort((a, b) => b.prefix.length - a.prefix.length);
176
+ const weightOf = (id: string): number =>
177
+ weights.find((w) => id.startsWith(w.prefix))?.weight ?? 1;
178
+
179
+ const groups = groupItems(items, opts.of);
180
+ const rows: ScoreboardData["rows"] = [];
181
+ for (const [key, group] of groups) {
182
+ // 题得分:perEval 折叠(同 eval × 快照 内);同题出现在多个快照时取快照级值的均值。
183
+ // TODO(results-lib):多快照同题的口径待与选择器对齐;常规姿势(latestPerExperiment)每题只有一个快照。
184
+ const perSnapshot = new Map<string, Map<string, number[]>>(); // evalId → snapshot 身份 → 原始值
185
+ for (const item of group) {
186
+ const value = await evaluateMetric(scoreMetric, item.attempt);
187
+ if (value === null) continue; // 测不了的 attempt 不进题得分;整题无样本 → missing
188
+ const id = item.attempt.result.id;
189
+ const snapKey = `${item.snapshot.experimentId} @ ${item.snapshot.startedAt}`;
190
+ let bySnap = perSnapshot.get(id);
191
+ if (!bySnap) perSnapshot.set(id, (bySnap = new Map()));
192
+ const bucket = bySnap.get(snapKey);
193
+ if (bucket) bucket.push(value);
194
+ else bySnap.set(snapKey, [value]);
195
+ }
196
+ const perEvalAgg = scoreMetric.aggregate?.perEval ?? "mean";
197
+ const scoreByEval = new Map<string, number>();
198
+ for (const [id, bySnap] of perSnapshot) {
199
+ const snapValues = [...bySnap.values()].map((values) => applyAggregator(perEvalAgg, values));
200
+ scoreByEval.set(id, snapValues.reduce((a, b) => a + b, 0) / snapValues.length);
201
+ }
202
+
203
+ // 科目累计:固定分母 —— 没跑的题 0 分挣、留在分母、计入 missing
204
+ const subjects = new Map<
205
+ string,
206
+ { key: string; earned: number; possible: number; evals: number; missing: number }
207
+ >();
208
+ for (const id of sortedUniverse) {
209
+ const subjectKey = subjectOf(id);
210
+ let subject = subjects.get(subjectKey);
211
+ if (!subject) subjects.set(subjectKey, (subject = { key: subjectKey, earned: 0, possible: 0, evals: 0, missing: 0 }));
212
+ const weight = weightOf(id);
213
+ const got = scoreByEval.get(id);
214
+ subject.earned += (got ?? 0) * weight;
215
+ subject.possible += weight;
216
+ subject.evals += 1;
217
+ if (got === undefined) subject.missing += 1;
218
+ }
219
+ let earned = 0;
220
+ let possible = 0;
221
+ for (const subject of subjects.values()) {
222
+ earned += subject.earned;
223
+ possible += subject.possible;
224
+ }
225
+ const value = possible === 0 ? 0 : (fullMarks * earned) / possible;
226
+ rows.push({ key, total: { value, display: formatPlainNumber(value) }, subjects: [...subjects.values()] });
227
+ }
228
+
229
+ return { of: dimensionName(opts.of), fullMarks, weights, rows };
230
+ }
231
+
232
+ // ───────────────────────── scatter ─────────────────────────
233
+
234
+ export interface ScatterOptions {
235
+ /** 点维度:每个点 = 该组 attempt 的聚合。 */
236
+ points: Dimension;
237
+ /** 可选:同系列的点连成线;省略 = 纯散点。 */
238
+ series?: Dimension;
239
+ x: Metric;
240
+ y: Metric;
241
+ }
242
+
243
+ export async function scatter(snapshots: SnapshotHandle[], opts: ScatterOptions): Promise<ScatterData> {
244
+ const items = dedupeAttempts(snapshots);
245
+ const groups = groupItems(items, opts.points);
246
+ const rows: ScatterData["rows"] = [];
247
+ for (const [key, group] of groups) {
248
+ rows.push({
249
+ key,
250
+ // 组内取第一条解析系列:点维度细于系列维度时(experiment ⊂ agent)天然一致
251
+ series: opts.series ? dimensionKey(opts.series, group[0]) : undefined,
252
+ x: await computeCell(opts.x, group),
253
+ y: await computeCell(opts.y, group), // 任一轴 null 的点留在 rows 里:组件不画,但注脚要报的数就从这里数
254
+ });
255
+ }
256
+ return {
257
+ points: dimensionName(opts.points),
258
+ series: opts.series ? dimensionName(opts.series) : undefined,
259
+ x: toColumn(opts.x),
260
+ y: toColumn(opts.y),
261
+ rows,
262
+ };
263
+ }
264
+
265
+ // ───────────────────────── overview ─────────────────────────
266
+
267
+ export interface OverviewOptions {
268
+ /** 选择器(latestPerExperiment 等)的警告,原样透传给 RunOverview 渲染。 */
269
+ warnings?: string[];
270
+ }
271
+
272
+ export async function overview(
273
+ snapshots: SnapshotHandle[],
274
+ opts?: OverviewOptions,
275
+ ): Promise<OverviewData> {
276
+ const items = dedupeAttempts(snapshots);
277
+ const evalIds = new Set<string>();
278
+ let passed = 0;
279
+ let failed = 0;
280
+ let errored = 0;
281
+ let skipped = 0;
282
+ let durationMs = 0;
283
+ let costUSD: number | null = null; // 任一 attempt 报了成本才有;全缺 = null,不编 0
284
+ for (const { attempt } of items) {
285
+ const result = attempt.result;
286
+ evalIds.add(result.id);
287
+ switch (result.outcome) {
288
+ case "passed":
289
+ passed += 1;
290
+ break;
291
+ case "failed":
292
+ failed += 1;
293
+ break;
294
+ case "errored":
295
+ errored += 1;
296
+ break;
297
+ case "skipped":
298
+ skipped += 1;
299
+ break;
300
+ }
301
+ durationMs += result.durationMs;
302
+ const cost = attemptCostUSD(result);
303
+ if (cost !== null) costUSD = (costUSD ?? 0) + cost;
304
+ }
305
+ return {
306
+ snapshots: snapshots.map((s) => ({
307
+ experimentId: s.experimentId,
308
+ agent: s.agent,
309
+ model: s.model,
310
+ startedAt: s.startedAt,
311
+ })),
312
+ totals: { evals: evalIds.size, attempts: items.length, passed, failed, errored, skipped, costUSD, durationMs },
313
+ warnings: [...(opts?.warnings ?? [])],
314
+ };
315
+ }
316
+
317
+ // ───────────────────────── delta ─────────────────────────
318
+
319
+ export interface DeltaPair {
320
+ /** 基线侧 experimentId。 */
321
+ a: string;
322
+ /** 对比侧 experimentId。 */
323
+ b: string;
324
+ label?: string;
325
+ }
326
+
327
+ export interface DeltaOptions {
328
+ /** 每行一对:B 相对 A。 */
329
+ pairs: DeltaPair[];
330
+ metrics: Metric[];
331
+ }
332
+
333
+ export async function delta(snapshots: SnapshotHandle[], opts: DeltaOptions): Promise<DeltaData> {
334
+ assertUniqueMetricNames(opts.metrics, "delta metrics");
335
+ const items = dedupeAttempts(snapshots);
336
+ const rows: DeltaData["rows"] = [];
337
+ for (const pair of opts.pairs) {
338
+ const aItems = items.filter((item) => experimentIdOf(item) === pair.a);
339
+ const bItems = items.filter((item) => experimentIdOf(item) === pair.b);
340
+ const cells: DeltaData["rows"][number]["cells"] = {};
341
+ for (const metric of opts.metrics) {
342
+ const a = await computeCell(metric, aItems);
343
+ const b = await computeCell(metric, bItems);
344
+ const d = a.value === null || b.value === null ? null : b.value - a.value;
345
+ cells[metric.name] = { a, b, delta: d, display: deltaDisplay(metric, d) };
346
+ }
347
+ rows.push({
348
+ key: pair.label ?? `${pair.a} vs ${pair.b}`,
349
+ a: { experimentId: pair.a },
350
+ b: { experimentId: pair.b },
351
+ cells,
352
+ });
353
+ }
354
+ return { columns: opts.metrics.map(toColumn), rows };
355
+ }
356
+
357
+ function deltaDisplay(metric: Metric, delta: number | null): string {
358
+ if (delta === null) return "—"; // 任一侧缺数据:Δ 显示为缺,不硬算
359
+ const text = displayValue(metric, delta); // 负号由格式化自带
360
+ return delta > 0 ? `+${text}` : text;
361
+ }
362
+
363
+ // ───────────────────────── cases ─────────────────────────
364
+
365
+ export interface CasesOptions {
366
+ /** 要列出的判决;默认 failed + errored。 */
367
+ outcomes?: ("failed" | "errored")[];
368
+ /** 超出如实报 truncated,不静默截断。 */
369
+ limit?: number;
370
+ /** 自由文本(error / 断言 detail / judge evidence)的发布消毒钩子;身份字段不经它。 */
371
+ redact?: (text: string) => string;
372
+ }
373
+
374
+ export async function cases(snapshots: SnapshotHandle[], opts?: CasesOptions): Promise<CaseListData> {
375
+ const wanted = new Set<"failed" | "errored">(opts?.outcomes ?? ["failed", "errored"]);
376
+ const redact = opts?.redact ?? ((text: string) => text);
377
+ const selected = dedupeAttempts(snapshots).filter((item) => {
378
+ const outcome = item.attempt.result.outcome;
379
+ return (outcome === "failed" || outcome === "errored") && wanted.has(outcome);
380
+ });
381
+ const shown = opts?.limit === undefined ? selected : selected.slice(0, opts.limit);
382
+ const rows: CaseListData["rows"] = shown.map((item) => {
383
+ const result = item.attempt.result;
384
+ const cost = attemptCostUSD(result);
385
+ return {
386
+ eval: result.id,
387
+ experimentId: experimentIdOf(item),
388
+ agent: result.agent,
389
+ outcome: result.outcome as "failed" | "errored",
390
+ error: result.error === undefined ? undefined : redact(result.error),
391
+ failedAssertions: result.assertions
392
+ .filter((assertion) => !assertion.passed)
393
+ .map((assertion) => ({
394
+ name: assertion.name,
395
+ score: assertion.score,
396
+ detail: assertion.detail === undefined ? undefined : redact(assertion.detail),
397
+ evidence: assertion.evidence === undefined ? undefined : redact(assertion.evidence),
398
+ })),
399
+ durationMs: result.durationMs,
400
+ costUSD: cost ?? undefined,
401
+ ref: item.attempt.ref,
402
+ };
403
+ });
404
+ return { rows, truncated: selected.length - shown.length };
405
+ }
@@ -0,0 +1,47 @@
1
+ // unit 驱动的内置格式化(docs/reports.md「指标与聚合」):
2
+ // "%" → 87% "ms" → 1.2s "$" → $0.31 其余 → 1.2k 缩写(带 unit 后缀)
3
+ // metric.display 可整体覆盖;这里只负责默认。
4
+
5
+ /** 一位小数、去掉无意义的 ".0" 尾巴。 */
6
+ function trimmed(n: number): string {
7
+ const s = n.toFixed(1);
8
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
9
+ }
10
+
11
+ /** 1.2k / 3.4M / 5.6B 式缩写(输入为非负数)。 */
12
+ function abbreviate(abs: number): string {
13
+ if (abs >= 1e9) return `${trimmed(abs / 1e9)}B`;
14
+ if (abs >= 1e6) return `${trimmed(abs / 1e6)}M`;
15
+ if (abs >= 1e3) return `${trimmed(abs / 1e3)}k`;
16
+ return Number.isInteger(abs) ? String(abs) : trimmed(abs);
17
+ }
18
+
19
+ function formatDuration(absMs: number): string {
20
+ if (absMs < 1000) return `${Math.round(absMs)}ms`;
21
+ if (absMs < 60_000) return `${trimmed(absMs / 1000)}s`;
22
+ const totalSeconds = Math.round(absMs / 1000);
23
+ return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
24
+ }
25
+
26
+ function formatDollars(abs: number): string {
27
+ if (abs >= 1000) return abbreviate(abs);
28
+ if (abs >= 0.01 || abs === 0) return abs.toFixed(2);
29
+ // 小额成本保留有效位,不四舍成 "$0.00" 假零
30
+ return abs.toFixed(4);
31
+ }
32
+
33
+ export function formatMetricValue(value: number, unit?: string): string {
34
+ const sign = value < 0 ? "-" : "";
35
+ const abs = Math.abs(value);
36
+ if (unit === "%") return `${sign}${trimmed(Math.round(abs * 1000) / 10)}%`;
37
+ if (unit === "ms") return sign + formatDuration(abs);
38
+ if (unit === "$") return `${sign}$${formatDollars(abs)}`;
39
+ const n = abbreviate(abs);
40
+ return unit ? `${sign}${n} ${unit}` : `${sign}${n}`;
41
+ }
42
+
43
+ /** 无单位纯数字(scoreboard 总分等):一位小数,去尾零。 */
44
+ export function formatPlainNumber(value: number): string {
45
+ const sign = value < 0 ? "-" : "";
46
+ return sign + trimmed(Math.round(Math.abs(value) * 10) / 10);
47
+ }