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,210 @@
1
+ // niceeval/report 的公开类型:指标(Metric)、维度(Dimension)与计算函数产物
2
+ // (即 niceeval/report/react 组件的 props)。数据契约照抄 docs/reports.md
3
+ // 「计算函数与数据契约」——这些不是持久化格式,没有 format / schemaVersion 信封;
4
+ // 兼容性跟随 npm 版本。
5
+
6
+ import type { AttemptHandle } from "../results/types.ts";
7
+
8
+ // ───────────────────────── 指标与聚合 ─────────────────────────
9
+
10
+ /** 两级聚合里单级的折叠方式。 */
11
+ export type Aggregator = "mean" | "sum" | "min" | "max" | ((values: number[]) => number);
12
+
13
+ /**
14
+ * 两级聚合:「每格 attempt 数相等」是幻觉(earlyExit 让失败的题天然比通过的题样本多),
15
+ * 平铺求均值会让分数和重试策略纠缠;所以先题内折叠、再跨题折叠,默认宏平均。
16
+ */
17
+ export interface MetricAggregate {
18
+ /** 第一级:同一 (eval × 快照) 的多 attempt → 一个题级值;默认 "mean"。 */
19
+ perEval?: Aggregator;
20
+ /** 第二级:分组内的题级值 → 格子终值;默认 "mean"。 */
21
+ across?: Aggregator;
22
+ }
23
+
24
+ /**
25
+ * 指标:纯函数,吃一个 AttemptHandle 吐一个值(null = 此 attempt 测不了这个指标,
26
+ * 不进聚合;0 = 测了结果是零,照常进),外加名字、两级聚合方式和渲染提示。
27
+ * 内置指标与自定义指标是同一个类型,没有特权。
28
+ */
29
+ export interface Metric {
30
+ /** MetricColumn.key 与列头的来源;同一次计算里重名是错误。 */
31
+ name: string;
32
+ /** 列头;省略时用 name。 */
33
+ label?: string;
34
+ description?: string;
35
+ /** 渲染提示:越高越好还是越低越好(排序方向、轴向、涨跌配色用)。 */
36
+ better?: "higher" | "lower";
37
+ /** 驱动内置格式化:"%" → 87%、"ms" → 1.2s、"$" → $0.31、其余 → 1.2k 缩写。 */
38
+ unit?: string;
39
+ /**
40
+ * 声明式前置:不满足 → null,语义等价于在 value 开头 return null。
41
+ * 单独设字段是因为这一步最容易忘(忘了它,code-golf 会奖励「写得短的坏代码」)。
42
+ */
43
+ where?: (attempt: AttemptHandle) => boolean;
44
+ value(attempt: AttemptHandle): number | null | Promise<number | null>;
45
+ aggregate?: MetricAggregate;
46
+ /** 覆盖 unit 驱动的内置格式化。 */
47
+ display?: (value: number) => string;
48
+ }
49
+
50
+ /**
51
+ * 维度:attempt 分到哪一组。内置维度就是 EvalResult 已有的身份字段;自定义维度是一个函数。
52
+ * - "evalGroup" = eval id 的第一段:"algebra/quadratic" → "algebra"(考试里的「科目」)
53
+ * - "snapshot" = "<experimentId> @ <startedAt>",把两次快照并排成行,与 view 的 Compare 同口径
54
+ */
55
+ export type Dimension =
56
+ | "agent"
57
+ | "model"
58
+ | "experiment"
59
+ | "eval"
60
+ | "evalGroup"
61
+ | "snapshot"
62
+ | { name: string; of: (attempt: AttemptHandle) => string };
63
+
64
+ // ───────────────────────── 计算产物(组件 props)─────────────────────────
65
+
66
+ export interface MetricColumn {
67
+ /** = metric.name,与 cells 的键对应。 */
68
+ key: string;
69
+ label: string;
70
+ unit?: string;
71
+ /** 渲染提示:排序方向、轴向、涨跌配色。 */
72
+ better?: "higher" | "lower";
73
+ }
74
+
75
+ export interface MetricCell {
76
+ /** 聚合后的值;null = 该组没有任何有效样本。 */
77
+ value: number | null;
78
+ /** 已格式化("87%" / "1.2k lines" / "$0.31"),前端可直接渲染。 */
79
+ display: string;
80
+ /** 有效 attempt 数(值为 null 的不计入)。 */
81
+ samples: number;
82
+ /** 组内 attempt 总数;samples < total = 有 attempt 测不了这个指标。 */
83
+ total: number;
84
+ /** 这个格子由哪些 attempt 算出 —— 回到证据的引用。 */
85
+ refs?: AttemptRef[];
86
+ }
87
+
88
+ // AttemptRef 的家在 niceeval/results:Reports 的 refs、view 深链、copyRun 用同一个证据身份。
89
+ // 这里 import + re-export,公共 API 入口不变。
90
+ import type { AttemptRef } from "../results/types.ts";
91
+ export type { AttemptRef };
92
+
93
+ export interface TableData {
94
+ /** 行维度名,如 "agent"。 */
95
+ dimension: string;
96
+ columns: MetricColumn[];
97
+ rows: { key: string; cells: Record<string, MetricCell> }[];
98
+ }
99
+
100
+ export interface MatrixData {
101
+ /** 行维度名,如 "eval"。 */
102
+ rows: string;
103
+ /** 列维度名,如 "agent"。 */
104
+ columns: string;
105
+ metric: MetricColumn;
106
+ /** 稀疏:没有样本的格子不出现。 */
107
+ cells: { row: string; column: string; cell: MetricCell }[];
108
+ }
109
+
110
+ export interface ScoreboardData {
111
+ /** 被打分的维度名,如 "agent"。 */
112
+ of: string;
113
+ fullMarks: number;
114
+ /** 实际生效的权重表(按匹配顺序:最长前缀在前)—— 成绩单可审计。 */
115
+ weights: { prefix: string; weight: number }[];
116
+ rows: {
117
+ key: string;
118
+ /** 已折算到 fullMarks。 */
119
+ total: { value: number; display: string };
120
+ subjects: {
121
+ /** 科目(subjects 维度的值)。 */
122
+ key: string;
123
+ /** 加权得分。 */
124
+ earned: number;
125
+ /** 科目分值合计。 */
126
+ possible: number;
127
+ /** 题数。 */
128
+ evals: number;
129
+ /** 无任何样本、按 0 计的题数 —— 固定分母的如实注脚。 */
130
+ missing: number;
131
+ }[];
132
+ }[];
133
+ }
134
+
135
+ export interface ScatterData {
136
+ /** 点维度名,如 "experiment"。 */
137
+ points: string;
138
+ /** 系列维度名,如 "agent"。 */
139
+ series?: string;
140
+ /** better: "lower" → 组件反向画轴,「好」的角落恒在右上。 */
141
+ x: MetricColumn;
142
+ y: MetricColumn;
143
+ rows: {
144
+ /** 点的键,如 "compare/bub-high"。 */
145
+ key: string;
146
+ /** 所属系列,如 "bub"。 */
147
+ series?: string;
148
+ x: MetricCell;
149
+ /** 任一为 null 的点组件不画,注脚如实报数(点仍留在 rows 里,可数)。 */
150
+ y: MetricCell;
151
+ }[];
152
+ }
153
+
154
+ export interface OverviewData {
155
+ snapshots: { experimentId: string; agent: string; model?: string; startedAt: string }[];
156
+ totals: {
157
+ evals: number;
158
+ attempts: number;
159
+ passed: number;
160
+ failed: number;
161
+ errored: number;
162
+ skipped: number;
163
+ /** 任一 attempt 报了成本才有;全缺 = null,不编 0。 */
164
+ costUSD: number | null;
165
+ durationMs: number;
166
+ };
167
+ /** 选择器的警告透传进来,RunOverview 直接渲染 —— 诚实不靠使用者记得渲染。 */
168
+ warnings: string[];
169
+ }
170
+
171
+ export interface DeltaData {
172
+ columns: MetricColumn[];
173
+ rows: {
174
+ /** pair 的 label,如 "bub"。 */
175
+ key: string;
176
+ /** 基线侧。 */
177
+ a: { experimentId: string };
178
+ /** 对比侧。 */
179
+ b: { experimentId: string };
180
+ cells: Record<
181
+ string,
182
+ {
183
+ a: MetricCell;
184
+ b: MetricCell;
185
+ /** b.value - a.value;任一侧 null → null,不硬算。 */
186
+ delta: number | null;
187
+ /** 已带符号("+12%" / "-$0.80"),涨跌好坏由 better 判定。 */
188
+ display: string;
189
+ }
190
+ >;
191
+ }[];
192
+ }
193
+
194
+ export interface CaseListData {
195
+ rows: {
196
+ eval: string;
197
+ experimentId: string;
198
+ agent: string;
199
+ outcome: "failed" | "errored";
200
+ /** errored 的错误摘要(已过 redact)。 */
201
+ error?: string;
202
+ failedAssertions: { name: string; score: number; detail?: string; evidence?: string }[];
203
+ durationMs: number;
204
+ costUSD?: number;
205
+ /** 每条案例都能回到证据。 */
206
+ ref: AttemptRef;
207
+ }[];
208
+ /** limit 之外还有几条,如实报,不静默截断。 */
209
+ truncated: number;
210
+ }
@@ -0,0 +1,200 @@
1
+ // copyRun:把选中快照按格式感知地复制到另一个目录(设计见 docs/results-lib.md「复制与瘦身」)。
2
+ //
3
+ // 发布场景的原语:只带指定工件、只带选中的 attempt,布局知识不外泄。
4
+ // 工件复制忠实于源(copyFile 原字节,不重新序列化、不消毒);summary.json 按选中条目重建,
5
+ // 版本元数据保留,榜单计数按选中条目重算 —— 产物是一个 openResults / `niceeval view`
6
+ // 都能直接读的合法 run 目录。
7
+
8
+ import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import type { EvalResult, RunSummary } from "../runner/types.ts";
11
+ import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../runner/types.ts";
12
+ import type { Usage } from "../o11y/types.ts";
13
+ import { artifactFileOf, attemptDirOf } from "./format.ts";
14
+ import { isNewerRun } from "./select.ts";
15
+ import type { ArtifactKind, AttemptHandle, RunHandle, SnapshotHandle } from "./types.ts";
16
+ import { ARTIFACT_KINDS } from "./types.ts";
17
+
18
+ export interface CopyRunOptions {
19
+ /** 要带上的工件种类;省略 = 全部五类。diff 可达百 MB、o11y 查看器不读,发布时常见地不带。 */
20
+ artifacts?: ArtifactKind[];
21
+ }
22
+
23
+ export interface CopiedRun {
24
+ /** 目标 run 目录的绝对路径(summary.json 所在目录)。 */
25
+ dir: string;
26
+ /** 重建后的 summary(与写入磁盘的一致)。 */
27
+ summary: RunSummary;
28
+ /** 复制过程中的警告(如多个 attempt 落到同一目录);不静默。 */
29
+ warnings: string[];
30
+ }
31
+
32
+ /**
33
+ * 把 `snapshots` 里的全部 attempt 复制成 `destDir` 下的一个 run 目录。
34
+ * 快照可以来自不同物理 run(latestPerExperiment 的典型输出);两个 attempt 落到同一
35
+ * 工件目录时保留最新 run 的那份并出 warning —— 复制前先 dedupeAttempts 可避免。
36
+ */
37
+ export async function copyRun(
38
+ snapshots: SnapshotHandle | SnapshotHandle[],
39
+ destDir: string,
40
+ opts?: CopyRunOptions,
41
+ ): Promise<CopiedRun> {
42
+ const selected = Array.isArray(snapshots) ? snapshots : [snapshots];
43
+ if (selected.length === 0) {
44
+ throw new Error(
45
+ "copyRun got no snapshots to copy. Check the experiments filter, or pass snapshots from openResults()/latestPerExperiment().",
46
+ );
47
+ }
48
+ const kinds = opts?.artifacts ?? [...ARTIFACT_KINDS];
49
+ const dest = resolve(destDir);
50
+
51
+ const sourceRuns = uniqueRuns(selected);
52
+ for (const run of sourceRuns) {
53
+ if (resolve(run.dir) === dest) {
54
+ throw new Error(`copyRun destination ${dest} is a source run directory. Pick a different destination.`);
55
+ }
56
+ }
57
+
58
+ // 第一趟:按目标工件目录选出胜者(最新 run 的那份),顺序取首次出现处 —— 两趟避免
59
+ // 「旧的先落盘、新的再去清理覆盖」的中间态。
60
+ const winners = new Map<string, AttemptHandle>();
61
+ const order: string[] = [];
62
+ const warnings: string[] = [];
63
+ for (const snapshot of selected) {
64
+ for (const attempt of snapshot.attempts) {
65
+ const relDir = attemptDirOf(attempt.result);
66
+ const existing = winners.get(relDir);
67
+ if (!existing) {
68
+ winners.set(relDir, attempt);
69
+ order.push(relDir);
70
+ continue;
71
+ }
72
+ warnings.push(
73
+ `warning: multiple attempts map to "${relDir}" in the copied run; kept the one from the newest run. Dedupe attempts or re-select snapshots before copyRun to avoid this.`,
74
+ );
75
+ if (isNewerRun(attempt.run, existing.run)) winners.set(relDir, attempt);
76
+ }
77
+ }
78
+
79
+ // 第二趟:复制工件 + 生成瘦身条目(has* 按目标目录里真的有什么重算)。
80
+ await mkdir(dest, { recursive: true });
81
+ const results: EvalResult[] = [];
82
+ for (const relDir of order) {
83
+ const attempt = winners.get(relDir)!;
84
+ const files = await findArtifactFiles(attempt, kinds);
85
+ if (files.length > 0) {
86
+ const attemptDest = join(dest, relDir);
87
+ await mkdir(attemptDest, { recursive: true });
88
+ await Promise.all(files.map(({ kind, source }) => copyFile(source, join(attemptDest, artifactFileOf(kind)))));
89
+ }
90
+ const copied = new Set(files.map((f) => f.kind));
91
+ results.push(slimForCopy(attempt.result, relDir, copied));
92
+ }
93
+
94
+ const summary = rebuildSummary(selected, sourceRuns, results, dest);
95
+ await writeFile(join(dest, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
96
+ return { dir: dest, summary, warnings };
97
+ }
98
+
99
+ /** 源工件定位:与读取面同一候选顺序(本 run 的 artifactsDir 优先,artifactBase 回退)。 */
100
+ async function findArtifactFiles(
101
+ attempt: AttemptHandle,
102
+ kinds: ArtifactKind[],
103
+ ): Promise<{ kind: ArtifactKind; source: string }[]> {
104
+ const candidates: string[] = [];
105
+ if (attempt.result.artifactsDir) candidates.push(join(attempt.run.dir, attempt.result.artifactsDir));
106
+ if (attempt.result.artifactBase) candidates.push(join(dirname(attempt.run.dir), attempt.result.artifactBase));
107
+
108
+ const found: { kind: ArtifactKind; source: string }[] = [];
109
+ for (const kind of kinds) {
110
+ for (const dir of candidates) {
111
+ const source = join(dir, artifactFileOf(kind));
112
+ try {
113
+ if ((await stat(source)).isFile()) {
114
+ found.push({ kind, source });
115
+ break;
116
+ }
117
+ } catch {
118
+ // 缺文件跳过:某类数据为空本来就不生成对应 JSON(docs/results-format.md)。
119
+ }
120
+ }
121
+ }
122
+ return found;
123
+ }
124
+
125
+ /** 瘦身条目:去掉内联大字段与 view 注入的路径字段,artifactsDir / has* 按目标目录重算。 */
126
+ function slimForCopy(r: EvalResult, relDir: string, copied: Set<ArtifactKind>): EvalResult {
127
+ const { events, sources, o11y, trace, diff, rawTranscript, artifactBase, artifactAbsBase, ...rest } = r;
128
+ void events;
129
+ void sources;
130
+ void o11y;
131
+ void trace;
132
+ void diff;
133
+ void rawTranscript;
134
+ void artifactBase;
135
+ void artifactAbsBase;
136
+ return {
137
+ ...rest,
138
+ artifactsDir: relDir,
139
+ hasEvents: copied.has("events"),
140
+ hasTrace: copied.has("trace"),
141
+ hasSources: copied.has("sources"),
142
+ };
143
+ }
144
+
145
+ function rebuildSummary(
146
+ selected: SnapshotHandle[],
147
+ sourceRuns: RunHandle[],
148
+ results: EvalResult[],
149
+ dest: string,
150
+ ): RunSummary {
151
+ // 版本元数据与 run 级元数据取最新源 run 的(producer 缺失的 legacy 源保持缺失,不冒充)。
152
+ let newest = sourceRuns[0];
153
+ for (const run of sourceRuns) if (isNewerRun(run, newest)) newest = run;
154
+
155
+ const counts = { passed: 0, failed: 0, skipped: 0, errored: 0 };
156
+ for (const r of results) counts[r.outcome] += 1;
157
+
158
+ return {
159
+ format: RESULTS_FORMAT,
160
+ schemaVersion: RESULTS_SCHEMA_VERSION,
161
+ ...(newest.summary.producer ? { producer: newest.summary.producer } : {}),
162
+ ...(newest.summary.name !== undefined ? { name: newest.summary.name } : {}),
163
+ // 顶层 agent/model 沿用 runner 的「第一个配置」姿势;快照各自的 agent 在条目里。
164
+ agent: selected[0].agent,
165
+ ...(selected[0].model !== undefined ? { model: selected[0].model } : {}),
166
+ startedAt: newest.summary.startedAt,
167
+ completedAt: newest.summary.completedAt,
168
+ ...counts,
169
+ // 跨 run 拼装无法还原墙钟时长,这里是选中 attempt 的耗时合计。
170
+ durationMs: results.reduce((total, r) => total + (r.durationMs ?? 0), 0),
171
+ ...usageAndCost(results),
172
+ results,
173
+ outputDir: dest,
174
+ };
175
+ }
176
+
177
+ /** usage / 成本按选中条目重算;口径同 runner 的 summarize(没有任何 usage 就不写)。 */
178
+ function usageAndCost(results: EvalResult[]): Pick<RunSummary, "usage" | "estimatedCostUSD"> {
179
+ const usages = results.map((r) => r.usage).filter((u): u is Usage => u !== undefined);
180
+ const cost = results.reduce((total, r) => total + (r.estimatedCostUSD ?? 0), 0);
181
+ if (usages.length === 0) return { estimatedCostUSD: cost || undefined };
182
+ const usage: Usage = { inputTokens: 0, outputTokens: 0 };
183
+ for (const u of usages) {
184
+ usage.inputTokens += u.inputTokens;
185
+ usage.outputTokens += u.outputTokens;
186
+ for (const key of ["cacheReadTokens", "cacheWriteTokens", "requests", "costUSD"] as const) {
187
+ const value = u[key];
188
+ if (value !== undefined) usage[key] = (usage[key] ?? 0) + value;
189
+ }
190
+ }
191
+ return { usage, estimatedCostUSD: cost || undefined };
192
+ }
193
+
194
+ function uniqueRuns(snapshots: SnapshotHandle[]): RunHandle[] {
195
+ const runs: RunHandle[] = [];
196
+ for (const snapshot of snapshots) {
197
+ if (!runs.includes(snapshot.run)) runs.push(snapshot.run);
198
+ }
199
+ return runs;
200
+ }
@@ -0,0 +1,75 @@
1
+ // Results Format 的布局与版本知识(读取面),规则见 docs/results-format.md。
2
+ //
3
+ // 版本判定与 src/view/loader.ts 的 normalizeSummary 同一口径(view 抛错驱动 CLI 提示,
4
+ // 这里返回分类值驱动 skipped 列表);attempt 目录规则与 src/runner/reporters/artifacts.ts
5
+ // 的 attemptDir 同一规则。按 results-lib 设计,这份知识最终应只住在本库,
6
+ // view / reporter 改吃这里是后续收编步骤 —— 本实验不动它们的代码。
7
+
8
+ import type { EvalResult, RunSummary } from "../runner/types.ts";
9
+ import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../runner/types.ts";
10
+ import type { ArtifactKind } from "./types.ts";
11
+
12
+ /** attempt 工件子目录(相对 run 根):<evalId>/<agent>/<model>[/<experimentId>]/a<attempt>。 */
13
+ export function attemptDirOf(r: Pick<EvalResult, "id" | "agent" | "model" | "attempt" | "experimentId">): string {
14
+ const safe = (s: string) => s.replace(/[^\w.@-]/g, "_");
15
+ // evalId 里的 / 保留作目录层级,其余危险字符替换。
16
+ const id = r.id.replace(/[^\w./@-]/g, "_");
17
+ // experiment 段与 writer(artifacts.ts 的 attemptDir)同一规则:两个实验可以同 agent 同 model、
18
+ // 只差 flags,少这一段它们的工件会互相覆盖;experimentId 里的 / 不作层级(整段压成 _)。
19
+ const exp = r.experimentId ? `/${safe(r.experimentId)}` : "";
20
+ return `${id}/${safe(r.agent)}/${safe(r.model ?? "default")}${exp}/a${r.attempt}`;
21
+ }
22
+
23
+ /** 工件文件名:种类即文件名。 */
24
+ export function artifactFileOf(kind: ArtifactKind): string {
25
+ return `${kind}.json`;
26
+ }
27
+
28
+ /** summary.json 的版本判定结果;openResults 按它分流 ok / skipped / 静默忽略。 */
29
+ export type SummaryClassification =
30
+ | { kind: "ok"; summary: RunSummary }
31
+ | { kind: "incompatible"; schemaVersion: number; producerVersion?: string }
32
+ | { kind: "malformed"; detail: string }
33
+ | { kind: "not-a-report" };
34
+
35
+ /**
36
+ * 版本判定与最小形状校验(docs/results-format.md「版本不匹配时的读取行为」):
37
+ * - 带 format 信封:format 不是 niceeval.results → 无关 JSON;schemaVersion 与当前不同 →
38
+ * 不兼容(不解析、不迁移、不降级),缺 schemaVersion 的早期信封按 1 处理。
39
+ * - 无信封:results[] 与 startedAt 同时具备才按 legacy 照读(未知字段忽略);
40
+ * 只沾到一个键的多半是别家工具的 summary.json,按规范当无关 JSON 忽略,不进 skipped 制造噪音。
41
+ */
42
+ export function classifySummary(raw: unknown): SummaryClassification {
43
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
44
+ return { kind: "malformed", detail: "not a JSON object" };
45
+ }
46
+ const data = raw as Partial<RunSummary>;
47
+ if (data.format !== undefined && data.format !== RESULTS_FORMAT) return { kind: "not-a-report" };
48
+ if (data.format === RESULTS_FORMAT) {
49
+ const version = data.schemaVersion ?? 1;
50
+ if (typeof version !== "number") return { kind: "malformed", detail: "schemaVersion is not a number" };
51
+ if (version !== RESULTS_SCHEMA_VERSION) {
52
+ return { kind: "incompatible", schemaVersion: version, producerVersion: data.producer?.version };
53
+ }
54
+ if (!Array.isArray(data.results) || typeof data.startedAt !== "string") {
55
+ return { kind: "malformed", detail: "missing results[] or startedAt" };
56
+ }
57
+ return { kind: "ok", summary: data as RunSummary };
58
+ }
59
+ if (!Array.isArray(data.results) || typeof data.startedAt !== "string") {
60
+ return { kind: "not-a-report" };
61
+ }
62
+ return { kind: "ok", summary: data as RunSummary };
63
+ }
64
+
65
+ /**
66
+ * 快照/去重共用的 experiment 身份键:experimentId 缺失时以 "<agent>/<model>" 合成
67
+ * (无 model 用 "default",与工件目录命名同一口径),synthesized 供调用方决定要不要出 warning。
68
+ */
69
+ export function experimentKeyOf(r: Pick<EvalResult, "experimentId" | "agent" | "model">): {
70
+ id: string;
71
+ synthesized: boolean;
72
+ } {
73
+ if (r.experimentId) return { id: r.experimentId, synthesized: false };
74
+ return { id: `${r.agent}/${r.model ?? "default"}`, synthesized: true };
75
+ }
@@ -0,0 +1,27 @@
1
+ // niceeval/results —— 实验结果数据的读写库(设计见 docs/results-lib.md)。
2
+ //
3
+ // 本实验先落读取面:openResults / latestPerExperiment / dedupeAttempts / copyRun。
4
+ // TODO(results-lib): 写入面 createRunWriter 落地后,src/runner/reporters/artifacts.ts
5
+ // 变薄壳、src/view/loader.ts 改吃这里的 reader —— 布局知识全宇宙只剩这一份实现。
6
+
7
+ export { openResults } from "./open.ts";
8
+ export { dedupeAttempts, latestPerExperiment } from "./select.ts";
9
+ export { copyRun, type CopiedRun, type CopyRunOptions } from "./copy.ts";
10
+ export {
11
+ ARTIFACT_KINDS,
12
+ type ArtifactKind,
13
+ type AttemptHandle,
14
+ type AttemptRef,
15
+ type ResultsCollection,
16
+ type RunHandle,
17
+ type SkippedResultsRun,
18
+ type SnapshotHandle,
19
+ } from "./types.ts";
20
+
21
+ // 结果数据类型的家还没搬(类型迁移是设计里单独的一步);先从这里 re-export,
22
+ // 让消费方从一个入口拿全「句柄 + 数据类型 + 格式常量」。
23
+ export { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../runner/types.ts";
24
+ export type { EvalResult, ExperimentRunInfo, RunSummary } from "../runner/types.ts";
25
+ export type { O11ySummary, StreamEvent, TraceSpan, Usage } from "../o11y/types.ts";
26
+ export type { DiffData } from "../scoring/types.ts";
27
+ export type { SourceArtifact } from "../shared/types.ts";