niceeval 0.4.6 → 0.5.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 (174) hide show
  1. package/docs-site/zh/concepts/adapter.mdx +6 -6
  2. package/docs-site/zh/concepts/experiment.mdx +7 -7
  3. package/docs-site/zh/concepts/overview.mdx +1 -1
  4. package/docs-site/zh/concepts/tier.mdx +6 -6
  5. package/docs-site/zh/guides/agent-feedback-loop.mdx +63 -20
  6. package/docs-site/zh/guides/connect-your-agent.mdx +7 -7
  7. package/docs-site/zh/guides/custom-reports.mdx +243 -0
  8. package/docs-site/zh/guides/experiments.mdx +3 -3
  9. package/docs-site/zh/guides/report-components.mdx +252 -0
  10. package/docs-site/zh/guides/reporters.mdx +1 -1
  11. package/docs-site/zh/guides/results-data.mdx +224 -0
  12. package/docs-site/zh/guides/runner.mdx +1 -1
  13. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  14. package/docs-site/zh/guides/viewing-results.mdx +147 -77
  15. package/docs-site/zh/guides/write-experiment.mdx +9 -9
  16. package/docs-site/zh/guides/write-send.mdx +6 -6
  17. package/docs-site/zh/index.mdx +1 -1
  18. package/docs-site/zh/introduction.mdx +1 -1
  19. package/docs-site/zh/reference/cli.mdx +10 -2
  20. package/docs-site/zh/reference/define-agent.mdx +5 -5
  21. package/docs-site/zh/reference/define-config.mdx +1 -1
  22. package/docs-site/zh/reference/define-eval.mdx +3 -3
  23. package/package.json +3 -2
  24. package/src/agents/ai-sdk.test.ts +1 -1
  25. package/src/agents/ai-sdk.ts +2 -2
  26. package/src/agents/claude-code.ts +1 -1
  27. package/src/agents/codex.ts +2 -2
  28. package/src/agents/streaming.test.ts +1 -1
  29. package/src/agents/types.ts +4 -4
  30. package/src/agents/ui-message-stream.test.ts +1 -1
  31. package/src/cli.ts +104 -14
  32. package/src/context/context.test.ts +1 -1
  33. package/src/context/context.ts +3 -3
  34. package/src/context/session.ts +2 -2
  35. package/src/context/types.ts +2 -2
  36. package/src/i18n/en.ts +29 -7
  37. package/src/i18n/zh-CN.ts +24 -2
  38. package/src/report/aggregate.ts +79 -50
  39. package/src/report/components.tsx +190 -0
  40. package/src/report/compute.ts +149 -84
  41. package/src/report/default-report.tsx +222 -0
  42. package/src/report/dual-face.test.tsx +527 -0
  43. package/src/report/format.ts +29 -0
  44. package/src/report/index.ts +79 -24
  45. package/src/report/load.ts +67 -0
  46. package/src/report/metrics.ts +2 -1
  47. package/src/report/param.ts +18 -0
  48. package/src/report/primitives.tsx +117 -0
  49. package/src/report/react/CaseList.tsx +2 -2
  50. package/src/report/react/DeltaTable.tsx +2 -2
  51. package/src/report/react/MetricBars.tsx +109 -0
  52. package/src/report/react/MetricLine.tsx +200 -0
  53. package/src/report/react/MetricMatrix.tsx +1 -1
  54. package/src/report/react/MetricScatter.tsx +1 -1
  55. package/src/report/react/MetricTable.tsx +1 -1
  56. package/src/report/react/RunOverview.tsx +4 -4
  57. package/src/report/react/Scoreboard.tsx +3 -3
  58. package/src/report/react/cell.tsx +1 -1
  59. package/src/report/react/fixtures.ts +98 -36
  60. package/src/report/react/format.ts +3 -28
  61. package/src/report/react/index.tsx +41 -24
  62. package/src/report/react/render.test.tsx +8 -8
  63. package/src/report/react/styles.css +77 -0
  64. package/src/report/report.test.ts +248 -128
  65. package/src/report/report.ts +69 -0
  66. package/src/report/text/faces.ts +356 -0
  67. package/src/report/text/layout.ts +125 -0
  68. package/src/report/text/plot.ts +127 -0
  69. package/src/report/tree.ts +220 -0
  70. package/src/report/types.ts +75 -27
  71. package/src/report/web.ts +40 -0
  72. package/src/results/copy.ts +95 -41
  73. package/src/results/format.ts +12 -11
  74. package/src/results/index.ts +34 -17
  75. package/src/results/open.ts +201 -81
  76. package/src/results/results.test.ts +595 -191
  77. package/src/results/select.ts +107 -55
  78. package/src/results/types.ts +137 -45
  79. package/src/results/writer.ts +258 -0
  80. package/src/runner/attempt.ts +3 -3
  81. package/src/runner/fingerprint.ts +1 -1
  82. package/src/runner/reporters/artifacts.ts +38 -78
  83. package/src/runner/reporters/braintrust.test.ts +2 -2
  84. package/src/runner/reporters/braintrust.ts +3 -3
  85. package/src/runner/run.ts +1 -1
  86. package/src/runner/types.ts +18 -12
  87. package/src/show/compose.ts +215 -0
  88. package/src/show/index.ts +263 -0
  89. package/src/show/render.ts +456 -0
  90. package/src/show/show.test.ts +505 -0
  91. package/src/view/app/App.tsx +85 -24
  92. package/src/view/app/components/CostScoreChart.tsx +29 -10
  93. package/src/view/app/components/ExperimentTable.tsx +27 -9
  94. package/src/view/app/components/GroupSelector.tsx +1 -1
  95. package/src/view/app/i18n.ts +18 -3
  96. package/src/view/app/lib/attempt-route.test.ts +9 -9
  97. package/src/view/app/lib/attempt-route.ts +5 -5
  98. package/src/view/app/lib/outcome.ts +1 -3
  99. package/src/view/app/lib/rows.ts +95 -15
  100. package/src/view/app/main.tsx +18 -6
  101. package/src/view/app/pages/RunsPage.tsx +4 -7
  102. package/src/view/app/pages/TracesPage.tsx +3 -8
  103. package/src/view/app/types.ts +50 -6
  104. package/src/view/client-dist/app.css +1 -1
  105. package/src/view/client-dist/app.js +20 -20
  106. package/src/view/data.test.ts +254 -0
  107. package/src/view/data.ts +336 -0
  108. package/src/view/index.ts +64 -10
  109. package/src/view/server.ts +31 -9
  110. package/src/view/shared/types.ts +47 -41
  111. package/src/view/styles.css +5 -0
  112. package/src/view/template.html +1 -0
  113. package/src/view/view-report.test.ts +308 -0
  114. package/docs-site/.mintignore +0 -13
  115. package/docs-site/AGENTS.md +0 -46
  116. package/docs-site/concepts/adapter.mdx +0 -287
  117. package/docs-site/concepts/assert.mdx +0 -243
  118. package/docs-site/concepts/drive.mdx +0 -118
  119. package/docs-site/concepts/evals.mdx +0 -108
  120. package/docs-site/concepts/experiment.mdx +0 -37
  121. package/docs-site/concepts/hitl.mdx +0 -83
  122. package/docs-site/concepts/judge.mdx +0 -129
  123. package/docs-site/concepts/overview.mdx +0 -98
  124. package/docs-site/concepts/tier.mdx +0 -42
  125. package/docs-site/docs-ref/00-index.md +0 -23
  126. package/docs-site/docs-ref/01-page-types.md +0 -43
  127. package/docs-site/docs-ref/03-style-rules.md +0 -45
  128. package/docs-site/docs-ref/04-code-examples.md +0 -37
  129. package/docs-site/docs-ref/05-dx-failure-paths.md +0 -45
  130. package/docs-site/docs-ref/06-checklists.md +0 -53
  131. package/docs-site/docs.json +0 -256
  132. package/docs-site/example/ai-agent-application.mdx +0 -148
  133. package/docs-site/example/claude-code-codex-plugin.mdx +0 -162
  134. package/docs-site/example/claude-code-codex-skill.mdx +0 -147
  135. package/docs-site/example/showcase.mdx +0 -39
  136. package/docs-site/example/tier1-ai-sdk-v7.mdx +0 -136
  137. package/docs-site/example/tier1-claude-sdk.mdx +0 -119
  138. package/docs-site/example/tier1-codex-sdk.mdx +0 -111
  139. package/docs-site/example/tier1-langgraph.mdx +0 -119
  140. package/docs-site/example/tier1-pi-sdk.mdx +0 -119
  141. package/docs-site/favicon.svg +0 -5
  142. package/docs-site/github-diff.css +0 -135
  143. package/docs-site/github-diff.js +0 -11
  144. package/docs-site/guides/authoring.mdx +0 -129
  145. package/docs-site/guides/ci-integration.mdx +0 -97
  146. package/docs-site/guides/connect-otel.mdx +0 -209
  147. package/docs-site/guides/connect-your-agent.mdx +0 -221
  148. package/docs-site/guides/dataset-fanout.mdx +0 -98
  149. package/docs-site/guides/experiments.mdx +0 -71
  150. package/docs-site/guides/fixtures.mdx +0 -147
  151. package/docs-site/guides/reporters.mdx +0 -113
  152. package/docs-site/guides/runner.mdx +0 -79
  153. package/docs-site/guides/sandbox-agent.mdx +0 -138
  154. package/docs-site/guides/sandbox-backends.mdx +0 -64
  155. package/docs-site/guides/scoring-guide.mdx +0 -92
  156. package/docs-site/guides/viewing-results.mdx +0 -195
  157. package/docs-site/guides/write-experiment.mdx +0 -81
  158. package/docs-site/guides/write-send.mdx +0 -347
  159. package/docs-site/index.mdx +0 -181
  160. package/docs-site/introduction.mdx +0 -141
  161. package/docs-site/quickstart.mdx +0 -136
  162. package/docs-site/reference/builtin-agents.mdx +0 -161
  163. package/docs-site/reference/capabilities.mdx +0 -76
  164. package/docs-site/reference/cli.mdx +0 -120
  165. package/docs-site/reference/define-agent.mdx +0 -168
  166. package/docs-site/reference/define-config.mdx +0 -41
  167. package/docs-site/reference/define-eval.mdx +0 -160
  168. package/docs-site/reference/events.mdx +0 -131
  169. package/docs-site/reference/expect.mdx +0 -112
  170. package/docs-site/tracker.js +0 -8
  171. package/src/report/react/data.ts +0 -17
  172. package/src/view/aggregate.ts +0 -103
  173. package/src/view/loader.test.ts +0 -61
  174. package/src/view/loader.ts +0 -257
@@ -1,12 +1,11 @@
1
- // Results Format 的布局与版本知识(读取面),规则见 docs/results-format.md。
1
+ // Results Format 的布局与版本知识,规则见 docs/results-format.md。
2
2
  //
3
- // 版本判定与 src/view/loader.ts normalizeSummary 同一口径(view 抛错驱动 CLI 提示,
4
- // 这里返回分类值驱动 skipped 列表);attempt 目录规则与 src/runner/reporters/artifacts.ts
5
- // attemptDir 同一规则。按 results-lib 设计,这份知识最终应只住在本库,
6
- // view / reporter 改吃这里是后续收编步骤 —— 本实验不动它们的代码。
3
+ // docs/results-lib.md,这份知识只住在本库:写入面(writer.ts / copy.ts)与
4
+ // 读取面(open.ts)共用这里的目录规则与版本判定;src/runner/reporters/artifacts.ts
5
+ // writer 的薄壳,view(src/view/data.ts)经 openResults 消费,不自带布局知识。
7
6
 
8
- import type { EvalResult, RunSummary } from "../runner/types.ts";
9
- import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../runner/types.ts";
7
+ import type { EvalResult, RunSummary } from "../types.ts";
8
+ import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../types.ts";
10
9
  import type { ArtifactKind } from "./types.ts";
11
10
 
12
11
  /** attempt 工件子目录(相对 run 根):<evalId>/<agent>/<model>[/<experimentId>]/a<attempt>。 */
@@ -14,8 +13,8 @@ export function attemptDirOf(r: Pick<EvalResult, "id" | "agent" | "model" | "att
14
13
  const safe = (s: string) => s.replace(/[^\w.@-]/g, "_");
15
14
  // evalId 里的 / 保留作目录层级,其余危险字符替换。
16
15
  const id = r.id.replace(/[^\w./@-]/g, "_");
17
- // experiment 段与 writer(artifacts.ts 的 attemptDir)同一规则:两个实验可以同 agent 同 model
18
- // 只差 flags,少这一段它们的工件会互相覆盖;experimentId 里的 / 不作层级(整段压成 _)
16
+ // experiment 段:两个实验可以同 agent 同 model、只差实验参数,少这一段它们的工件会互相
17
+ // 覆盖;experimentId 里的 / 不作层级(整段压成 _),整个实验一格。
19
18
  const exp = r.experimentId ? `/${safe(r.experimentId)}` : "";
20
19
  return `${id}/${safe(r.agent)}/${safe(r.model ?? "default")}${exp}/a${r.attempt}`;
21
20
  }
@@ -28,7 +27,7 @@ export function artifactFileOf(kind: ArtifactKind): string {
28
27
  /** summary.json 的版本判定结果;openResults 按它分流 ok / skipped / 静默忽略。 */
29
28
  export type SummaryClassification =
30
29
  | { kind: "ok"; summary: RunSummary }
31
- | { kind: "incompatible"; schemaVersion: number; producerVersion?: string }
30
+ | { kind: "incompatible"; schemaVersion: number; producer?: RunSummary["producer"] }
32
31
  | { kind: "malformed"; detail: string }
33
32
  | { kind: "not-a-report" };
34
33
 
@@ -49,7 +48,9 @@ export function classifySummary(raw: unknown): SummaryClassification {
49
48
  const version = data.schemaVersion ?? 1;
50
49
  if (typeof version !== "number") return { kind: "malformed", detail: "schemaVersion is not a number" };
51
50
  if (version !== RESULTS_SCHEMA_VERSION) {
52
- return { kind: "incompatible", schemaVersion: version, producerVersion: data.producer?.version };
51
+ // skipped 必须带完整 producer(name + version):npx 提示只对 name === "niceeval" 成立,
52
+ // 第三方 harness 的落盘只给裸版本号的话,消费方连做对这个分支的信息都没有。
53
+ return { kind: "incompatible", schemaVersion: version, producer: data.producer };
53
54
  }
54
55
  if (!Array.isArray(data.results) || typeof data.startedAt !== "string") {
55
56
  return { kind: "malformed", detail: "missing results[] or startedAt" };
@@ -1,27 +1,44 @@
1
- // niceeval/results —— 实验结果数据的读写库(设计见 docs/results-lib.md)。
1
+ // niceeval/results —— 实验结果数据的读写库(定稿见 docs/results-lib.md)。
2
2
  //
3
- // 本实验先落读取面:openResults / latestPerExperiment / dedupeAttempts / copyRun。
4
- // TODO(results-lib): 写入面 createRunWriter 落地后,src/runner/reporters/artifacts.ts
5
- // 变薄壳、src/view/loader.ts 改吃这里的 reader —— 布局知识全宇宙只剩这一份实现。
3
+ // 读:openResults(实验 快照 eval attempt 分层、skipped、runDirs、latest() 选集);
4
+ // 写:createRunWriter(快照级声明 + attempt 增量落盘 + summary 收尾);
5
+ // 发布:copySnapshots(格式感知复制 + knownEvalIds 补记);
6
+ // 身份:dedupeAttempts(跨快照聚合前按 (experimentId, evalId, attempt, startedAt) 去重)。
7
+ // 布局知识(路径、清洗、拆分、版本)全宇宙只有这一份实现;
8
+ // src/runner/reporters/artifacts.ts 是写入面的薄壳,view 的读取收编是下一波。
6
9
 
7
10
  export { openResults } from "./open.ts";
8
- export { dedupeAttempts, latestPerExperiment } from "./select.ts";
9
- export { copyRun, type CopiedRun, type CopyRunOptions } from "./copy.ts";
11
+ export { dedupeAttempts } from "./select.ts";
12
+ export { copySnapshots, type CopySnapshotsOptions, type CopySnapshotsResult } from "./copy.ts";
13
+ export {
14
+ createRunWriter,
15
+ type AttemptArtifacts,
16
+ type AttemptEntry,
17
+ type FinishOverrides,
18
+ type RunWriter,
19
+ type RunWriterOptions,
20
+ type SnapshotDeclaration,
21
+ type SnapshotWriter,
22
+ } from "./writer.ts";
10
23
  export {
11
24
  ARTIFACT_KINDS,
12
25
  type ArtifactKind,
13
26
  type AttemptHandle,
14
27
  type AttemptRef,
15
- type ResultsCollection,
16
- type RunHandle,
17
- type SkippedResultsRun,
18
- type SnapshotHandle,
28
+ type DedupeWarning,
29
+ type Eval,
30
+ type Experiment,
31
+ type Results,
32
+ type RunDir,
33
+ type Selection,
34
+ type SelectionWarning,
35
+ type SkippedRun,
36
+ type Snapshot,
19
37
  } from "./types.ts";
20
38
 
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";
39
+ // 结果数据类型的家还没搬(facade 迁移是下一波);先从这里 re-export,
40
+ // 让消费方从一个入口拿全「分层句柄 + 数据类型 + 格式常量」。
41
+ export { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../types.ts";
42
+ export type { EvalResult, ExperimentRunInfo, RunSummary } from "../types.ts";
43
+ export type { O11ySummary, StreamEvent, TraceSpan, Usage } from "../types.ts";
44
+ export type { DiffData, SourceArtifact } from "../types.ts";
@@ -1,75 +1,132 @@
1
- // openResults:扫描结果目录,返回类型化句柄(设计见 docs/results-lib.md「读:openResults」)。
1
+ // openResults:扫描结果目录,返回「实验 → 快照 → eval → attempt」的类型化层次
2
+ // (定稿见 docs/results-lib.md「读:openResults」)。
2
3
  //
3
4
  // 三条铁律:
4
- // - runs 忠实磁盘,不合并不去重;合并/聚合永远发生在消费方。
5
- // - 读不了的 run 进 skipped,不静默丢,也不抛错(单个坏 run 不拖垮整次扫描)。
5
+ // - 忠实磁盘:快照与实验归组只切片,不合并、不聚合、不去重;合并/聚合永远发生在消费方。
6
+ // - 读不了的落盘进 skipped(三种原因),不静默丢,也不抛错(单个坏 run 不拖垮整次扫描)。
6
7
  // - 重工件全部懒加载:缺失返回 null(存在性判断被方法语义吸收),同一 handle 内记忆化。
7
8
 
8
9
  import { readFile, readdir, stat } from "node:fs/promises";
9
10
  import { basename, dirname, join, resolve } from "node:path";
10
- import type { EvalResult } from "../runner/types.ts";
11
- import type { O11ySummary, StreamEvent, TraceSpan } from "../o11y/types.ts";
12
- import type { DiffData } from "../scoring/types.ts";
13
- import type { SourceArtifact } from "../shared/types.ts";
11
+ import type { EvalResult, RunSummary } from "../types.ts";
12
+ import type { O11ySummary, StreamEvent, TraceSpan } from "../types.ts";
13
+ import type { DiffData, SourceArtifact } from "../types.ts";
14
14
  import { artifactFileOf, classifySummary, experimentKeyOf } from "./format.ts";
15
- import type { ArtifactKind, AttemptHandle, ResultsCollection, RunHandle, SkippedResultsRun, SnapshotHandle } from "./types.ts";
15
+ import { isNewerRunDir, selectLatest } from "./select.ts";
16
+ import type {
17
+ ArtifactKind,
18
+ AttemptHandle,
19
+ Eval,
20
+ Experiment,
21
+ Results,
22
+ RunDir,
23
+ Selection,
24
+ SkippedRun,
25
+ Snapshot,
26
+ } from "./types.ts";
27
+ import { ARTIFACT_KINDS } from "./types.ts";
28
+
29
+ // copySnapshots 补记 knownEvalIds 需要「复制时刻该实验的 evalIds」,而 Snapshot 上按定稿
30
+ // 不挂 Experiment 反向指针 —— 用模块级 WeakMap 记归属,只供库内部(copy.ts)取用。
31
+ const experimentBySnapshot = new WeakMap<Snapshot, Experiment>();
32
+
33
+ /** 库内部:快照所属的 Experiment(仅对 openResults 产出的快照存在)。 */
34
+ export function experimentOfSnapshot(snapshot: Snapshot): Experiment | undefined {
35
+ return experimentBySnapshot.get(snapshot);
36
+ }
16
37
 
17
38
  /**
18
39
  * 打开 `.niceeval/` 根目录、单个 run 目录,或直接指向某个 summary.json 的路径。
19
- * 目录不存在返回空集合(还没跑过 eval 不是错误);任何读不了的 run 进 skipped,不抛错。
40
+ * 目录不存在返回空集合(还没跑过 eval 不是错误);任何读不了的落盘进 skipped,不抛错。
20
41
  */
21
- export async function openResults(dir: string): Promise<ResultsCollection> {
42
+ export async function openResults(dir: string): Promise<Results> {
22
43
  const target = resolve(dir);
23
- const collection: ResultsCollection = { runs: [], snapshots: [], skipped: [], warnings: [] };
44
+ const runDirs: RunDir[] = [];
45
+ const skipped: SkippedRun[] = [];
24
46
 
25
47
  let targetStat;
26
48
  try {
27
49
  targetStat = await stat(target);
28
50
  } catch {
29
- return collection;
51
+ return makeResults([], skipped, runDirs);
52
+ }
53
+
54
+ const summaryPaths: string[] = [];
55
+ if (targetStat.isFile()) {
56
+ summaryPaths.push(target);
57
+ } else {
58
+ const direct = join(target, "summary.json");
59
+ if (await fileExists(direct)) {
60
+ // target 本身是 run 目录:它的子目录是 attempt 工件目录,不做 incomplete 探测。
61
+ summaryPaths.push(direct, ...(await findSummaryFiles(target, direct)));
62
+ } else {
63
+ // target 是结果根:逐个 immediate child 判定 —— 有 summary 的收 run;
64
+ // 没有 summary 但有 attempt 工件的 = crash 没收尾,进 skipped("incomplete")。
65
+ const entries = await readdir(target, { withFileTypes: true });
66
+ for (const entry of entries.filter((e) => e.isDirectory())) {
67
+ const childDir = join(target, entry.name);
68
+ const found = await findSummaryFiles(childDir);
69
+ if (found.length > 0) {
70
+ summaryPaths.push(...found);
71
+ } else if (await hasArtifactFiles(childDir)) {
72
+ skipped.push({ dir: childDir, reason: "incomplete" });
73
+ }
74
+ }
75
+ }
30
76
  }
31
- const summaryPaths = targetStat.isFile() ? [target] : await findSummaryFiles(target);
32
77
 
33
78
  for (const path of summaryPaths) {
34
79
  const outcome = await readRun(path);
35
- if (outcome.kind === "run") collection.runs.push(outcome.run);
36
- else if (outcome.kind === "skipped") collection.skipped.push(outcome.entry);
80
+ if (outcome.kind === "run") runDirs.push(outcome.run);
81
+ else if (outcome.kind === "skipped") skipped.push(outcome.entry);
37
82
  // not-a-report:无关 JSON,静默忽略。
38
83
  }
39
84
 
40
85
  // 最新在前;run 目录名是时间戳,startedAt 同刻时按目录名降序兜底。
41
- collection.runs.sort((a, b) => b.summary.startedAt.localeCompare(a.summary.startedAt) || b.dir.localeCompare(a.dir));
42
- collection.skipped.sort((a, b) => b.dir.localeCompare(a.dir));
86
+ runDirs.sort((a, b) => (isNewerRunDir(a, b) ? -1 : 1));
87
+ skipped.sort((a, b) => b.dir.localeCompare(a.dir));
43
88
 
44
- for (const run of collection.runs) {
45
- collection.snapshots.push(...sliceSnapshots(run, collection.warnings));
46
- }
47
- return collection;
89
+ const snapshots: Snapshot[] = [];
90
+ for (const run of runDirs) snapshots.push(...sliceSnapshots(run));
91
+ return makeResults(buildExperiments(snapshots), skipped, runDirs);
92
+ }
93
+
94
+ function makeResults(experiments: Experiment[], skipped: SkippedRun[], runDirs: RunDir[]): Results {
95
+ return {
96
+ experiments,
97
+ skipped,
98
+ runDirs,
99
+ latest(opts?: { experiments?: string | string[] }): Selection {
100
+ return selectLatest(experiments, opts);
101
+ },
102
+ };
48
103
  }
49
104
 
105
+ // ───────────────────────── 单个 run 的读取 ─────────────────────────
106
+
50
107
  type ReadRunOutcome =
51
- | { kind: "run"; run: RunHandle }
52
- | { kind: "skipped"; entry: SkippedResultsRun }
108
+ | { kind: "run"; run: RunDir }
109
+ | { kind: "skipped"; entry: SkippedRun }
53
110
  | { kind: "not-a-report" };
54
111
 
55
112
  async function readRun(path: string): Promise<ReadRunOutcome> {
56
113
  const runDir = dirname(path);
57
- const skipped = (entry: Omit<SkippedResultsRun, "dir" | "path">): ReadRunOutcome => ({
114
+ const skippedEntry = (entry: Omit<SkippedRun, "dir">): ReadRunOutcome => ({
58
115
  kind: "skipped",
59
- entry: { dir: runDir, path, ...entry },
116
+ entry: { dir: runDir, ...entry },
60
117
  });
61
118
 
62
119
  let text: string;
63
120
  try {
64
121
  text = await readFile(path, "utf-8");
65
122
  } catch (e) {
66
- return skipped({ reason: "malformed", detail: `cannot read file (${e instanceof Error ? e.message : String(e)})` });
123
+ return skippedEntry({ reason: "malformed", detail: `cannot read file (${e instanceof Error ? e.message : String(e)})` });
67
124
  }
68
125
  let raw: unknown;
69
126
  try {
70
127
  raw = JSON.parse(text);
71
128
  } catch {
72
- return skipped({ reason: "malformed", detail: "invalid JSON" });
129
+ return skippedEntry({ reason: "malformed", detail: "invalid JSON" });
73
130
  }
74
131
 
75
132
  const classified = classifySummary(raw);
@@ -77,34 +134,114 @@ async function readRun(path: string): Promise<ReadRunOutcome> {
77
134
  case "not-a-report":
78
135
  return { kind: "not-a-report" };
79
136
  case "malformed":
80
- return skipped({ reason: "malformed", detail: classified.detail });
137
+ return skippedEntry({ reason: "malformed", detail: classified.detail });
81
138
  case "incompatible":
82
- return skipped({
139
+ return skippedEntry({
83
140
  reason: "incompatible-version",
84
141
  schemaVersion: classified.schemaVersion,
85
- producerVersion: classified.producerVersion,
142
+ ...(classified.producer ? { producer: classified.producer } : {}),
86
143
  });
87
144
  case "ok": {
88
- const run: RunHandle = { dir: runDir, summary: classified.summary, attempts: [] };
89
- run.attempts = classified.summary.results.map((r, i) => makeAttempt(run, r, i));
145
+ const run: RunDir = { dir: runDir, summary: classified.summary, attempts: [] };
90
146
  return { kind: "run", run };
91
147
  }
92
148
  }
93
149
  }
94
150
 
151
+ // ───────────────────────── 快照切片与实验归组 ─────────────────────────
152
+
153
+ /**
154
+ * 把一个 run 按 experiment 身份切成快照,同时填充 run.attempts(按 results[] 下标顺序)。
155
+ * 只切片、不合并、不去重;experimentId 缺失时以 "<agent>/<model>" 合成键,synthetic: true
156
+ * (对应警告由 latest() 生成,归属 Selection)。
157
+ */
158
+ function sliceSnapshots(run: RunDir): Snapshot[] {
159
+ const summary = run.summary;
160
+ const byExperiment = new Map<string, Snapshot>();
161
+ const evalsByExperiment = new Map<string, Map<string, Eval>>();
162
+
163
+ summary.results.forEach((result, index) => {
164
+ const key = experimentKeyOf(result);
165
+ let snapshot = byExperiment.get(key.id);
166
+ if (!snapshot) {
167
+ const meta = summary.snapshots?.[key.id];
168
+ snapshot = {
169
+ experimentId: key.id,
170
+ startedAt: meta?.startedAt ?? summary.startedAt,
171
+ agent: result.agent,
172
+ ...(result.model !== undefined ? { model: result.model } : {}),
173
+ ...(summary.producer ? { producer: summary.producer } : {}),
174
+ schemaVersion: summary.schemaVersion ?? 1,
175
+ evals: [],
176
+ attempts: [],
177
+ runDir: run,
178
+ ...(key.synthesized ? { synthetic: true } : {}),
179
+ ...(meta?.knownEvalIds ? { knownEvalIds: [...meta.knownEvalIds] } : {}),
180
+ };
181
+ byExperiment.set(key.id, snapshot);
182
+ evalsByExperiment.set(key.id, new Map());
183
+ }
184
+ const attempt = makeAttempt(run, key.id, result, index);
185
+ run.attempts.push(attempt);
186
+ const evals = evalsByExperiment.get(key.id)!;
187
+ let ev = evals.get(result.id);
188
+ if (!ev) {
189
+ ev = { id: result.id, attempts: [] };
190
+ evals.set(result.id, ev);
191
+ snapshot.evals.push(ev);
192
+ }
193
+ ev.attempts.push(attempt);
194
+ });
195
+
196
+ for (const snapshot of byExperiment.values()) {
197
+ // attempts 平铺 = evals 逐题展开(同题的重试相邻)。
198
+ snapshot.attempts = snapshot.evals.flatMap((ev) => ev.attempts);
199
+ }
200
+ return [...byExperiment.values()];
201
+ }
202
+
203
+ /** 同一 experiment id 的历次快照归在一起;实验按 id 字典序,快照最新在前。 */
204
+ function buildExperiments(snapshots: Snapshot[]): Experiment[] {
205
+ const byId = new Map<string, Snapshot[]>();
206
+ for (const snapshot of snapshots) {
207
+ const group = byId.get(snapshot.experimentId);
208
+ if (group) group.push(snapshot);
209
+ else byId.set(snapshot.experimentId, [snapshot]);
210
+ }
211
+
212
+ const experiments: Experiment[] = [];
213
+ for (const [id, group] of byId) {
214
+ group.sort((a, b) => b.startedAt.localeCompare(a.startedAt) || b.runDir.dir.localeCompare(a.runDir.dir));
215
+ // 已知并集 = 本地历史(各快照覆盖的题)∪ 各快照携带的 knownEvalIds ——
216
+ // 不是「优先字段」:把快照复制进已有历史的目录时,本地并集可能更大,优先字段会让分母缩水。
217
+ const ids = new Set<string>();
218
+ for (const snapshot of group) {
219
+ for (const ev of snapshot.evals) ids.add(ev.id);
220
+ for (const known of snapshot.knownEvalIds ?? []) ids.add(known);
221
+ }
222
+ const experiment: Experiment = { id, snapshots: group, latest: group[0], evalIds: [...ids].sort() };
223
+ for (const snapshot of group) experimentBySnapshot.set(snapshot, experiment);
224
+ experiments.push(experiment);
225
+ }
226
+ experiments.sort((a, b) => a.id.localeCompare(b.id));
227
+ return experiments;
228
+ }
229
+
95
230
  // ───────────────────────── attempt 懒加载 ─────────────────────────
96
231
 
97
- function makeAttempt(run: RunHandle, result: EvalResult, index: number): AttemptHandle {
232
+ function makeAttempt(run: RunDir, experimentId: string, result: EvalResult, index: number): AttemptHandle {
98
233
  // 候选工件目录:本 run 下的 artifactsDir 为主;--resume / 跨实验携带合入的条目,其工件
99
- // 留在旧 run 目录里,summary 里的 artifactBase(相对结果根目录)指向那里,作为回退。
234
+ // 留在原 run 目录里,summary 里的 artifactBase(相对结果根目录)指向那里,作为回退。
100
235
  const candidates: string[] = [];
101
236
  if (result.artifactsDir) candidates.push(join(run.dir, result.artifactsDir));
102
237
  if (result.artifactBase) candidates.push(join(dirname(run.dir), result.artifactBase));
103
238
 
104
239
  return {
105
- run,
106
- ref: { run: basename(run.dir), result: index },
240
+ evalId: result.id,
241
+ experimentId,
107
242
  result,
243
+ ref: { run: basename(run.dir), result: index },
244
+ runDir: run,
108
245
  events: lazyArtifact<StreamEvent[]>(candidates, "events", result.events),
109
246
  trace: lazyArtifact<TraceSpan[]>(candidates, "trace", result.trace),
110
247
  o11y: lazyArtifact<O11ySummary>(candidates, "o11y", result.o11y),
@@ -153,55 +290,38 @@ function isMissingFile(e: unknown): boolean {
153
290
  return code === "ENOENT" || code === "ENOTDIR";
154
291
  }
155
292
 
156
- // ───────────────────────── 快照切片 ─────────────────────────
157
-
158
- /**
159
- * 把一个 run 按 experiment 身份切成快照;只切片、不合并、不去重。
160
- * experimentId 缺失时以 "<agent>/<model>" 合成键,并出集合级 warning(每 run 每键一条)。
161
- */
162
- function sliceSnapshots(run: RunHandle, warnings: string[]): SnapshotHandle[] {
163
- const byExperiment = new Map<string, SnapshotHandle>();
164
- const evalIdSeen = new Map<string, Set<string>>();
165
- for (const attempt of run.attempts) {
166
- const key = experimentKeyOf(attempt.result);
167
- let snapshot = byExperiment.get(key.id);
168
- if (!snapshot) {
169
- if (key.synthesized) {
170
- warnings.push(
171
- `warning: run "${run.dir}" has results without experimentId; grouped as "${key.id}" for snapshot identity.`,
172
- );
173
- }
174
- snapshot = {
175
- experimentId: key.id,
176
- run,
177
- startedAt: run.summary.startedAt,
178
- agent: attempt.result.agent,
179
- model: attempt.result.model,
180
- attempts: [],
181
- evalIds: [],
182
- ...(key.synthesized ? { synthetic: true } : {}),
183
- };
184
- byExperiment.set(key.id, snapshot);
185
- evalIdSeen.set(key.id, new Set());
186
- }
187
- snapshot.attempts.push(attempt);
188
- const seen = evalIdSeen.get(key.id)!;
189
- if (!seen.has(attempt.result.id)) {
190
- seen.add(attempt.result.id);
191
- snapshot.evalIds.push(attempt.result.id);
192
- }
193
- }
194
- return [...byExperiment.values()];
195
- }
196
-
197
293
  // ───────────────────────── 目录扫描 ─────────────────────────
198
294
 
199
- /** 递归找出全部 summary.json(与 view 的扫描同一姿势;run 目录嵌套深度不做假设) */
200
- async function findSummaryFiles(dir: string): Promise<string[]> {
295
+ /** 递归找出全部 summary.json(run 目录嵌套深度不做假设);skip 用于排除已收录的直达文件。 */
296
+ async function findSummaryFiles(dir: string, skip?: string): Promise<string[]> {
201
297
  const entries = await readdir(dir, { withFileTypes: true });
202
- const direct = entries.filter((e) => e.isFile() && e.name === "summary.json").map((e) => join(dir, e.name));
298
+ const direct = entries
299
+ .filter((e) => e.isFile() && e.name === "summary.json")
300
+ .map((e) => join(dir, e.name))
301
+ .filter((p) => p !== skip);
203
302
  const nested = await Promise.all(
204
- entries.filter((e) => e.isDirectory()).map((e) => findSummaryFiles(join(dir, e.name))),
303
+ entries.filter((e) => e.isDirectory()).map((e) => findSummaryFiles(join(dir, e.name), skip)),
205
304
  );
206
305
  return [...direct, ...nested.flat()];
207
306
  }
307
+
308
+ /** 目录下(递归)是否存在任何 attempt 工件文件 —— incomplete 判定的依据。 */
309
+ async function hasArtifactFiles(dir: string): Promise<boolean> {
310
+ const artifactNames = new Set<string>(ARTIFACT_KINDS.map((kind) => artifactFileOf(kind)));
311
+ const entries = await readdir(dir, { withFileTypes: true });
312
+ for (const entry of entries) {
313
+ if (entry.isFile() && artifactNames.has(entry.name)) return true;
314
+ }
315
+ for (const entry of entries) {
316
+ if (entry.isDirectory() && (await hasArtifactFiles(join(dir, entry.name)))) return true;
317
+ }
318
+ return false;
319
+ }
320
+
321
+ async function fileExists(path: string): Promise<boolean> {
322
+ try {
323
+ return (await stat(path)).isFile();
324
+ } catch {
325
+ return false;
326
+ }
327
+ }