niceeval 0.3.0 → 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.
- package/README.zh.md +11 -0
- package/docs/README.md +2 -1
- package/docs/adapters/collection.md +1 -1
- package/docs/adapters/contract.md +6 -5
- package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
- package/docs/assertions.md +0 -1
- package/docs/capabilities-by-construction.md +2 -2
- package/docs/cli.md +2 -2
- package/docs/concepts.md +2 -2
- package/docs/e2e-ci.md +135 -98
- package/docs/eval-authoring.md +3 -3
- package/docs/experiments.md +7 -4
- package/docs/observability.md +9 -5
- package/docs/origin-integration.md +18 -18
- package/docs/references.md +1 -1
- package/docs/reports.md +551 -0
- package/docs/results-format.md +3 -3
- package/docs/results-lib.md +191 -0
- package/docs/runner.md +1 -1
- package/docs/sandbox.md +1 -1
- package/docs/source-map.md +22 -2
- package/docs/tier-sync.md +74 -58
- package/docs/view.md +39 -5
- package/package.json +29 -3
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/claude-code.ts +18 -1
- package/src/agents/codex.ts +3 -2
- package/src/agents/index.ts +16 -0
- package/src/agents/openai-compat.test.ts +56 -0
- package/src/agents/openai-compat.ts +151 -0
- package/src/agents/types.ts +3 -1
- package/src/cli.ts +3 -2
- package/src/context/context.test.ts +78 -1
- package/src/context/context.ts +19 -11
- package/src/context/session.ts +2 -0
- package/src/context/types.ts +11 -5
- package/src/i18n/en.ts +3 -4
- package/src/i18n/zh-CN.ts +2 -3
- package/src/o11y/cost.test.ts +40 -0
- package/src/o11y/cost.ts +27 -5
- package/src/o11y/derive.ts +2 -2
- package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
- package/src/o11y/otlp/mappers/claude-code.ts +24 -0
- package/src/o11y/otlp/mappers/index.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +127 -0
- package/src/o11y/prices.json +176 -119
- package/src/o11y/types.ts +2 -1
- package/src/report/aggregate.ts +215 -0
- package/src/report/compute.ts +405 -0
- package/src/report/format.ts +47 -0
- package/src/report/index.ts +40 -0
- package/src/report/metrics.ts +96 -0
- package/src/report/react/CaseList.tsx +70 -0
- package/src/report/react/DeltaTable.tsx +79 -0
- package/src/report/react/MetricMatrix.tsx +75 -0
- package/src/report/react/MetricScatter.tsx +217 -0
- package/src/report/react/MetricTable.tsx +71 -0
- package/src/report/react/RunOverview.tsx +87 -0
- package/src/report/react/Scoreboard.tsx +87 -0
- package/src/report/react/cell.tsx +49 -0
- package/src/report/react/colors.ts +42 -0
- package/src/report/react/data.ts +17 -0
- package/src/report/react/fixtures.ts +236 -0
- package/src/report/react/format.ts +34 -0
- package/src/report/react/index.tsx +34 -0
- package/src/report/react/render.test.tsx +303 -0
- package/src/report/react/styles.css +302 -0
- package/src/report/report.test.ts +667 -0
- package/src/report/types.ts +210 -0
- package/src/results/copy.ts +200 -0
- package/src/results/format.ts +75 -0
- package/src/results/index.ts +27 -0
- package/src/results/open.ts +207 -0
- package/src/results/results.test.ts +359 -0
- package/src/results/select.ts +101 -0
- package/src/results/types.ts +98 -0
- package/src/runner/attempt.ts +3 -1
- package/src/runner/report.test.ts +111 -0
- package/src/runner/report.ts +52 -1
- package/src/runner/reporters/artifacts.ts +1 -1
- package/src/runner/reporters/braintrust.test.ts +106 -0
- package/src/runner/reporters/braintrust.ts +197 -0
- package/src/runner/reporters/index.ts +3 -1
- package/src/runner/run.ts +30 -19
- package/src/runner/types.ts +17 -0
- package/src/sandbox/types.ts +3 -3
- package/src/view/app/App.tsx +82 -17
- package/src/view/app/components/CostScoreChart.tsx +127 -0
- package/src/view/app/i18n.ts +10 -1
- package/src/view/app/lib/attempt-route.test.ts +89 -0
- package/src/view/app/lib/attempt-route.ts +52 -0
- package/src/view/app/lib/chart.ts +72 -0
- package/src/view/app/lib/outcome.ts +1 -1
- package/src/view/app/types.ts +4 -5
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/src/view/index.ts +12 -18
- package/src/view/loader.ts +14 -7
- package/src/view/shared/types.ts +14 -1
- package/src/view/styles.css +43 -0
- package/src/view/template.html +0 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// openResults:扫描结果目录,返回类型化句柄(设计见 docs/results-lib.md「读:openResults」)。
|
|
2
|
+
//
|
|
3
|
+
// 三条铁律:
|
|
4
|
+
// - runs 忠实磁盘,不合并不去重;合并/聚合永远发生在消费方。
|
|
5
|
+
// - 读不了的 run 进 skipped,不静默丢,也不抛错(单个坏 run 不拖垮整次扫描)。
|
|
6
|
+
// - 重工件全部懒加载:缺失返回 null(存在性判断被方法语义吸收),同一 handle 内记忆化。
|
|
7
|
+
|
|
8
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
9
|
+
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";
|
|
14
|
+
import { artifactFileOf, classifySummary, experimentKeyOf } from "./format.ts";
|
|
15
|
+
import type { ArtifactKind, AttemptHandle, ResultsCollection, RunHandle, SkippedResultsRun, SnapshotHandle } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 打开 `.niceeval/` 根目录、单个 run 目录,或直接指向某个 summary.json 的路径。
|
|
19
|
+
* 目录不存在返回空集合(还没跑过 eval 不是错误);任何读不了的 run 进 skipped,不抛错。
|
|
20
|
+
*/
|
|
21
|
+
export async function openResults(dir: string): Promise<ResultsCollection> {
|
|
22
|
+
const target = resolve(dir);
|
|
23
|
+
const collection: ResultsCollection = { runs: [], snapshots: [], skipped: [], warnings: [] };
|
|
24
|
+
|
|
25
|
+
let targetStat;
|
|
26
|
+
try {
|
|
27
|
+
targetStat = await stat(target);
|
|
28
|
+
} catch {
|
|
29
|
+
return collection;
|
|
30
|
+
}
|
|
31
|
+
const summaryPaths = targetStat.isFile() ? [target] : await findSummaryFiles(target);
|
|
32
|
+
|
|
33
|
+
for (const path of summaryPaths) {
|
|
34
|
+
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);
|
|
37
|
+
// not-a-report:无关 JSON,静默忽略。
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 最新在前;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));
|
|
43
|
+
|
|
44
|
+
for (const run of collection.runs) {
|
|
45
|
+
collection.snapshots.push(...sliceSnapshots(run, collection.warnings));
|
|
46
|
+
}
|
|
47
|
+
return collection;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ReadRunOutcome =
|
|
51
|
+
| { kind: "run"; run: RunHandle }
|
|
52
|
+
| { kind: "skipped"; entry: SkippedResultsRun }
|
|
53
|
+
| { kind: "not-a-report" };
|
|
54
|
+
|
|
55
|
+
async function readRun(path: string): Promise<ReadRunOutcome> {
|
|
56
|
+
const runDir = dirname(path);
|
|
57
|
+
const skipped = (entry: Omit<SkippedResultsRun, "dir" | "path">): ReadRunOutcome => ({
|
|
58
|
+
kind: "skipped",
|
|
59
|
+
entry: { dir: runDir, path, ...entry },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let text: string;
|
|
63
|
+
try {
|
|
64
|
+
text = await readFile(path, "utf-8");
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return skipped({ reason: "malformed", detail: `cannot read file (${e instanceof Error ? e.message : String(e)})` });
|
|
67
|
+
}
|
|
68
|
+
let raw: unknown;
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(text);
|
|
71
|
+
} catch {
|
|
72
|
+
return skipped({ reason: "malformed", detail: "invalid JSON" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const classified = classifySummary(raw);
|
|
76
|
+
switch (classified.kind) {
|
|
77
|
+
case "not-a-report":
|
|
78
|
+
return { kind: "not-a-report" };
|
|
79
|
+
case "malformed":
|
|
80
|
+
return skipped({ reason: "malformed", detail: classified.detail });
|
|
81
|
+
case "incompatible":
|
|
82
|
+
return skipped({
|
|
83
|
+
reason: "incompatible-version",
|
|
84
|
+
schemaVersion: classified.schemaVersion,
|
|
85
|
+
producerVersion: classified.producerVersion,
|
|
86
|
+
});
|
|
87
|
+
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));
|
|
90
|
+
return { kind: "run", run };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ───────────────────────── attempt 懒加载 ─────────────────────────
|
|
96
|
+
|
|
97
|
+
function makeAttempt(run: RunHandle, result: EvalResult, index: number): AttemptHandle {
|
|
98
|
+
// 候选工件目录:本 run 下的 artifactsDir 为主;--resume / 跨实验携带合入的条目,其工件
|
|
99
|
+
// 留在旧 run 目录里,summary 里的 artifactBase(相对结果根目录)指向那里,作为回退。
|
|
100
|
+
const candidates: string[] = [];
|
|
101
|
+
if (result.artifactsDir) candidates.push(join(run.dir, result.artifactsDir));
|
|
102
|
+
if (result.artifactBase) candidates.push(join(dirname(run.dir), result.artifactBase));
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
run,
|
|
106
|
+
ref: { run: basename(run.dir), result: index },
|
|
107
|
+
result,
|
|
108
|
+
events: lazyArtifact<StreamEvent[]>(candidates, "events", result.events),
|
|
109
|
+
trace: lazyArtifact<TraceSpan[]>(candidates, "trace", result.trace),
|
|
110
|
+
o11y: lazyArtifact<O11ySummary>(candidates, "o11y", result.o11y),
|
|
111
|
+
diff: lazyArtifact<DiffData>(candidates, "diff", result.diff),
|
|
112
|
+
sources: lazyArtifact<SourceArtifact[]>(candidates, "sources", result.sources),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 单个工件的懒加载器:缺失返回 null(不抛错);同一 handle 内记忆化,diff 这类可达百 MB 的
|
|
118
|
+
* 文件绝不读两遍。summary 里内联了该字段时直接用(Json reporter 全量输出/外部工具转换的场景)。
|
|
119
|
+
* 文件存在但 JSON 损坏是真错误,抛英文错误而不是伪装成缺失;失败不缓存,允许重试。
|
|
120
|
+
*/
|
|
121
|
+
function lazyArtifact<T>(candidateDirs: string[], kind: ArtifactKind, inline: T | undefined): () => Promise<T | null> {
|
|
122
|
+
let memo: Promise<T | null> | undefined;
|
|
123
|
+
const load = async (): Promise<T | null> => {
|
|
124
|
+
if (inline !== undefined) return inline;
|
|
125
|
+
for (const dir of candidateDirs) {
|
|
126
|
+
const file = join(dir, artifactFileOf(kind));
|
|
127
|
+
let text: string;
|
|
128
|
+
try {
|
|
129
|
+
text = await readFile(file, "utf-8");
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (isMissingFile(e)) continue;
|
|
132
|
+
throw new Error(`Cannot read artifact ${file} (${e instanceof Error ? e.message : String(e)}).`);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(text) as T;
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error(`Artifact ${file} is not valid JSON. The file may be corrupted; re-run the eval or delete this attempt directory.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
142
|
+
return () => {
|
|
143
|
+
memo ??= load().catch((e: unknown) => {
|
|
144
|
+
memo = undefined;
|
|
145
|
+
throw e;
|
|
146
|
+
});
|
|
147
|
+
return memo;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isMissingFile(e: unknown): boolean {
|
|
152
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
153
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
154
|
+
}
|
|
155
|
+
|
|
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
|
+
// ───────────────────────── 目录扫描 ─────────────────────────
|
|
198
|
+
|
|
199
|
+
/** 递归找出全部 summary.json(与 view 的扫描同一姿势;run 目录嵌套深度不做假设)。 */
|
|
200
|
+
async function findSummaryFiles(dir: string): Promise<string[]> {
|
|
201
|
+
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));
|
|
203
|
+
const nested = await Promise.all(
|
|
204
|
+
entries.filter((e) => e.isDirectory()).map((e) => findSummaryFiles(join(dir, e.name))),
|
|
205
|
+
);
|
|
206
|
+
return [...direct, ...nested.flat()];
|
|
207
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// niceeval/results 读取面的单测:临时目录里手工构造最小 summary.json / 工件 fixture,
|
|
2
|
+
// 覆盖快照切片、latest-per-experiment、残缺警告、resume 去重、版本 skipped、懒加载。
|
|
3
|
+
// fixture 的目录名/工件路径全部手写(不 import 库的路径函数),让测试独立于实现充当格式基准。
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
RESULTS_FORMAT,
|
|
11
|
+
RESULTS_SCHEMA_VERSION,
|
|
12
|
+
copyRun,
|
|
13
|
+
dedupeAttempts,
|
|
14
|
+
latestPerExperiment,
|
|
15
|
+
openResults,
|
|
16
|
+
type EvalResult,
|
|
17
|
+
type RunSummary,
|
|
18
|
+
} from "./index.ts";
|
|
19
|
+
|
|
20
|
+
// ───────────────────────── fixture 工具 ─────────────────────────
|
|
21
|
+
|
|
22
|
+
const roots: string[] = [];
|
|
23
|
+
async function makeRoot(): Promise<string> {
|
|
24
|
+
const root = await mkdtemp(join(tmpdir(), "niceeval-results-"));
|
|
25
|
+
roots.push(root);
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function res(over: Partial<EvalResult> & Pick<EvalResult, "id" | "agent">): EvalResult {
|
|
33
|
+
return { outcome: "passed", attempt: 1, durationMs: 1000, assertions: [], ...over };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function summaryOf(results: EvalResult[], over: Partial<RunSummary> = {}): RunSummary {
|
|
37
|
+
const count = (o: EvalResult["outcome"]) => results.filter((r) => r.outcome === o).length;
|
|
38
|
+
return {
|
|
39
|
+
format: RESULTS_FORMAT,
|
|
40
|
+
schemaVersion: RESULTS_SCHEMA_VERSION,
|
|
41
|
+
producer: { name: "niceeval", version: "0.3.0" },
|
|
42
|
+
agent: results[0]?.agent ?? "agent",
|
|
43
|
+
startedAt: "2026-07-01T08:00:00.000Z",
|
|
44
|
+
completedAt: "2026-07-01T08:10:00.000Z",
|
|
45
|
+
passed: count("passed"),
|
|
46
|
+
failed: count("failed"),
|
|
47
|
+
skipped: count("skipped"),
|
|
48
|
+
errored: count("errored"),
|
|
49
|
+
durationMs: 60_000,
|
|
50
|
+
results,
|
|
51
|
+
...over,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writeRun(root: string, dirName: string, summary: unknown): Promise<string> {
|
|
56
|
+
const dir = join(root, dirName);
|
|
57
|
+
await mkdir(dir, { recursive: true });
|
|
58
|
+
await writeFile(join(dir, "summary.json"), typeof summary === "string" ? summary : JSON.stringify(summary, null, 2), "utf-8");
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeArtifact(runDir: string, relDir: string, file: string, data: unknown): Promise<string> {
|
|
63
|
+
const dir = join(runDir, relDir);
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
const path = join(dir, file);
|
|
66
|
+
await writeFile(path, JSON.stringify(data), "utf-8");
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function exists(path: string): Promise<boolean> {
|
|
71
|
+
try {
|
|
72
|
+
await stat(path);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ───────────────────────── 测试 ─────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("openResults · 快照切片", () => {
|
|
82
|
+
it("一个 run 装两个 experiment:按 experimentId 切成两个快照,agent/model/evalIds 取自切片自身", async () => {
|
|
83
|
+
const root = await makeRoot();
|
|
84
|
+
await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
85
|
+
res({ id: "algebra/q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", startedAt: "2026-07-01T08:01:00.000Z" }),
|
|
86
|
+
res({ id: "algebra/q2", agent: "bub", model: "gpt-5", experimentId: "compare/bub", outcome: "failed" }),
|
|
87
|
+
res({ id: "algebra/q1", agent: "codex", model: "o3", experimentId: "compare/codex" }),
|
|
88
|
+
]));
|
|
89
|
+
|
|
90
|
+
const results = await openResults(root);
|
|
91
|
+
expect(results.runs).toHaveLength(1);
|
|
92
|
+
expect(results.skipped).toHaveLength(0);
|
|
93
|
+
expect(results.warnings).toHaveLength(0);
|
|
94
|
+
expect(results.snapshots).toHaveLength(2);
|
|
95
|
+
|
|
96
|
+
const bub = results.snapshots.find((s) => s.experimentId === "compare/bub")!;
|
|
97
|
+
expect(bub.agent).toBe("bub");
|
|
98
|
+
expect(bub.model).toBe("gpt-5");
|
|
99
|
+
expect(bub.attempts).toHaveLength(2);
|
|
100
|
+
expect(bub.evalIds).toEqual(["algebra/q1", "algebra/q2"]);
|
|
101
|
+
expect(bub.startedAt).toBe(results.runs[0].summary.startedAt);
|
|
102
|
+
expect(bub.run).toBe(results.runs[0]);
|
|
103
|
+
|
|
104
|
+
const codex = results.snapshots.find((s) => s.experimentId === "compare/codex")!;
|
|
105
|
+
expect(codex.agent).toBe("codex");
|
|
106
|
+
expect(codex.model).toBe("o3");
|
|
107
|
+
expect(codex.attempts).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("experimentId 缺失时以 <agent>/<model> 合成快照键,并出英文 warning(无 model 用 default)", async () => {
|
|
111
|
+
const root = await makeRoot();
|
|
112
|
+
await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
113
|
+
res({ id: "q1", agent: "codex" }),
|
|
114
|
+
res({ id: "q2", agent: "codex" }),
|
|
115
|
+
]));
|
|
116
|
+
|
|
117
|
+
const results = await openResults(root);
|
|
118
|
+
expect(results.snapshots).toHaveLength(1);
|
|
119
|
+
expect(results.snapshots[0].experimentId).toBe("codex/default");
|
|
120
|
+
expect(results.warnings).toHaveLength(1);
|
|
121
|
+
expect(results.warnings[0]).toMatch(/without experimentId/);
|
|
122
|
+
expect(results.warnings[0]).toMatch(/"codex\/default"/);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("latestPerExperiment", () => {
|
|
127
|
+
it("跨两个 run 各取所属:每个 experiment 拿自己最新的快照", async () => {
|
|
128
|
+
const root = await makeRoot();
|
|
129
|
+
const monday = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
130
|
+
res({ id: "q1", agent: "bub", experimentId: "mid/a" }),
|
|
131
|
+
res({ id: "q2", agent: "bub", experimentId: "mid/a" }),
|
|
132
|
+
res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
|
|
133
|
+
res({ id: "q2", agent: "codex", experimentId: "mid/b" }),
|
|
134
|
+
], { startedAt: "2026-07-01T08:00:00.000Z" }));
|
|
135
|
+
const tuesday = await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
|
|
136
|
+
res({ id: "q1", agent: "codex", experimentId: "mid/b" }),
|
|
137
|
+
res({ id: "q2", agent: "codex", experimentId: "mid/b" }),
|
|
138
|
+
], { startedAt: "2026-07-02T08:00:00.000Z" }));
|
|
139
|
+
|
|
140
|
+
const all = await openResults(root);
|
|
141
|
+
const { snapshots, warnings } = latestPerExperiment(all.snapshots);
|
|
142
|
+
expect(warnings).toHaveLength(0);
|
|
143
|
+
expect(snapshots.map((s) => s.experimentId)).toEqual(["mid/a", "mid/b"]);
|
|
144
|
+
expect(snapshots.find((s) => s.experimentId === "mid/a")!.run.dir).toBe(monday);
|
|
145
|
+
expect(snapshots.find((s) => s.experimentId === "mid/b")!.run.dir).toBe(tuesday);
|
|
146
|
+
|
|
147
|
+
// experiments 前缀过滤,同 CLI 语义;尾斜杠写法等价。
|
|
148
|
+
expect(latestPerExperiment(all.snapshots, { experiments: "mid/a" }).snapshots).toHaveLength(1);
|
|
149
|
+
expect(latestPerExperiment(all.snapshots, { experiments: "mid/" }).snapshots).toHaveLength(2);
|
|
150
|
+
expect(latestPerExperiment(all.snapshots, { experiments: "other" }).snapshots).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("最新快照残缺(只重跑了一道题)时生成 covers x of y 警告,仍返回该快照", async () => {
|
|
154
|
+
const root = await makeRoot();
|
|
155
|
+
await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
156
|
+
res({ id: "algebra/q1", agent: "bub", experimentId: "midterm/bub-gpt-5.4" }),
|
|
157
|
+
res({ id: "algebra/q2", agent: "bub", experimentId: "midterm/bub-gpt-5.4" }),
|
|
158
|
+
res({ id: "algebra/q3", agent: "bub", experimentId: "midterm/bub-gpt-5.4" }),
|
|
159
|
+
], { startedAt: "2026-07-01T08:00:00.000Z" }));
|
|
160
|
+
await writeRun(root, "2026-07-05T08-00-00-000Z", summaryOf([
|
|
161
|
+
res({ id: "algebra/q1", agent: "bub", experimentId: "midterm/bub-gpt-5.4" }),
|
|
162
|
+
], { startedAt: "2026-07-05T08:00:00.000Z" }));
|
|
163
|
+
|
|
164
|
+
const all = await openResults(root);
|
|
165
|
+
const { snapshots, warnings } = latestPerExperiment(all.snapshots);
|
|
166
|
+
expect(snapshots).toHaveLength(1);
|
|
167
|
+
expect(snapshots[0].startedAt).toBe("2026-07-05T08:00:00.000Z");
|
|
168
|
+
expect(warnings).toHaveLength(1);
|
|
169
|
+
expect(warnings[0]).toMatch(/^warning: snapshot "midterm\/bub-gpt-5\.4" @ 2026-07-05T08:00:00\.000Z covers 1 of 3 evals seen in history\./);
|
|
170
|
+
expect(warnings[0]).toMatch(/Re-run `niceeval exp midterm\/bub-gpt-5\.4` for a full snapshot/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("dedupeAttempts", () => {
|
|
175
|
+
it("resume 原样合入的重复 attempt 按身份键去重,保留最新 run 的那份;缺 startedAt 不去重并出 warning", async () => {
|
|
176
|
+
const root = await makeRoot();
|
|
177
|
+
// 周一:q1 通过、q2 失败;q3 没有 startedAt(异常数据)。
|
|
178
|
+
const monday = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
179
|
+
res({ id: "q1", agent: "bub", experimentId: "e", startedAt: "2026-07-01T08:01:00.000Z" }),
|
|
180
|
+
res({ id: "q2", agent: "bub", experimentId: "e", outcome: "failed", startedAt: "2026-07-01T08:02:00.000Z" }),
|
|
181
|
+
res({ id: "q3", agent: "bub", experimentId: "e" }),
|
|
182
|
+
], { startedAt: "2026-07-01T08:00:00.000Z" }));
|
|
183
|
+
// 周二 --resume:q1 原样合入(身份键完全相同),q2 重跑出新 startedAt,q3 依旧没有 startedAt。
|
|
184
|
+
const tuesday = await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
|
|
185
|
+
res({ id: "q1", agent: "bub", experimentId: "e", startedAt: "2026-07-01T08:01:00.000Z" }),
|
|
186
|
+
res({ id: "q2", agent: "bub", experimentId: "e", startedAt: "2026-07-02T08:02:00.000Z" }),
|
|
187
|
+
res({ id: "q3", agent: "bub", experimentId: "e" }),
|
|
188
|
+
], { startedAt: "2026-07-02T08:00:00.000Z" }));
|
|
189
|
+
|
|
190
|
+
const all = await openResults(root);
|
|
191
|
+
const attempts = all.snapshots.flatMap((s) => s.attempts);
|
|
192
|
+
expect(attempts).toHaveLength(6);
|
|
193
|
+
|
|
194
|
+
const { attempts: deduped, warnings } = dedupeAttempts(attempts);
|
|
195
|
+
// q1 合并成一条;q2 两条(startedAt 不同,是两次真实运行);q3 两条(缺 startedAt,不敢去重)。
|
|
196
|
+
expect(deduped).toHaveLength(5);
|
|
197
|
+
const q1 = deduped.filter((a) => a.result.id === "q1");
|
|
198
|
+
expect(q1).toHaveLength(1);
|
|
199
|
+
expect(q1[0].run.dir).toBe(tuesday);
|
|
200
|
+
expect(deduped.filter((a) => a.result.id === "q2")).toHaveLength(2);
|
|
201
|
+
expect(deduped.filter((a) => a.result.id === "q3")).toHaveLength(2);
|
|
202
|
+
expect(warnings).toHaveLength(2);
|
|
203
|
+
expect(warnings[0]).toMatch(/"q3".*has no startedAt/);
|
|
204
|
+
void monday;
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("openResults · 版本与坏文件", () => {
|
|
209
|
+
it("schemaVersion 不匹配进 skipped 带 producerVersion;坏 JSON 记 malformed;无关 JSON 静默忽略;legacy 无信封照读", async () => {
|
|
210
|
+
const root = await makeRoot();
|
|
211
|
+
const incompatible = await writeRun(root, "2026-07-03T08-00-00-000Z", {
|
|
212
|
+
format: RESULTS_FORMAT,
|
|
213
|
+
schemaVersion: RESULTS_SCHEMA_VERSION + 1,
|
|
214
|
+
producer: { name: "niceeval", version: "9.9.9" },
|
|
215
|
+
startedAt: "2026-07-03T08:00:00.000Z",
|
|
216
|
+
results: [],
|
|
217
|
+
});
|
|
218
|
+
await writeRun(root, "2026-07-04T08-00-00-000Z", "not json {");
|
|
219
|
+
await writeRun(root, "unrelated", { hello: 1 });
|
|
220
|
+
// 只有 results 键、没有 startedAt:不满足 legacy 启发式,按无关 JSON 静默忽略(不进 skipped)
|
|
221
|
+
await writeRun(root, "alien-results", { results: [] });
|
|
222
|
+
await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([res({ id: "q1", agent: "bub", experimentId: "e" })]));
|
|
223
|
+
// legacy:引入版本号之前的存量报告,没有 format 信封,按 schemaVersion 1 读。
|
|
224
|
+
const legacy = summaryOf([res({ id: "q1", agent: "bub", experimentId: "old" })], { startedAt: "2026-06-01T08:00:00.000Z" });
|
|
225
|
+
delete legacy.format;
|
|
226
|
+
delete legacy.schemaVersion;
|
|
227
|
+
delete legacy.producer;
|
|
228
|
+
await writeRun(root, "2026-06-01T08-00-00-000Z", legacy);
|
|
229
|
+
|
|
230
|
+
const results = await openResults(root);
|
|
231
|
+
expect(results.runs).toHaveLength(2);
|
|
232
|
+
expect(results.skipped).toHaveLength(2);
|
|
233
|
+
|
|
234
|
+
const versionSkip = results.skipped.find((s) => s.reason === "incompatible-version")!;
|
|
235
|
+
expect(versionSkip.dir).toBe(incompatible);
|
|
236
|
+
expect(versionSkip.schemaVersion).toBe(RESULTS_SCHEMA_VERSION + 1);
|
|
237
|
+
expect(versionSkip.producerVersion).toBe("9.9.9");
|
|
238
|
+
|
|
239
|
+
const malformed = results.skipped.find((s) => s.reason === "malformed")!;
|
|
240
|
+
expect(malformed.detail).toBe("invalid JSON");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("AttemptHandle · 懒加载", () => {
|
|
245
|
+
it("缺文件返回 null 不抛错;读过一次即缓存(文件删了也还在);没有 artifactsDir 时全部为 null", async () => {
|
|
246
|
+
const root = await makeRoot();
|
|
247
|
+
const runDir = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
248
|
+
res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "e", artifactsDir: "q1/bub/gpt-5/a1", hasEvents: true }),
|
|
249
|
+
res({ id: "q2", agent: "bub", model: "gpt-5", experimentId: "e" }),
|
|
250
|
+
]));
|
|
251
|
+
const eventsPath = await writeArtifact(runDir, "q1/bub/gpt-5/a1", "events.json", [
|
|
252
|
+
{ type: "message", role: "assistant", text: "hi" },
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
const results = await openResults(root);
|
|
256
|
+
const [q1, q2] = results.runs[0].attempts;
|
|
257
|
+
|
|
258
|
+
// ref 契约:run 目录名 + summary.results 下标,Reports 的 refs 与 view 深链直接可用
|
|
259
|
+
expect(q1.ref).toEqual({ run: "2026-07-01T08-00-00-000Z", result: 0 });
|
|
260
|
+
expect(q2.ref).toEqual({ run: "2026-07-01T08-00-00-000Z", result: 1 });
|
|
261
|
+
|
|
262
|
+
const events = await q1.events();
|
|
263
|
+
expect(events).toHaveLength(1);
|
|
264
|
+
// summary 只有 hasEvents/hasTrace/hasSources 三个标记,o11y/diff 没有标记 —— 全靠方法语义吸收。
|
|
265
|
+
expect(await q1.trace()).toBeNull();
|
|
266
|
+
expect(await q1.o11y()).toBeNull();
|
|
267
|
+
expect(await q1.diff()).toBeNull();
|
|
268
|
+
expect(await q1.sources()).toBeNull();
|
|
269
|
+
|
|
270
|
+
// 记忆化:同一 handle 读一次缓存,文件删掉后再读仍返回同一份数据。
|
|
271
|
+
await rm(eventsPath);
|
|
272
|
+
expect(await q1.events()).toBe(events);
|
|
273
|
+
|
|
274
|
+
// 条目没有 artifactsDir(异常/极简数据):不猜路径,全部 null。
|
|
275
|
+
expect(await q2.events()).toBeNull();
|
|
276
|
+
expect(await q2.diff()).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("copyRun", () => {
|
|
281
|
+
it("按指定工件种类复制选中快照,重建的 summary 只含选中条目并保留版本元数据;产物可被 openResults 回读", async () => {
|
|
282
|
+
const root = await makeRoot();
|
|
283
|
+
const monday = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
284
|
+
res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5/a1", hasEvents: true, hasTrace: true, startedAt: "2026-07-01T08:01:00.000Z", estimatedCostUSD: 0.5, usage: { inputTokens: 10, outputTokens: 5 } }),
|
|
285
|
+
res({ id: "q1", agent: "codex", model: "o3", experimentId: "compare/codex", artifactsDir: "q1/codex/o3/a1", hasEvents: true, outcome: "failed", estimatedCostUSD: 0.25, usage: { inputTokens: 20, outputTokens: 2 } }),
|
|
286
|
+
], { startedAt: "2026-07-01T08:00:00.000Z" }));
|
|
287
|
+
await writeArtifact(monday, "q1/bub/gpt-5/a1", "events.json", [{ type: "message" }]);
|
|
288
|
+
await writeArtifact(monday, "q1/bub/gpt-5/a1", "trace.json", [{ name: "turn" }]);
|
|
289
|
+
await writeArtifact(monday, "q1/bub/gpt-5/a1", "diff.json", { generatedFiles: {}, deletedFiles: [] });
|
|
290
|
+
await writeArtifact(monday, "q1/codex/o3/a1", "events.json", [{ type: "message" }]);
|
|
291
|
+
const tuesday = await writeRun(root, "2026-07-02T08-00-00-000Z", summaryOf([
|
|
292
|
+
res({ id: "q1", agent: "bub", model: "gpt-5", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5/a1", hasEvents: true, startedAt: "2026-07-02T08:01:00.000Z" }),
|
|
293
|
+
], { startedAt: "2026-07-02T08:00:00.000Z" }));
|
|
294
|
+
await writeArtifact(tuesday, "q1/bub/gpt-5/a1", "events.json", [{ type: "message" }, { type: "message" }]);
|
|
295
|
+
|
|
296
|
+
const all = await openResults(root);
|
|
297
|
+
const picked = latestPerExperiment(all.snapshots, { experiments: "compare/" });
|
|
298
|
+
const dest = join(await makeRoot(), "site/data/run");
|
|
299
|
+
const copied = await copyRun(picked.snapshots, dest, { artifacts: ["events", "sources"] });
|
|
300
|
+
|
|
301
|
+
expect(copied.warnings).toHaveLength(0);
|
|
302
|
+
expect(copied.summary.format).toBe(RESULTS_FORMAT);
|
|
303
|
+
expect(copied.summary.schemaVersion).toBe(RESULTS_SCHEMA_VERSION);
|
|
304
|
+
expect(copied.summary.producer?.version).toBe("0.3.0");
|
|
305
|
+
expect(copied.summary.results).toHaveLength(2);
|
|
306
|
+
expect(copied.summary.passed).toBe(1);
|
|
307
|
+
expect(copied.summary.failed).toBe(1);
|
|
308
|
+
// bub 取周二那份(最新),codex 取周一那份。
|
|
309
|
+
const bubEntry = copied.summary.results.find((r) => r.experimentId === "compare/bub")!;
|
|
310
|
+
expect(bubEntry.startedAt).toBe("2026-07-02T08:01:00.000Z");
|
|
311
|
+
expect(bubEntry.hasEvents).toBe(true);
|
|
312
|
+
expect(bubEntry.hasTrace).toBe(false); // trace 没被选中,存在标记按目标目录重算
|
|
313
|
+
|
|
314
|
+
// 磁盘:只有选中的工件种类被复制。
|
|
315
|
+
// 目标目录带 experiment 段(与 writer 的 attemptDir 同规则),源 fixture 的旧式路径靠 artifactsDir 定位
|
|
316
|
+
expect(await exists(join(dest, "q1/bub/gpt-5/compare_bub/a1/events.json"))).toBe(true);
|
|
317
|
+
expect(await exists(join(dest, "q1/bub/gpt-5/compare_bub/a1/trace.json"))).toBe(false);
|
|
318
|
+
expect(await exists(join(dest, "q1/bub/gpt-5/compare_bub/a1/diff.json"))).toBe(false);
|
|
319
|
+
expect(await exists(join(dest, "q1/codex/o3/compare_codex/a1/events.json"))).toBe(true);
|
|
320
|
+
|
|
321
|
+
// 回读:产物是合法 run 目录,懒加载语义不变。
|
|
322
|
+
const reopened = await openResults(dest);
|
|
323
|
+
expect(reopened.runs).toHaveLength(1);
|
|
324
|
+
expect(reopened.snapshots.map((s) => s.experimentId).sort()).toEqual(["compare/bub", "compare/codex"]);
|
|
325
|
+
const bubAttempt = reopened.snapshots.find((s) => s.experimentId === "compare/bub")!.attempts[0];
|
|
326
|
+
expect(await bubAttempt.events()).toHaveLength(2);
|
|
327
|
+
expect(await bubAttempt.trace()).toBeNull();
|
|
328
|
+
|
|
329
|
+
// 同一 experiment 的两个快照(未 dedupe)落到同一工件目录:保留最新并出 warning。
|
|
330
|
+
const bubSnapshots = all.snapshots.filter((s) => s.experimentId === "compare/bub");
|
|
331
|
+
expect(bubSnapshots).toHaveLength(2);
|
|
332
|
+
const dest2 = join(await makeRoot(), "run2");
|
|
333
|
+
const collided = await copyRun(bubSnapshots, dest2);
|
|
334
|
+
expect(collided.warnings).toHaveLength(1);
|
|
335
|
+
expect(collided.warnings[0]).toMatch(/multiple attempts map to "q1\/bub\/gpt-5\/compare_bub\/a1"/);
|
|
336
|
+
expect(collided.summary.results).toHaveLength(1);
|
|
337
|
+
expect(collided.summary.results[0].startedAt).toBe("2026-07-02T08:01:00.000Z");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("同 agent 同 model 只差 experiment 的两个实验互不覆盖(目录带 experiment 段)", async () => {
|
|
341
|
+
// memory-evals 的典型配对:bub-gpt-5.4 vs bub-gpt-5.4--agents-md,只差 flags。
|
|
342
|
+
// attemptDirOf 少了 experiment 段时,这两份工件会在拷贝时碰撞、其中一份被静默丢弃。
|
|
343
|
+
const root = await makeRoot();
|
|
344
|
+
const run = await writeRun(root, "2026-07-01T08-00-00-000Z", summaryOf([
|
|
345
|
+
res({ id: "q1", agent: "bub", model: "gpt-5.4", experimentId: "compare/bub", artifactsDir: "q1/bub/gpt-5.4/compare_bub/a1", hasEvents: true }),
|
|
346
|
+
res({ id: "q1", agent: "bub", model: "gpt-5.4", experimentId: "compare/bub--agents-md", artifactsDir: "q1/bub/gpt-5.4/compare_bub--agents-md/a1", hasEvents: true }),
|
|
347
|
+
]));
|
|
348
|
+
await writeArtifact(run, "q1/bub/gpt-5.4/compare_bub/a1", "events.json", [{ n: 1 }]);
|
|
349
|
+
await writeArtifact(run, "q1/bub/gpt-5.4/compare_bub--agents-md/a1", "events.json", [{ n: 2 }]);
|
|
350
|
+
|
|
351
|
+
const all = await openResults(root);
|
|
352
|
+
const dest = join(await makeRoot(), "pair");
|
|
353
|
+
const copied = await copyRun(all.snapshots, dest);
|
|
354
|
+
expect(copied.warnings).toHaveLength(0);
|
|
355
|
+
expect(copied.summary.results).toHaveLength(2);
|
|
356
|
+
expect(await exists(join(dest, "q1/bub/gpt-5.4/compare_bub/a1/events.json"))).toBe(true);
|
|
357
|
+
expect(await exists(join(dest, "q1/bub/gpt-5.4/compare_bub--agents-md/a1/events.json"))).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// 快照选择器与 attempt 去重(设计见 docs/results-lib.md「选择快照」「身份键与去重」)。
|
|
2
|
+
//
|
|
3
|
+
// 选择器不是 DSL,只是最常用的那次筛选;更细的口径消费方自己 .filter()。
|
|
4
|
+
// 选择器必须诚实:残缺(最新快照覆盖的 eval 少于历史并集)永远被算出来,以 warnings 返回,
|
|
5
|
+
// 渲染与否在消费方,但缺口不静默。
|
|
6
|
+
|
|
7
|
+
import { experimentKeyOf } from "./format.ts";
|
|
8
|
+
import type { AttemptHandle, RunHandle, SnapshotHandle } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 每个 experiment 取最新一次快照(按所属 run 的 startedAt,同刻按 run 目录名兜底)。
|
|
12
|
+
* `experiments` 是 experiment id 前缀过滤,同 CLI 位置参数语义("compare" 匹配自身与
|
|
13
|
+
* "compare/..." 子级;允许写成 "compare/",等价)。
|
|
14
|
+
*/
|
|
15
|
+
export function latestPerExperiment(
|
|
16
|
+
snapshots: SnapshotHandle[],
|
|
17
|
+
opts?: { experiments?: string | string[] },
|
|
18
|
+
): { snapshots: SnapshotHandle[]; warnings: string[] } {
|
|
19
|
+
const filtered = filterByExperiments(snapshots, opts?.experiments);
|
|
20
|
+
|
|
21
|
+
const byExperiment = new Map<string, SnapshotHandle[]>();
|
|
22
|
+
for (const snapshot of filtered) {
|
|
23
|
+
const group = byExperiment.get(snapshot.experimentId);
|
|
24
|
+
if (group) group.push(snapshot);
|
|
25
|
+
else byExperiment.set(snapshot.experimentId, [snapshot]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const picked: SnapshotHandle[] = [];
|
|
29
|
+
const warnings: string[] = [];
|
|
30
|
+
for (const [experimentId, group] of byExperiment) {
|
|
31
|
+
let latest = group[0];
|
|
32
|
+
for (const candidate of group) {
|
|
33
|
+
if (isNewerRun(candidate.run, latest.run)) latest = candidate;
|
|
34
|
+
}
|
|
35
|
+
// 残缺检测:与该 experiment 全部历史快照的 evalId 并集对比。
|
|
36
|
+
// 位置参数允许只重跑一道题,产出的「最新快照」可能只有一道题 —— 不能安静吞下。
|
|
37
|
+
const historyIds = new Set<string>();
|
|
38
|
+
for (const snapshot of group) for (const id of snapshot.evalIds) historyIds.add(id);
|
|
39
|
+
if (latest.evalIds.length < historyIds.size) {
|
|
40
|
+
// 合成键不是真实 experiment id,拼不出可执行的 `niceeval exp` 命令,提示退化成中性说法。
|
|
41
|
+
const hint = latest.synthetic
|
|
42
|
+
? " Re-run the experiment for a full snapshot, or pick another via .filter()."
|
|
43
|
+
: ` Re-run \`niceeval exp ${experimentId}\` for a full snapshot, or pick another via .filter().`;
|
|
44
|
+
warnings.push(
|
|
45
|
+
`warning: snapshot "${experimentId}" @ ${latest.startedAt} covers ${latest.evalIds.length} of ${historyIds.size} evals seen in history.\n` +
|
|
46
|
+
hint,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
picked.push(latest);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
picked.sort((a, b) => a.experimentId.localeCompare(b.experimentId));
|
|
53
|
+
return { snapshots: picked, warnings };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 跨快照聚合前的身份键去重:(experimentId, evalId, attempt, startedAt)。
|
|
58
|
+
* --resume 会把上一轮已通过的结果原样合入新 run 的 summary,同一 attempt 因此存在于多份落盘;
|
|
59
|
+
* 重复时保留最新 run 里的那份(位置取首次出现处,顺序稳定)。
|
|
60
|
+
* startedAt 缺失时宁可不去重也不误删,并记入 warnings。
|
|
61
|
+
*/
|
|
62
|
+
export function dedupeAttempts(attempts: AttemptHandle[]): { attempts: AttemptHandle[]; warnings: string[] } {
|
|
63
|
+
const deduped: AttemptHandle[] = [];
|
|
64
|
+
const indexByKey = new Map<string, number>();
|
|
65
|
+
const warnings: string[] = [];
|
|
66
|
+
|
|
67
|
+
for (const attempt of attempts) {
|
|
68
|
+
const r = attempt.result;
|
|
69
|
+
if (!r.startedAt) {
|
|
70
|
+
warnings.push(
|
|
71
|
+
`warning: attempt "${r.id}" (attempt ${r.attempt}) in run "${attempt.run.dir}" has no startedAt; kept as-is without dedupe.`,
|
|
72
|
+
);
|
|
73
|
+
deduped.push(attempt);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const key = JSON.stringify([experimentKeyOf(r).id, r.id, r.attempt, r.startedAt]);
|
|
77
|
+
const existing = indexByKey.get(key);
|
|
78
|
+
if (existing === undefined) {
|
|
79
|
+
indexByKey.set(key, deduped.length);
|
|
80
|
+
deduped.push(attempt);
|
|
81
|
+
} else if (isNewerRun(attempt.run, deduped[existing].run)) {
|
|
82
|
+
deduped[existing] = attempt;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { attempts: deduped, warnings };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** run 新旧比较:startedAt 优先,同刻按目录名(时间戳目录,字典序即时序)。 */
|
|
89
|
+
export function isNewerRun(a: RunHandle, b: RunHandle): boolean {
|
|
90
|
+
const byStart = a.summary.startedAt.localeCompare(b.summary.startedAt);
|
|
91
|
+
if (byStart !== 0) return byStart > 0;
|
|
92
|
+
return a.dir.localeCompare(b.dir) > 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function filterByExperiments(snapshots: SnapshotHandle[], experiments?: string | string[]): SnapshotHandle[] {
|
|
96
|
+
if (experiments === undefined) return snapshots;
|
|
97
|
+
const prefixes = (Array.isArray(experiments) ? experiments : [experiments])
|
|
98
|
+
// 允许 "compare/" 这种带尾斜杠的写法,与 "compare" 等价。
|
|
99
|
+
.map((p) => p.replace(/\/+$/, ""));
|
|
100
|
+
return snapshots.filter((s) => prefixes.some((p) => s.experimentId === p || s.experimentId.startsWith(p + "/")));
|
|
101
|
+
}
|