niceeval 0.11.0 → 0.11.1-canary.8
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/dist/i18n/en.d.ts +1 -0
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/zh-CN.d.ts +1 -0
- package/dist/i18n/zh-CN.js +1 -0
- package/dist/runner/fingerprint.d.ts +49 -5
- package/dist/runner/types.d.ts +25 -2
- package/docs-site/zh/tutorials/write-experiment.mdx +34 -0
- package/package.json +1 -1
- package/src/cli.ts +5 -3
- package/src/define.ts +6 -0
- package/src/i18n/en.ts +1 -0
- package/src/i18n/zh-CN.ts +1 -0
- package/src/runner/feedback/human.test.ts +35 -6
- package/src/runner/feedback/human.ts +3 -1
- package/src/runner/feedback/reducer.test.ts +19 -0
- package/src/runner/feedback/reducer.ts +6 -3
- package/src/runner/fingerprint.test.ts +124 -0
- package/src/runner/fingerprint.ts +110 -11
- package/src/runner/run.test.ts +48 -0
- package/src/runner/run.ts +19 -6
- package/src/runner/types.ts +25 -2
- package/src/view/data.ts +33 -2
- package/src/view/index.ts +1 -0
package/dist/i18n/en.d.ts
CHANGED
|
@@ -134,6 +134,7 @@ export declare const en: {
|
|
|
134
134
|
"define.experimentAgentRequired": string;
|
|
135
135
|
"define.experimentFlagNotJson": string;
|
|
136
136
|
"define.experimentLabelInvalid": string;
|
|
137
|
+
"define.experimentProvenanceFlagsInvalid": string;
|
|
137
138
|
"define.experimentSetupNotFunction": string;
|
|
138
139
|
"define.experimentClassifyFailureNotFunction": string;
|
|
139
140
|
"define.experimentIdRejected": string;
|
package/dist/i18n/en.js
CHANGED
|
@@ -202,6 +202,7 @@ export const en = {
|
|
|
202
202
|
"define.experimentAgentRequired": "defineExperiment requires agent.",
|
|
203
203
|
"define.experimentFlagNotJson": "experiment.flags.{{key}} is not JSON-serializable (functions / undefined / cycles / bigint are not allowed); flags are persisted verbatim into result snapshots and must be plain JSON.",
|
|
204
204
|
"define.experimentLabelInvalid": "experiment.labels.{{key}} must be a string or a finite number; labels are report-side grouping coordinates persisted verbatim into result snapshots.",
|
|
205
|
+
"define.experimentProvenanceFlagsInvalid": "experiment.provenanceFlags must be an array of flag key names (strings); it lists the flags recorded for provenance only, which stay out of the cache fingerprint.",
|
|
205
206
|
"define.experimentSetupNotFunction": "experiment.setup must be a function ((ctx) => void); use experiment.teardown for cleanup; to prepare the in-sandbox environment per experiment, chain .setup() hooks on the sandbox spec instead.",
|
|
206
207
|
"define.experimentClassifyFailureNotFunction": "experiment.classifyFailure must be a function ((failure) => FailureClass | undefined); it classifies failures that surface as third-party errors and must return undefined for anything it does not recognize.",
|
|
207
208
|
"define.experimentIdRejected": "defineExperiment does not accept id; ids are derived from file paths.",
|
package/dist/i18n/zh-CN.d.ts
CHANGED
|
@@ -134,6 +134,7 @@ export declare const zhCN: {
|
|
|
134
134
|
readonly "define.experimentAgentRequired": "defineExperiment 需要 agent。";
|
|
135
135
|
readonly "define.experimentFlagNotJson": "experiment.flags.{{key}} 不是可 JSON 序列化的值(函数 / undefined / 循环引用 / bigint 不允许);flags 会原样进入结果快照,必须是纯 JSON。";
|
|
136
136
|
readonly "define.experimentLabelInvalid": "experiment.labels.{{key}} 必须是字符串或有限数字;labels 是报告侧的归类坐标,会原样进入结果快照。";
|
|
137
|
+
readonly "define.experimentProvenanceFlagsInvalid": "experiment.provenanceFlags 必须是 flag 键名(字符串)数组;它列出只作为出处记录、不进缓存指纹的那些 flag。";
|
|
137
138
|
readonly "define.experimentSetupNotFunction": "experiment.setup 必须是函数((ctx) => void);要清理请挂 experiment.teardown;要按实验准备沙箱内环境请挂 sandbox spec 的 .setup() 钩子链。";
|
|
138
139
|
readonly "define.experimentClassifyFailureNotFunction": "experiment.classifyFailure 必须是函数((failure) => FailureClass | undefined):它识别以第三方错误形态浮出的失败,认不出的一律返回 undefined 交给后续链路。";
|
|
139
140
|
readonly "define.experimentIdRejected": "defineExperiment 不接受 id —— id 由文件路径推导。";
|
package/dist/i18n/zh-CN.js
CHANGED
|
@@ -198,6 +198,7 @@ export const zhCN = {
|
|
|
198
198
|
"define.experimentAgentRequired": "defineExperiment 需要 agent。",
|
|
199
199
|
"define.experimentFlagNotJson": "experiment.flags.{{key}} 不是可 JSON 序列化的值(函数 / undefined / 循环引用 / bigint 不允许);flags 会原样进入结果快照,必须是纯 JSON。",
|
|
200
200
|
"define.experimentLabelInvalid": "experiment.labels.{{key}} 必须是字符串或有限数字;labels 是报告侧的归类坐标,会原样进入结果快照。",
|
|
201
|
+
"define.experimentProvenanceFlagsInvalid": "experiment.provenanceFlags 必须是 flag 键名(字符串)数组;它列出只作为出处记录、不进缓存指纹的那些 flag。",
|
|
201
202
|
"define.experimentSetupNotFunction": "experiment.setup 必须是函数((ctx) => void);要清理请挂 experiment.teardown;要按实验准备沙箱内环境请挂 sandbox spec 的 .setup() 钩子链。",
|
|
202
203
|
"define.experimentClassifyFailureNotFunction": "experiment.classifyFailure 必须是函数((failure) => FailureClass | undefined):它识别以第三方错误形态浮出的失败,认不出的一律返回 undefined 交给后续链路。",
|
|
203
204
|
"define.experimentIdRejected": "defineExperiment 不接受 id —— id 由文件路径推导。",
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
import type { DiscoveredEval, EvalResult, SandboxOption } from "../types.ts";
|
|
1
|
+
import type { DiscoveredEval, EvalResult, JsonValue, SandboxOption } from "../types.ts";
|
|
2
2
|
import type { AgentRun } from "./types.ts";
|
|
3
3
|
export declare function cacheKey(run: AgentRun, evalId: string): string;
|
|
4
4
|
/**
|
|
5
5
|
* @param sourceCache 按 sourcePath 缓存文件内容:一个矩阵(实验 × eval)会对同一批源文件
|
|
6
6
|
* 反复算指纹,不带缓存会在任何 attempt 起跑前做 E×N 次重复文件读。
|
|
7
|
+
* @param flagsOverride 用这份 flags 代替 `run.flags` 的指纹口径算一遍。只有一个用途:
|
|
8
|
+
* 对已落盘结果做**反事实重算**——「把 flags 换成它当时那份,指纹还相等吗」等价于问
|
|
9
|
+
* 「除 flags 外的一切是否都没变」,`acceptableFingerprints` 用它判定某条历史结果与本次
|
|
10
|
+
* 规划的差异是否完全落在 provenance flag 上。
|
|
7
11
|
*/
|
|
8
|
-
export declare function computeFingerprint(evalDef: DiscoveredEval, run: AgentRun, sourceCache?: Map<string, Promise<string>>, configSandbox?: SandboxOption): Promise<string>;
|
|
12
|
+
export declare function computeFingerprint(evalDef: DiscoveredEval, run: AgentRun, sourceCache?: Map<string, Promise<string>>, configSandbox?: SandboxOption, flagsOverride?: Record<string, JsonValue>): Promise<string>;
|
|
9
13
|
export interface CarryPlan {
|
|
10
14
|
/** `cacheKey(run, evalId)` → 本次规划出的指纹,供调用方按同一口径判断"这条要不要携入"。 */
|
|
11
15
|
plannedFingerprints: Map<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* `cacheKey(run, evalId)` → 这条组合**可以携带的全部指纹**:本次规划的那个,加上
|
|
18
|
+
* 「只在 provenance flag 上与本次不同」的历史口径(见 `acceptableFingerprints`)。
|
|
19
|
+
* 没声明 provenance flag 时恒是单元素集合 = `plannedFingerprints` 的那一个。
|
|
20
|
+
* 携带判定一律读这个集合,`plannedFingerprints` 只用来给新跑的 attempt 落盘打戳。
|
|
21
|
+
*/
|
|
22
|
+
acceptableFingerprints: Map<string, Set<string>>;
|
|
12
23
|
/**
|
|
13
24
|
* 携带以 attempt 为粒度:命中携入条件(该 attempt 自身 passed/failed 终态 + 指纹匹配)的
|
|
14
25
|
* `${experimentId}|${evalId}` → 该 eval 下具体携入的 attempt 序号集合(0-based)。同一个
|
|
@@ -35,14 +46,16 @@ export declare function resolvedTimeoutMsForCarry(run: AgentRun, evalDef: Discov
|
|
|
35
46
|
* 1. 该 attempt 自己是终态(`passed` / `failed`)。`errored` 是框架/环境层面的不确定失败,
|
|
36
47
|
* 判定本身不可信;`skipped` 根本没跑。同一 eval 的别的序号命中不能连带把它捎上
|
|
37
48
|
* (反例与修法见 memory 的 carry-must-be-per-attempt-not-whole-eval-key)。
|
|
38
|
-
* 2. 该 attempt 落盘的 `fingerprint`
|
|
49
|
+
* 2. 该 attempt 落盘的 `fingerprint` 落在本次的可携带指纹集合里(`CarryPlan.acceptableFingerprints`
|
|
50
|
+
* 的那一条,通常只有本次规划出的那一个;声明了 provenance flag 时还含「只在这些键上与本次
|
|
51
|
+
* 不同」的历史口径)。
|
|
39
52
|
* 3. 该 attempt 的 `durationMs` 不超过本次 resolved 的 `timeoutMs`——`timeoutMs` 是携带资格
|
|
40
53
|
* 判据、不进指纹哈希(docs/runner.md「缓存:指纹去重」)。
|
|
41
54
|
*
|
|
42
55
|
* `planCarry`(整场静态规划)与 run.ts 派发时刻的携带重查共用这一个函数:两条路径一旦把判据
|
|
43
56
|
* 各写一份就会分叉,重查会携入静态规划判过不可携带的条目(或反过来)。
|
|
44
57
|
*/
|
|
45
|
-
export declare function carriableAttempts(priorResults: EvalResult[] | undefined, key: string,
|
|
58
|
+
export declare function carriableAttempts(priorResults: EvalResult[] | undefined, key: string, fingerprints: ReadonlySet<string> | undefined, timeoutMs: number): EvalResult[];
|
|
46
59
|
/**
|
|
47
60
|
* 算出这一批 (agentRun × eval) 的指纹,并据此从 priorResults 里筛出可以携入(跳过重跑)的结果。
|
|
48
61
|
* run.ts 与 cli.ts(live 表格构建)必须共用这同一份计算 —— 否则两边一旦对"哪些携入"的判断
|
|
@@ -58,4 +71,35 @@ export declare function carriableAttempts(priorResults: EvalResult[] | undefined
|
|
|
58
71
|
* `resolvedTimeoutMsForCarry`)。省略时按未配置处理,不是当作 0——只有 `run.timeoutMs` /
|
|
59
72
|
* `evalDef.timeoutMs` 都缺席时才轮到它兜底。
|
|
60
73
|
*/
|
|
61
|
-
export declare function planCarry(evals: DiscoveredEval[], agentRuns: AgentRun[], priorResults: EvalResult[] | undefined, configSandbox?: SandboxOption, configTimeoutMs?: number): Promise<CarryPlan>;
|
|
74
|
+
export declare function planCarry(evals: DiscoveredEval[], agentRuns: AgentRun[], priorResults: EvalResult[] | undefined, configSandbox?: SandboxOption, configTimeoutMs?: number, flagBagsByExperiment?: Map<string, Record<string, JsonValue>[]>): Promise<CarryPlan>;
|
|
75
|
+
/**
|
|
76
|
+
* 这条 `(experimentId, evalId)` 本次可以携带的指纹全集。
|
|
77
|
+
*
|
|
78
|
+
* 没声明 provenance flag 时就是 `{ primary }`——判据与「指纹相等」逐字等价,一条历史结果都
|
|
79
|
+
* 不会因此多携入。声明了之后多出一类:**只在 provenance flag 上与本次不同**的历史口径。
|
|
80
|
+
*
|
|
81
|
+
* 判定不靠比对两串哈希的差异(哈希不可差分),而是**反事实重算**:取该历史结果所属快照记下的
|
|
82
|
+
* `ExperimentRunInfo.flags`(整袋原样,`applySnapshotDefaults` 已把它挂在 `EvalResult.experiment`
|
|
83
|
+
* 上),用它替换本次的 flags 口径重算一遍指纹——算出来等于历史那一串,就证明「除 flags 外的
|
|
84
|
+
* 一切(eval 源码、agent、model、sandbox、strict…)都没变」。再要求两袋 flags 抹掉 provenance
|
|
85
|
+
* 键之后逐字相等,才把这串历史指纹计入可携带集合:真改了某个影响行为的 flag(`webResearch`
|
|
86
|
+
* 从 true 改成 false)照旧作废,不会被这条通道放行。
|
|
87
|
+
*
|
|
88
|
+
* 历史结果落盘时的指纹口径是「整袋 flags」(provenance 概念引入之前),所以两个口径都要试:
|
|
89
|
+
* 整袋(老结果)与抹掉 provenance 键的那袋(声明之后跑出来的结果,与 primary 相同则自然去重)。
|
|
90
|
+
*/
|
|
91
|
+
export declare function acceptableFingerprints(args: {
|
|
92
|
+
evalDef: DiscoveredEval;
|
|
93
|
+
run: AgentRun;
|
|
94
|
+
key: string;
|
|
95
|
+
priorResults: EvalResult[] | undefined;
|
|
96
|
+
/** 本次规划出的指纹(新跑的 attempt 用它落盘打戳)。 */
|
|
97
|
+
primary: string;
|
|
98
|
+
/**
|
|
99
|
+
* 该实验历史快照记下过的 flags(见 `loadCarryInputs`)。候选假设的来源之一,与结果自带的那袋
|
|
100
|
+
* 并列——携带条目带着**产出它那一轮**的指纹合入新快照,那一轮的 flags 只在更早的快照里留着。
|
|
101
|
+
*/
|
|
102
|
+
historicalFlagBags?: readonly Record<string, JsonValue>[];
|
|
103
|
+
sourceCache?: Map<string, Promise<string>>;
|
|
104
|
+
configSandbox?: SandboxOption;
|
|
105
|
+
}): Promise<Set<string>>;
|
package/dist/runner/types.d.ts
CHANGED
|
@@ -500,6 +500,21 @@ export interface ExperimentDef {
|
|
|
500
500
|
* (defineExperiment 解析时校验,非 JSON 直接报错),经 ctx.flags 透传给 adapter、
|
|
501
501
|
* t.flags 暴露给 eval,并原样进入结果快照的 ExperimentRunInfo.flags。 */
|
|
502
502
|
flags?: Record<string, JsonValue>;
|
|
503
|
+
/**
|
|
504
|
+
* `flags` 里只作为**出处记录**的键名:照常落盘、照常透给 `ctx.flags` / `t.flags`,但不参与
|
|
505
|
+
* 可比性配置——值变了不作废任何已有结果,已跑完的照常携带(carry)。
|
|
506
|
+
*
|
|
507
|
+
* 给的是「每次跑都可能换、但换了不改变 attempt 里发生什么」的坐标:隧道 / 反向代理 URL、
|
|
508
|
+
* 服务端实例地址、跑批时刻这类。它们要留在 `flags` 里(报告要按 `flag()` 看这轮连的是哪个,
|
|
509
|
+
* eval 或 adapter 也可能要读),又不该像 `webResearch: true → false` 那样让缓存全部失效。
|
|
510
|
+
*
|
|
511
|
+
* 声明前跑出来的结果同样携带得到:携带判定按快照记下的历史 flags 做一次反事实重算,
|
|
512
|
+
* 确认差异完全落在这些键上(见 `runner/fingerprint.ts` 的 `acceptableFingerprints`)。
|
|
513
|
+
* 键不必存在于 `flags` 里——把一个键从 `flags` 移走时留着这条声明,历史结果照样不作废。
|
|
514
|
+
*
|
|
515
|
+
* 完全不需要在运行时被看见的事实用 `labels`,那是报告侧坐标(本来就不进指纹)。
|
|
516
|
+
*/
|
|
517
|
+
provenanceFlags?: readonly string[];
|
|
503
518
|
/**
|
|
504
519
|
* 报告归类标注:实验在各对比轴上的坐标(如 `{ line: "codex", memory: "mempal" }`)。
|
|
505
520
|
* 值域 string | number(解析时校验)。与 `flags` 的分界是「会不会改变 attempt 里发生的事」:
|
|
@@ -669,6 +684,8 @@ export interface AgentRun {
|
|
|
669
684
|
model?: string;
|
|
670
685
|
reasoningEffort?: string;
|
|
671
686
|
flags: Record<string, JsonValue>;
|
|
687
|
+
/** 只作为出处记录、不进指纹的 flag 键(来自 ExperimentDef.provenanceFlags);见该字段说明。 */
|
|
688
|
+
provenanceFlags?: readonly string[];
|
|
672
689
|
runs: number;
|
|
673
690
|
earlyExit: boolean;
|
|
674
691
|
sandbox?: SandboxOption;
|
|
@@ -794,8 +811,14 @@ export interface ActiveAttempt {
|
|
|
794
811
|
/** 展示 label,等价 `runWho()` 的结果;渲染要用,但绝不作为 identity/key。 */
|
|
795
812
|
who: string;
|
|
796
813
|
phase: LifecyclePhase;
|
|
797
|
-
/**
|
|
798
|
-
|
|
814
|
+
/**
|
|
815
|
+
* 这条 attempt 被派发的墙钟时间(epoch ms,取 `attempt:start` 的 `at`)—— active 行时间列的
|
|
816
|
+
* **唯一**基准,`attempt:phase` 不得改写它:live 面板不做 spinner 动画,存活性完全由这一列
|
|
817
|
+
* 持续增长证明(见 docs/feature/experiments/cli.md「active 行的列序」),一列会归零的时间既
|
|
818
|
+
* 证明不了存活,也让人误以为这条 eval 重跑了。阶段各自的耗时不进这里——它由结果的
|
|
819
|
+
* `timing.phases` 完整落盘,live 面板要回答的是「这条还活着吗、跑了多久、正在干什么」。
|
|
820
|
+
*/
|
|
821
|
+
startedAt: number;
|
|
799
822
|
detail?: string;
|
|
800
823
|
}
|
|
801
824
|
/** 实验级钩子只有 setup 与它返回的 teardown 两员,同一实验内两者永不并发
|
|
@@ -117,6 +117,7 @@ npx niceeval exp prompts/concise
|
|
|
117
117
|
| `model` | 单个模型名,经 `ctx.model` 透传 |
|
|
118
118
|
| `reasoningEffort` | 单个推理努力程度(如 `"high"`),经 `ctx.reasoningEffort` / `t.reasoningEffort` 透传,归属与 `model` 一致 |
|
|
119
119
|
| `flags` | 实验条件(A/B 里的 feature flag),任意 JSON 对象,经 `ctx.flags` / `t.flags` 透传 |
|
|
120
|
+
| `provenanceFlags` | 列出只作出处记录的 flag 键名:照常落盘和透传,但不进缓存指纹,值变了不作废已跑完的结果(见下) |
|
|
120
121
|
| `runs` | 每个评估用例 × 配置最多跑几次 |
|
|
121
122
|
| `earlyExit` | 多次运行里通过一次就提前停止;默认关,`runs` 默认跑满测完整通过率 |
|
|
122
123
|
| `evals` | `"*"`、id 前缀数组,或遍历只读 eval 描述并返回 boolean 的函数 |
|
|
@@ -127,6 +128,39 @@ npx niceeval exp prompts/concise
|
|
|
127
128
|
| `setup` | 实验级 Hook:整个实验只跑一次、在你自己的机器上执行,启动全部 Attempt 共享的服务(见下) |
|
|
128
129
|
| `teardown` | 与 `setup` 成对的实验级 Hook:全部 Attempt 收尾后执行一次,当且仅当 `setup` 的时点已经走到(见下) |
|
|
129
130
|
|
|
131
|
+
## 记下这轮连的是哪个地址,又不让它作废缓存
|
|
132
|
+
|
|
133
|
+
把服务地址记进 `flags`,报告里就能按它分组、看出这轮连的是哪个实例。但隧道 URL 每次重启就换一个,而 `flags` 变了等于配置变了——下一次跑,已经跑完的结果一条都复用不上,全部重跑。
|
|
134
|
+
|
|
135
|
+
在 `provenanceFlags` 里点名这个键,它就只作记录、不进缓存指纹:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
export default defineExperiment({
|
|
139
|
+
agent: nowledgeAgent(),
|
|
140
|
+
evals: ["memory/"],
|
|
141
|
+
flags: {
|
|
142
|
+
memory: "nowledge",
|
|
143
|
+
memoryVersion: "0.10.39",
|
|
144
|
+
memoryEndpoint: process.env.NMEM_URL!, // 隧道重启就换一个
|
|
145
|
+
},
|
|
146
|
+
provenanceFlags: ["memoryEndpoint"],
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
换了 URL 再跑,已完成的照常复用,只跑还缺的:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
$ pnpm exec niceeval exp compare/codex--nowledge
|
|
154
|
+
╭─ PLAN ──────────────────────────────────────────╮
|
|
155
|
+
│ 36 attempts · 36 evals × 1 configs │
|
|
156
|
+
│ 24 of 36 carried in from cache · 12 to run │
|
|
157
|
+
╰─────────────────────────────────────────────────╯
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`memoryVersion` 没点名——服务端换了版本,行为可能真的不一样,那种变化就该让结果重跑。加上声明之前跑的结果也复用得到,不用为了「洗」旧结果先空跑一轮。
|
|
161
|
+
|
|
162
|
+
完全不需要在 Adapter 或评估用例里被读到的事实,直接写 `labels`:那是报告侧坐标,本来就不进指纹。
|
|
163
|
+
|
|
130
164
|
## 启动 Experiment 共享服务
|
|
131
165
|
|
|
132
166
|
有些资源是「一个实验一份、所有 Attempt 共用」的:一条到内网记忆服务的隧道、一个实验专用的 mock server、一个 license 租约。这类资源写进一对实验级 Hook `setup` / `teardown`:整场至多各跑一次。`setup` 在这个实验第一个要派发的 Attempt 前执行;`teardown` 在全部 Attempt 收尾后执行(运行被中断也执行),当且仅当 `setup` 的时点已经走到才触发——`setup` 抛错同样要走到 `teardown`,收尾代码要对可能未赋值的变量做防御。上一次的结果全部被复用、这个实验一个 Attempt 都不需要真正运行时,`setup` 和 `teardown` 都不会执行:
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
import {
|
|
44
44
|
buildView,
|
|
45
45
|
startViewServer,
|
|
46
|
-
|
|
46
|
+
loadCarryInputs,
|
|
47
47
|
resolveViewInput,
|
|
48
48
|
IncompatibleResultsError,
|
|
49
49
|
ViewInputError,
|
|
@@ -874,6 +874,7 @@ async function main(): Promise<void> {
|
|
|
874
874
|
model: exp.model,
|
|
875
875
|
reasoningEffort: exp.reasoningEffort,
|
|
876
876
|
flags: exp.flags ?? {},
|
|
877
|
+
...(exp.provenanceFlags !== undefined ? { provenanceFlags: exp.provenanceFlags } : {}),
|
|
877
878
|
runs: flags.runs ?? envNumber("NICEEVAL_RUNS") ?? exp.runs ?? 1,
|
|
878
879
|
earlyExit: flags.earlyExit ?? exp.earlyExit ?? false,
|
|
879
880
|
sandbox: exp.sandbox ?? config.sandbox,
|
|
@@ -943,9 +944,10 @@ async function main(): Promise<void> {
|
|
|
943
944
|
// `--dry`(两种形态)都需要这份计算:`--dry --json` 的 `ExpPlanDocument.matrix[].reused`,
|
|
944
945
|
// 人读 `--dry` 首行的携入摘要(见 docs/feature/experiments/cli.md 开头示例与「事件与计划
|
|
945
946
|
// 文档的 TypeScript 形状」),口径必须与真正开跑时一致。
|
|
946
|
-
const
|
|
947
|
+
const carryInputs = flags.force ? undefined : await loadCarryInputs(join(cwd, ".niceeval"));
|
|
948
|
+
const priorResults = carryInputs?.results;
|
|
947
949
|
const carryPlan = priorResults?.length
|
|
948
|
-
? await planCarry(evals, agentRuns, priorResults, config.sandbox, config.timeoutMs)
|
|
950
|
+
? await planCarry(evals, agentRuns, priorResults, config.sandbox, config.timeoutMs, carryInputs?.flagBagsByExperiment)
|
|
949
951
|
: undefined;
|
|
950
952
|
|
|
951
953
|
if (flags.dry) {
|
package/src/define.ts
CHANGED
|
@@ -128,6 +128,12 @@ export function defineExperiment(def: ExperimentDef): ExperimentDef {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
// provenanceFlags 只接受字符串键名数组:写错形状(误传对象、误传 flags 本身)在解析时就报,
|
|
132
|
+
// 不等到携带判定悄悄按「没声明」处理——那会表现成缓存莫名其妙全失效,极难查。
|
|
133
|
+
if (def.provenanceFlags !== undefined) {
|
|
134
|
+
const ok = Array.isArray(def.provenanceFlags) && def.provenanceFlags.every((k) => typeof k === "string" && k.length > 0);
|
|
135
|
+
if (!ok) throw new Error(t("define.experimentProvenanceFlagsInvalid"));
|
|
136
|
+
}
|
|
131
137
|
// labels 是报告归类坐标(进 ExperimentRunInfo.labels,不透传 ctx/t):值域 string | number,
|
|
132
138
|
// 解析时即校验,布尔 / 对象 / NaN 直接报错,不等到落盘或报告分组才炸。
|
|
133
139
|
if (def.labels !== undefined) {
|
package/src/i18n/en.ts
CHANGED
|
@@ -243,6 +243,7 @@ export const en = {
|
|
|
243
243
|
"define.experimentAgentRequired": "defineExperiment requires agent.",
|
|
244
244
|
"define.experimentFlagNotJson": "experiment.flags.{{key}} is not JSON-serializable (functions / undefined / cycles / bigint are not allowed); flags are persisted verbatim into result snapshots and must be plain JSON.",
|
|
245
245
|
"define.experimentLabelInvalid": "experiment.labels.{{key}} must be a string or a finite number; labels are report-side grouping coordinates persisted verbatim into result snapshots.",
|
|
246
|
+
"define.experimentProvenanceFlagsInvalid": "experiment.provenanceFlags must be an array of flag key names (strings); it lists the flags recorded for provenance only, which stay out of the cache fingerprint.",
|
|
246
247
|
"define.experimentSetupNotFunction": "experiment.setup must be a function ((ctx) => void); use experiment.teardown for cleanup; to prepare the in-sandbox environment per experiment, chain .setup() hooks on the sandbox spec instead.",
|
|
247
248
|
"define.experimentClassifyFailureNotFunction": "experiment.classifyFailure must be a function ((failure) => FailureClass | undefined); it classifies failures that surface as third-party errors and must return undefined for anything it does not recognize.",
|
|
248
249
|
"define.experimentIdRejected": "defineExperiment does not accept id; ids are derived from file paths.",
|
package/src/i18n/zh-CN.ts
CHANGED
|
@@ -236,6 +236,7 @@ export const zhCN = {
|
|
|
236
236
|
"define.experimentAgentRequired": "defineExperiment 需要 agent。",
|
|
237
237
|
"define.experimentFlagNotJson": "experiment.flags.{{key}} 不是可 JSON 序列化的值(函数 / undefined / 循环引用 / bigint 不允许);flags 会原样进入结果快照,必须是纯 JSON。",
|
|
238
238
|
"define.experimentLabelInvalid": "experiment.labels.{{key}} 必须是字符串或有限数字;labels 是报告侧的归类坐标,会原样进入结果快照。",
|
|
239
|
+
"define.experimentProvenanceFlagsInvalid": "experiment.provenanceFlags 必须是 flag 键名(字符串)数组;它列出只作为出处记录、不进缓存指纹的那些 flag。",
|
|
239
240
|
"define.experimentSetupNotFunction": "experiment.setup 必须是函数((ctx) => void);要清理请挂 experiment.teardown;要按实验准备沙箱内环境请挂 sandbox spec 的 .setup() 钩子链。",
|
|
240
241
|
"define.experimentClassifyFailureNotFunction": "experiment.classifyFailure 必须是函数((failure) => FailureClass | undefined):它识别以第三方错误形态浮出的失败,认不出的一律返回 undefined 交给后续链路。",
|
|
241
242
|
"define.experimentIdRejected": "defineExperiment 不接受 id —— id 由文件路径推导。",
|
|
@@ -186,7 +186,7 @@ describe("live dashboard — 接线到 panel.ts", () => {
|
|
|
186
186
|
failed: 2,
|
|
187
187
|
elapsedMs: 134_000,
|
|
188
188
|
estimatedCostUSD: 0.84,
|
|
189
|
-
active: new Map([[key, { identity, who: "compare/bub-e2b", phase: "eval.run",
|
|
189
|
+
active: new Map([[key, { identity, who: "compare/bub-e2b", phase: "eval.run", startedAt: 0 }]]),
|
|
190
190
|
};
|
|
191
191
|
renderer.onLifecycle?.({ type: "attempt:start", at: 0, identity, who: "compare/bub-e2b", phase: "eval.run" }, state);
|
|
192
192
|
renderer.redrawDynamic?.(state);
|
|
@@ -265,7 +265,7 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
|
|
|
265
265
|
failed: 2,
|
|
266
266
|
elapsedMs: 134_000,
|
|
267
267
|
active: new Map([
|
|
268
|
-
[key, { identity, who: "compare/bub-e2b", phase: "eval.run",
|
|
268
|
+
[key, { identity, who: "compare/bub-e2b", phase: "eval.run", startedAt: 0, detail: longDetail }],
|
|
269
269
|
]),
|
|
270
270
|
};
|
|
271
271
|
renderer.onLifecycle?.(
|
|
@@ -315,7 +315,7 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
|
|
|
315
315
|
...createInitialRunFeedbackState(),
|
|
316
316
|
total: 1,
|
|
317
317
|
running: 1,
|
|
318
|
-
active: new Map([[key, { identity, who: "w1", phase: "eval.run",
|
|
318
|
+
active: new Map([[key, { identity, who: "w1", phase: "eval.run", startedAt: 0 }]]),
|
|
319
319
|
};
|
|
320
320
|
renderer.onLifecycle?.({ type: "attempt:start", at: 0, identity, who: "w1", phase: "eval.run" }, state);
|
|
321
321
|
renderer.redrawDynamic?.(state);
|
|
@@ -326,6 +326,35 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
|
|
|
326
326
|
expect(plain).toContain("● e1 w1 ");
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
it("时间列从 attempt 派发起算:阶段推进只换标签,不把时钟归零(存活性证明必须单调)", () => {
|
|
330
|
+
const { io, stderr, advance } = createFakeFeedbackIO({ stderr: { isTTY: true, columns: 200, rows: 30 } });
|
|
331
|
+
const renderer = createHumanRenderer({ io, command: "niceeval exp compare" });
|
|
332
|
+
const identity = { experimentId: "compare", evalId: "react-tooltip/pr-1271", attempt: 0 };
|
|
333
|
+
const who = "compare/codex--nowledge";
|
|
334
|
+
let state = reduceRunFeedback(createInitialRunFeedbackState(), {
|
|
335
|
+
type: "plan",
|
|
336
|
+
at: 0,
|
|
337
|
+
plan: { shape: { evals: 1, configs: 1, totalAttempts: 1, maxConcurrency: 1 }, reused: 0, reusedFailures: [] },
|
|
338
|
+
});
|
|
339
|
+
const start = { type: "attempt:start", at: 0, identity, who, phase: "eval.run" } as const;
|
|
340
|
+
state = reduceRunFeedback(state, start);
|
|
341
|
+
renderer.onLifecycle?.(start, state);
|
|
342
|
+
advance(262_000);
|
|
343
|
+
renderer.redrawDynamic?.(state);
|
|
344
|
+
expect(stripAnsi(stderr.writes.join(""))).toContain("4m 22s running eval");
|
|
345
|
+
|
|
346
|
+
// eval.run(几分钟)→ workspace.diff(秒级)是真实运行里最刺眼的一跳:旧实现按当前 phase
|
|
347
|
+
// 计时,这里回到 0s,读起来像这条 eval 重跑了。时间列答的是「这条派发多久了」,不是
|
|
348
|
+
// 「当前阶段跑了多久」——阶段各自的耗时由结果的 timing.phases 负责。
|
|
349
|
+
const mark = stderr.writes.length;
|
|
350
|
+
state = reduceRunFeedback(state, { type: "attempt:phase", at: 262_000, identity, phase: "workspace.diff" });
|
|
351
|
+
advance(1_000);
|
|
352
|
+
renderer.redrawDynamic?.(state);
|
|
353
|
+
const frame2 = stripAnsi(stderr.writes.slice(mark).join(""));
|
|
354
|
+
expect(frame2).toContain("4m 23s capturing diff");
|
|
355
|
+
expect(frame2).not.toContain(" 0s ");
|
|
356
|
+
});
|
|
357
|
+
|
|
329
358
|
it("身份列跨帧单调:长 id 出现后,后续短 id 所在帧的列宽不回缩", () => {
|
|
330
359
|
const { io, stderr } = createFakeFeedbackIO({ stderr: { isTTY: true, columns: 200, rows: 30 } });
|
|
331
360
|
const renderer = createHumanRenderer({ io, command: "niceeval exp compare" });
|
|
@@ -340,7 +369,7 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
|
|
|
340
369
|
total: 2,
|
|
341
370
|
running: 1,
|
|
342
371
|
queued: 1,
|
|
343
|
-
active: new Map([[longKey, { identity: longIdentity, who, phase: "eval.run",
|
|
372
|
+
active: new Map([[longKey, { identity: longIdentity, who, phase: "eval.run", startedAt: 0 }]]),
|
|
344
373
|
};
|
|
345
374
|
renderer.onLifecycle?.(
|
|
346
375
|
{ type: "attempt:start", at: 0, identity: longIdentity, who, phase: "eval.run" },
|
|
@@ -361,7 +390,7 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
|
|
|
361
390
|
total: 2,
|
|
362
391
|
running: 1,
|
|
363
392
|
passed: 1,
|
|
364
|
-
active: new Map([[shortKey, { identity: shortIdentity, who, phase: "eval.run",
|
|
393
|
+
active: new Map([[shortKey, { identity: shortIdentity, who, phase: "eval.run", startedAt: 1 }]]),
|
|
365
394
|
};
|
|
366
395
|
renderer.onLifecycle?.(
|
|
367
396
|
{ type: "attempt:start", at: 1, identity: shortIdentity, who, phase: "eval.run" },
|
|
@@ -389,7 +418,7 @@ describe("live dashboard — 宽终端下 ACTIVE 行与身份列分配", () => {
|
|
|
389
418
|
...createInitialRunFeedbackState(),
|
|
390
419
|
total: 1,
|
|
391
420
|
running: 1,
|
|
392
|
-
active: new Map([[key, { identity, who: longWho, phase: "eval.run",
|
|
421
|
+
active: new Map([[key, { identity, who: longWho, phase: "eval.run", startedAt: 0 }]]),
|
|
393
422
|
};
|
|
394
423
|
renderer.onLifecycle?.({ type: "attempt:start", at: 0, identity, who: longWho, phase: "eval.run" }, state);
|
|
395
424
|
renderer.redrawDynamic?.(state);
|
|
@@ -718,7 +718,9 @@ function formatActiveRow(
|
|
|
718
718
|
evalWidth: number,
|
|
719
719
|
whoWidth: number,
|
|
720
720
|
): string {
|
|
721
|
-
|
|
721
|
+
// 时间列从 attempt 派发起算,阶段推进不重置(见 ActiveAttempt.startedAt):这一列是存活性的
|
|
722
|
+
// 唯一证明,归零会被读成「这条 eval 重跑了」。
|
|
723
|
+
const elapsed = formatElapsed(io.clock.now() - active.startedAt).padStart(6);
|
|
722
724
|
const sym = "● ";
|
|
723
725
|
const evalCol = padTrunc(active.identity.evalId, evalWidth);
|
|
724
726
|
const whoCol = padTrunc(active.who, whoWidth);
|
|
@@ -460,6 +460,25 @@ describe("reduceRunFeedback: 守恒公式", () => {
|
|
|
460
460
|
expect(activeAfterPhase?.phase).toBe("workspace.diff");
|
|
461
461
|
expect(activeAfterPhase?.detail).toBeUndefined();
|
|
462
462
|
});
|
|
463
|
+
|
|
464
|
+
it("phase 变化不重置 startedAt:时间列是 attempt 的存活性证明,阶段边界只换标签", () => {
|
|
465
|
+
const a = ref("memory/a");
|
|
466
|
+
let state = reduceRunFeedback(createInitialRunFeedbackState(), {
|
|
467
|
+
type: "plan",
|
|
468
|
+
at: 0,
|
|
469
|
+
plan: { shape: { evals: 1, configs: 1, totalAttempts: 1, maxConcurrency: 1 }, reused: 0, reusedFailures: [] },
|
|
470
|
+
});
|
|
471
|
+
state = reduceRunFeedback(state, { type: "attempt:start", at: 1_000, identity: a, who: "codex", phase: "sandbox.queue" });
|
|
472
|
+
for (const [at, phase] of [
|
|
473
|
+
[3_000, "sandbox.create"],
|
|
474
|
+
[9_000, "sandbox.setup"],
|
|
475
|
+
[21_000, "eval.run"],
|
|
476
|
+
[283_000, "workspace.diff"],
|
|
477
|
+
] as const) {
|
|
478
|
+
state = reduceRunFeedback(state, { type: "attempt:phase", at, identity: a, phase });
|
|
479
|
+
expect([...state.active.values()][0]?.startedAt).toBe(1_000);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
463
482
|
});
|
|
464
483
|
|
|
465
484
|
describe("reduceRunFeedback: judge 预检运行级行", () => {
|
|
@@ -96,7 +96,9 @@ export function reduceRunFeedback(state: RunFeedbackState, event: RunFeedbackEve
|
|
|
96
96
|
identity: event.identity,
|
|
97
97
|
who: event.who,
|
|
98
98
|
phase: event.phase,
|
|
99
|
-
|
|
99
|
+
// active 行时间列的基准只在这里写一次:一条 attempt 的时间从派发起算,之后的阶段推进
|
|
100
|
+
// 只换标签不重置时钟(见 ActiveAttempt.startedAt 与下面的 attempt:phase 分支)。
|
|
101
|
+
startedAt: event.at,
|
|
100
102
|
});
|
|
101
103
|
return {
|
|
102
104
|
...state,
|
|
@@ -112,8 +114,9 @@ export function reduceRunFeedback(state: RunFeedbackState, event: RunFeedbackEve
|
|
|
112
114
|
if (!existing) return state; // 防御:识别不到的 attempt 静默忽略,不让 renderer 崩
|
|
113
115
|
const active = new Map(state.active);
|
|
114
116
|
// 进入新 phase 清空旧 detail —— 次要文本是绑定到具体 phase 的(如 running 阶段的
|
|
115
|
-
// "tool: shell"),不该原样带进下一个 phase
|
|
116
|
-
|
|
117
|
+
// "tool: shell"),不该原样带进下一个 phase 显示。`startedAt` 原样带过去(spread 保留):
|
|
118
|
+
// 阶段边界换的是「正在干什么」,不是「这条跑了多久」。
|
|
119
|
+
active.set(key, { ...existing, phase: event.phase, detail: undefined });
|
|
117
120
|
return { ...state, active };
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -238,3 +238,127 @@ describe("planCarry · timeoutMs 是携带资格判据,不进指纹哈希", () =
|
|
|
238
238
|
expect(plan.carriedResults).toEqual([]);
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
|
+
|
|
242
|
+
// 覆盖「缓存」分区的 provenanceFlags 行:声明为出处记录的 flag 不进指纹,只有这些键取值不同的
|
|
243
|
+
// 历史终态照常携带。fixture 里的历史结果一律按**整袋 flags** 算指纹——那正是声明之前落盘的口径,
|
|
244
|
+
// 这条通道要能把它们救回来(现实反例:隧道 URL 每次重启就换,换一次全部已完成结果作废重跑)。
|
|
245
|
+
describe("planCarry · provenanceFlags 不进指纹", () => {
|
|
246
|
+
const OLD_FLAGS = { memory: "nowledge", endpoint: "https://old.example" };
|
|
247
|
+
const NEW_FLAGS = { memory: "nowledge", endpoint: "https://new.example" };
|
|
248
|
+
|
|
249
|
+
function runWith(flags: Record<string, string>, provenanceFlags?: string[]): AgentRun {
|
|
250
|
+
return {
|
|
251
|
+
...makeRun("exp", ["e"], 1),
|
|
252
|
+
flags,
|
|
253
|
+
...(provenanceFlags !== undefined ? { provenanceFlags } : {}),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 声明之前的落盘:指纹按整袋 flags 算,快照记下当时那袋 flags。 */
|
|
258
|
+
async function priorFrom(evalDef: DiscoveredEval, flags: Record<string, string>): Promise<EvalResult> {
|
|
259
|
+
return result({
|
|
260
|
+
id: "e",
|
|
261
|
+
attempt: 0,
|
|
262
|
+
verdict: "passed",
|
|
263
|
+
fingerprint: await computeFingerprint(evalDef, runWith(flags)),
|
|
264
|
+
experiment: { flags, runs: 1, earlyExit: false, selectedEvalIds: ["e"] },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
it("只有 provenance flag 的取值不同时,历史终态照常携带", async () => {
|
|
269
|
+
const evals = [makeEval("e")];
|
|
270
|
+
const prior = await priorFrom(evals[0]!, OLD_FLAGS);
|
|
271
|
+
|
|
272
|
+
// 没声明:endpoint 变了就是配置变了,全部作废重跑(修改前的行为)。
|
|
273
|
+
const without = await planCarry(evals, [runWith(NEW_FLAGS)], [prior]);
|
|
274
|
+
expect(without.carriedAttemptsByKey.get("exp|e")).toBeUndefined();
|
|
275
|
+
|
|
276
|
+
// 声明之后:同一份历史结果照常携带,不需要重跑一轮来"洗"它,也不动已落盘的文件。
|
|
277
|
+
const with_ = await planCarry(evals, [runWith(NEW_FLAGS, ["endpoint"])], [prior]);
|
|
278
|
+
expect(with_.carriedAttemptsByKey.get("exp|e")).toEqual(new Set([0]));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("其余 flag 有任一不同则照旧作废——放行只限声明过的键", async () => {
|
|
282
|
+
const evals = [makeEval("e")];
|
|
283
|
+
const prior = await priorFrom(evals[0]!, OLD_FLAGS);
|
|
284
|
+
// endpoint 声明为 provenance,但 memory 这个真影响行为的 flag 也变了:不能携带。
|
|
285
|
+
const run = runWith({ memory: "baseline", endpoint: "https://new.example" }, ["endpoint"]);
|
|
286
|
+
|
|
287
|
+
const plan = await planCarry(evals, [run], [prior]);
|
|
288
|
+
|
|
289
|
+
expect(plan.carriedAttemptsByKey.get("exp|e")).toBeUndefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("声明之后落盘的结果(指纹已按抹掉 provenance 的口径算)在下一次换值后照常携带", async () => {
|
|
293
|
+
const evals = [makeEval("e")];
|
|
294
|
+
// 上一轮已经带着声明跑:落盘指纹 = 抹掉 endpoint 之后算的那个。
|
|
295
|
+
const prior = result({
|
|
296
|
+
id: "e",
|
|
297
|
+
attempt: 0,
|
|
298
|
+
verdict: "passed",
|
|
299
|
+
fingerprint: await computeFingerprint(evals[0]!, runWith(OLD_FLAGS, ["endpoint"])),
|
|
300
|
+
experiment: { flags: OLD_FLAGS, runs: 1, earlyExit: false, selectedEvalIds: ["e"] },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const plan = await planCarry(evals, [runWith(NEW_FLAGS, ["endpoint"])], [prior]);
|
|
304
|
+
|
|
305
|
+
expect(plan.carriedAttemptsByKey.get("exp|e")).toEqual(new Set([0]));
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("候选 flags 可以来自实验的历史快照——携带条目背着更早那轮的指纹,本轮快照的 flags 对不上它", async () => {
|
|
309
|
+
const evals = [makeEval("e")];
|
|
310
|
+
// 现实形态:上一轮把这条结果**携带**进了自己的快照,指纹还是更早那轮(OLD_FLAGS)算的,
|
|
311
|
+
// 而它所在快照记的 flags 已经是中间那轮(MID)的。只看结果自带的那袋永远对不上。
|
|
312
|
+
const MID_FLAGS = { memory: "nowledge", endpoint: "https://mid.example" };
|
|
313
|
+
const prior = result({
|
|
314
|
+
id: "e",
|
|
315
|
+
attempt: 0,
|
|
316
|
+
verdict: "passed",
|
|
317
|
+
fingerprint: await computeFingerprint(evals[0]!, runWith(OLD_FLAGS)),
|
|
318
|
+
experiment: { flags: MID_FLAGS, runs: 1, earlyExit: false, selectedEvalIds: ["e"] },
|
|
319
|
+
});
|
|
320
|
+
const run = runWith(NEW_FLAGS, ["endpoint"]);
|
|
321
|
+
|
|
322
|
+
const withoutHistory = await planCarry(evals, [run], [prior]);
|
|
323
|
+
expect(withoutHistory.carriedAttemptsByKey.get("exp|e")).toBeUndefined();
|
|
324
|
+
|
|
325
|
+
const withHistory = await planCarry(evals, [run], [prior], undefined, undefined, new Map([["exp", [OLD_FLAGS]]]));
|
|
326
|
+
expect(withHistory.carriedAttemptsByKey.get("exp|e")).toEqual(new Set([0]));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("历史候选袋子同样只放行 provenance 键上的差异", async () => {
|
|
330
|
+
const evals = [makeEval("e")];
|
|
331
|
+
const prior = result({
|
|
332
|
+
id: "e",
|
|
333
|
+
attempt: 0,
|
|
334
|
+
verdict: "passed",
|
|
335
|
+
fingerprint: await computeFingerprint(evals[0]!, runWith({ memory: "baseline", endpoint: "https://old.example" })),
|
|
336
|
+
experiment: { flags: { memory: "baseline", endpoint: "https://old.example" }, runs: 1, earlyExit: false, selectedEvalIds: ["e"] },
|
|
337
|
+
});
|
|
338
|
+
// 历史袋子里 memory=baseline,本次 memory=nowledge:抹掉 endpoint 后仍不相等,不放行。
|
|
339
|
+
const plan = await planCarry(
|
|
340
|
+
evals,
|
|
341
|
+
[runWith(NEW_FLAGS, ["endpoint"])],
|
|
342
|
+
[prior],
|
|
343
|
+
undefined,
|
|
344
|
+
undefined,
|
|
345
|
+
new Map([["exp", [{ memory: "baseline", endpoint: "https://old.example" }]]]),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(plan.carriedAttemptsByKey.get("exp|e")).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("落盘缺 ExperimentRunInfo.flags(第三方 harness)时无从反事实重算,保守不携带", async () => {
|
|
352
|
+
const evals = [makeEval("e")];
|
|
353
|
+
const prior = result({
|
|
354
|
+
id: "e",
|
|
355
|
+
attempt: 0,
|
|
356
|
+
verdict: "passed",
|
|
357
|
+
fingerprint: await computeFingerprint(evals[0]!, runWith(OLD_FLAGS)),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const plan = await planCarry(evals, [runWith(NEW_FLAGS, ["endpoint"])], [prior]);
|
|
361
|
+
|
|
362
|
+
expect(plan.carriedAttemptsByKey.get("exp|e")).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
import { sandboxRunInfo } from "../sandbox/resolve.ts";
|
|
7
|
-
import type { DiscoveredEval, EvalResult, SandboxOption } from "../types.ts";
|
|
7
|
+
import type { DiscoveredEval, EvalResult, JsonValue, SandboxOption } from "../types.ts";
|
|
8
8
|
import type { AgentRun } from "./types.ts";
|
|
9
9
|
import { prepareRunSandboxes, sandboxForEval } from "./sandbox-selection.ts";
|
|
10
10
|
import { selectedEvalsForRun } from "./eval-selection.ts";
|
|
@@ -13,15 +13,33 @@ export function cacheKey(run: AgentRun, evalId: string): string {
|
|
|
13
13
|
return `${run.experimentId ?? ""}|${evalId}`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 指纹口径里的 flags:去掉实验声明为出处记录的键(`ExperimentDef.provenanceFlags`)。
|
|
18
|
+
* 这些键照常落盘、照常透传 `ctx.flags` / `t.flags`,只是不参与可比性——隧道 URL、跑批时刻
|
|
19
|
+
* 这类连接坐标每次都变,把它们算进指纹会让每一次坐标轮换作废全部已完成结果。
|
|
20
|
+
*/
|
|
21
|
+
function fingerprintFlags(flags: Record<string, JsonValue>, provenanceFlags: readonly string[] | undefined): Record<string, JsonValue> {
|
|
22
|
+
if (!provenanceFlags?.length) return flags;
|
|
23
|
+
const drop = new Set(provenanceFlags);
|
|
24
|
+
const out: Record<string, JsonValue> = {};
|
|
25
|
+
for (const [k, v] of Object.entries(flags)) if (!drop.has(k)) out[k] = v;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* @param sourceCache 按 sourcePath 缓存文件内容:一个矩阵(实验 × eval)会对同一批源文件
|
|
18
31
|
* 反复算指纹,不带缓存会在任何 attempt 起跑前做 E×N 次重复文件读。
|
|
32
|
+
* @param flagsOverride 用这份 flags 代替 `run.flags` 的指纹口径算一遍。只有一个用途:
|
|
33
|
+
* 对已落盘结果做**反事实重算**——「把 flags 换成它当时那份,指纹还相等吗」等价于问
|
|
34
|
+
* 「除 flags 外的一切是否都没变」,`acceptableFingerprints` 用它判定某条历史结果与本次
|
|
35
|
+
* 规划的差异是否完全落在 provenance flag 上。
|
|
19
36
|
*/
|
|
20
37
|
export async function computeFingerprint(
|
|
21
38
|
evalDef: DiscoveredEval,
|
|
22
39
|
run: AgentRun,
|
|
23
40
|
sourceCache?: Map<string, Promise<string>>,
|
|
24
41
|
configSandbox?: SandboxOption,
|
|
42
|
+
flagsOverride?: Record<string, JsonValue>,
|
|
25
43
|
): Promise<string> {
|
|
26
44
|
let sourcePromise = sourceCache?.get(evalDef.sourcePath);
|
|
27
45
|
if (!sourcePromise) {
|
|
@@ -41,7 +59,7 @@ export async function computeFingerprint(
|
|
|
41
59
|
experimentId: run.experimentId,
|
|
42
60
|
agent: run.agent.name,
|
|
43
61
|
model: run.model,
|
|
44
|
-
flags: run.flags,
|
|
62
|
+
flags: flagsOverride ?? fingerprintFlags(run.flags, run.provenanceFlags),
|
|
45
63
|
sandbox: sandboxRunInfo(sandboxForEval(run, evalDef, configSandbox)),
|
|
46
64
|
strict: run.strict,
|
|
47
65
|
},
|
|
@@ -56,6 +74,13 @@ export async function computeFingerprint(
|
|
|
56
74
|
export interface CarryPlan {
|
|
57
75
|
/** `cacheKey(run, evalId)` → 本次规划出的指纹,供调用方按同一口径判断"这条要不要携入"。 */
|
|
58
76
|
plannedFingerprints: Map<string, string>;
|
|
77
|
+
/**
|
|
78
|
+
* `cacheKey(run, evalId)` → 这条组合**可以携带的全部指纹**:本次规划的那个,加上
|
|
79
|
+
* 「只在 provenance flag 上与本次不同」的历史口径(见 `acceptableFingerprints`)。
|
|
80
|
+
* 没声明 provenance flag 时恒是单元素集合 = `plannedFingerprints` 的那一个。
|
|
81
|
+
* 携带判定一律读这个集合,`plannedFingerprints` 只用来给新跑的 attempt 落盘打戳。
|
|
82
|
+
*/
|
|
83
|
+
acceptableFingerprints: Map<string, Set<string>>;
|
|
59
84
|
/**
|
|
60
85
|
* 携带以 attempt 为粒度:命中携入条件(该 attempt 自身 passed/failed 终态 + 指纹匹配)的
|
|
61
86
|
* `${experimentId}|${evalId}` → 该 eval 下具体携入的 attempt 序号集合(0-based)。同一个
|
|
@@ -86,7 +111,9 @@ export function resolvedTimeoutMsForCarry(run: AgentRun, evalDef: DiscoveredEval
|
|
|
86
111
|
* 1. 该 attempt 自己是终态(`passed` / `failed`)。`errored` 是框架/环境层面的不确定失败,
|
|
87
112
|
* 判定本身不可信;`skipped` 根本没跑。同一 eval 的别的序号命中不能连带把它捎上
|
|
88
113
|
* (反例与修法见 memory 的 carry-must-be-per-attempt-not-whole-eval-key)。
|
|
89
|
-
* 2. 该 attempt 落盘的 `fingerprint`
|
|
114
|
+
* 2. 该 attempt 落盘的 `fingerprint` 落在本次的可携带指纹集合里(`CarryPlan.acceptableFingerprints`
|
|
115
|
+
* 的那一条,通常只有本次规划出的那一个;声明了 provenance flag 时还含「只在这些键上与本次
|
|
116
|
+
* 不同」的历史口径)。
|
|
90
117
|
* 3. 该 attempt 的 `durationMs` 不超过本次 resolved 的 `timeoutMs`——`timeoutMs` 是携带资格
|
|
91
118
|
* 判据、不进指纹哈希(docs/runner.md「缓存:指纹去重」)。
|
|
92
119
|
*
|
|
@@ -96,15 +123,15 @@ export function resolvedTimeoutMsForCarry(run: AgentRun, evalDef: DiscoveredEval
|
|
|
96
123
|
export function carriableAttempts(
|
|
97
124
|
priorResults: EvalResult[] | undefined,
|
|
98
125
|
key: string,
|
|
99
|
-
|
|
126
|
+
fingerprints: ReadonlySet<string> | undefined,
|
|
100
127
|
timeoutMs: number,
|
|
101
128
|
): EvalResult[] {
|
|
102
|
-
if (!priorResults?.length ||
|
|
129
|
+
if (!priorResults?.length || fingerprints === undefined || fingerprints.size === 0) return [];
|
|
103
130
|
const out: EvalResult[] = [];
|
|
104
131
|
for (const r of priorResults) {
|
|
105
132
|
if (!r.experimentId || `${r.experimentId}|${r.id}` !== key) continue;
|
|
106
133
|
const isTerminalVerdict = r.verdict === "passed" || r.verdict === "failed";
|
|
107
|
-
if (!isTerminalVerdict || r.fingerprint === undefined || r.fingerprint
|
|
134
|
+
if (!isTerminalVerdict || r.fingerprint === undefined || !fingerprints.has(r.fingerprint)) continue;
|
|
108
135
|
// `durationMs` 在 `EvalResult` 上是必填字段,正常落盘不会缺失;这里的 `typeof` 防御只处理
|
|
109
136
|
// 磁盘数据损坏等异常情形——保守地判不可携带,而不是当 0 处理(当 0 会让所有旧记录都通过
|
|
110
137
|
// 判据,把「数据缺失」悄悄伪装成「跑得很快」)。
|
|
@@ -136,6 +163,7 @@ export async function planCarry(
|
|
|
136
163
|
priorResults: EvalResult[] | undefined,
|
|
137
164
|
configSandbox?: SandboxOption,
|
|
138
165
|
configTimeoutMs?: number,
|
|
166
|
+
flagBagsByExperiment?: Map<string, Record<string, JsonValue>[]>,
|
|
139
167
|
): Promise<CarryPlan> {
|
|
140
168
|
prepareRunSandboxes(evals, agentRuns, configSandbox);
|
|
141
169
|
const sourceCache = new Map<string, Promise<string>>();
|
|
@@ -144,15 +172,32 @@ export async function planCarry(
|
|
|
144
172
|
// 这次的携带资格线是多少」——同一个 key 在同一次 planCarry 调用里只对应一个 (run, evalDef)
|
|
145
173
|
// 组合,与 plannedFingerprints 的 key 语义一致。
|
|
146
174
|
const plannedTimeoutMs = new Map<string, number>();
|
|
175
|
+
const acceptable = new Map<string, Set<string>>();
|
|
147
176
|
const jobs: Promise<void>[] = [];
|
|
148
177
|
for (const run of agentRuns) {
|
|
149
178
|
for (const evalDef of selectedEvalsForRun(evals, run)) {
|
|
150
179
|
const key = cacheKey(run, evalDef.id);
|
|
151
180
|
plannedTimeoutMs.set(key, resolvedTimeoutMsForCarry(run, evalDef, configTimeoutMs));
|
|
152
181
|
jobs.push(
|
|
153
|
-
|
|
182
|
+
(async () => {
|
|
183
|
+
const fp = await computeFingerprint(evalDef, run, sourceCache, configSandbox);
|
|
154
184
|
plannedFingerprints.set(key, fp);
|
|
155
|
-
|
|
185
|
+
acceptable.set(
|
|
186
|
+
key,
|
|
187
|
+
await acceptableFingerprints({
|
|
188
|
+
evalDef,
|
|
189
|
+
run,
|
|
190
|
+
key,
|
|
191
|
+
priorResults,
|
|
192
|
+
primary: fp,
|
|
193
|
+
sourceCache,
|
|
194
|
+
configSandbox,
|
|
195
|
+
...(run.experimentId !== undefined && flagBagsByExperiment?.has(run.experimentId)
|
|
196
|
+
? { historicalFlagBags: flagBagsByExperiment.get(run.experimentId)! }
|
|
197
|
+
: {}),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
})(),
|
|
156
201
|
);
|
|
157
202
|
}
|
|
158
203
|
}
|
|
@@ -162,8 +207,8 @@ export async function planCarry(
|
|
|
162
207
|
// 不可能对「哪些携入」得出不同结论。
|
|
163
208
|
const carriedAttemptsByKey = new Map<string, Set<number>>();
|
|
164
209
|
const hit = new Set<EvalResult>();
|
|
165
|
-
for (const
|
|
166
|
-
const carried = carriableAttempts(priorResults, key,
|
|
210
|
+
for (const key of plannedFingerprints.keys()) {
|
|
211
|
+
const carried = carriableAttempts(priorResults, key, acceptable.get(key), plannedTimeoutMs.get(key) ?? Infinity);
|
|
167
212
|
if (carried.length === 0) continue;
|
|
168
213
|
const indices = new Set<number>();
|
|
169
214
|
for (const r of carried) {
|
|
@@ -174,7 +219,61 @@ export async function planCarry(
|
|
|
174
219
|
}
|
|
175
220
|
// 按 priorResults 的原始顺序输出(调用方的展示顺序不因分组而抖动)。
|
|
176
221
|
const carriedResults = (priorResults ?? []).filter((r) => hit.has(r));
|
|
177
|
-
return { plannedFingerprints, carriedAttemptsByKey, carriedResults };
|
|
222
|
+
return { plannedFingerprints, acceptableFingerprints: acceptable, carriedAttemptsByKey, carriedResults };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 这条 `(experimentId, evalId)` 本次可以携带的指纹全集。
|
|
227
|
+
*
|
|
228
|
+
* 没声明 provenance flag 时就是 `{ primary }`——判据与「指纹相等」逐字等价,一条历史结果都
|
|
229
|
+
* 不会因此多携入。声明了之后多出一类:**只在 provenance flag 上与本次不同**的历史口径。
|
|
230
|
+
*
|
|
231
|
+
* 判定不靠比对两串哈希的差异(哈希不可差分),而是**反事实重算**:取该历史结果所属快照记下的
|
|
232
|
+
* `ExperimentRunInfo.flags`(整袋原样,`applySnapshotDefaults` 已把它挂在 `EvalResult.experiment`
|
|
233
|
+
* 上),用它替换本次的 flags 口径重算一遍指纹——算出来等于历史那一串,就证明「除 flags 外的
|
|
234
|
+
* 一切(eval 源码、agent、model、sandbox、strict…)都没变」。再要求两袋 flags 抹掉 provenance
|
|
235
|
+
* 键之后逐字相等,才把这串历史指纹计入可携带集合:真改了某个影响行为的 flag(`webResearch`
|
|
236
|
+
* 从 true 改成 false)照旧作废,不会被这条通道放行。
|
|
237
|
+
*
|
|
238
|
+
* 历史结果落盘时的指纹口径是「整袋 flags」(provenance 概念引入之前),所以两个口径都要试:
|
|
239
|
+
* 整袋(老结果)与抹掉 provenance 键的那袋(声明之后跑出来的结果,与 primary 相同则自然去重)。
|
|
240
|
+
*/
|
|
241
|
+
export async function acceptableFingerprints(args: {
|
|
242
|
+
evalDef: DiscoveredEval;
|
|
243
|
+
run: AgentRun;
|
|
244
|
+
key: string;
|
|
245
|
+
priorResults: EvalResult[] | undefined;
|
|
246
|
+
/** 本次规划出的指纹(新跑的 attempt 用它落盘打戳)。 */
|
|
247
|
+
primary: string;
|
|
248
|
+
/**
|
|
249
|
+
* 该实验历史快照记下过的 flags(见 `loadCarryInputs`)。候选假设的来源之一,与结果自带的那袋
|
|
250
|
+
* 并列——携带条目带着**产出它那一轮**的指纹合入新快照,那一轮的 flags 只在更早的快照里留着。
|
|
251
|
+
*/
|
|
252
|
+
historicalFlagBags?: readonly Record<string, JsonValue>[];
|
|
253
|
+
sourceCache?: Map<string, Promise<string>>;
|
|
254
|
+
configSandbox?: SandboxOption;
|
|
255
|
+
}): Promise<Set<string>> {
|
|
256
|
+
const { evalDef, run, key, priorResults, primary, historicalFlagBags, sourceCache, configSandbox } = args;
|
|
257
|
+
const out = new Set([primary]);
|
|
258
|
+
if (!run.provenanceFlags?.length) return out;
|
|
259
|
+
const currentStripped = stableJson(fingerprintFlags(run.flags, run.provenanceFlags));
|
|
260
|
+
const candidates: Record<string, JsonValue>[] = [];
|
|
261
|
+
for (const r of priorResults ?? []) {
|
|
262
|
+
if (!r.experimentId || `${r.experimentId}|${r.id}` !== key) continue;
|
|
263
|
+
// 第三方落盘 / 缺 ExperimentRunInfo 时这里没有袋子可试,只能靠 historicalFlagBags。
|
|
264
|
+
if (r.experiment?.flags !== undefined) candidates.push(r.experiment.flags);
|
|
265
|
+
}
|
|
266
|
+
candidates.push(...(historicalFlagBags ?? []));
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
for (const bag of candidates) {
|
|
269
|
+
const bagJson = stableJson(bag);
|
|
270
|
+
if (seen.has(bagJson)) continue;
|
|
271
|
+
seen.add(bagJson);
|
|
272
|
+
// 抹掉 provenance 键之后必须逐字相等:差异只准落在这些键上。
|
|
273
|
+
if (stableJson(fingerprintFlags(bag, run.provenanceFlags)) !== currentStripped) continue;
|
|
274
|
+
out.add(await computeFingerprint(evalDef, run, sourceCache, configSandbox, bag));
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
178
277
|
}
|
|
179
278
|
|
|
180
279
|
/** 键序稳定的 JSON 序列化(对象键排序),保证同一 payload 永远同一指纹。 */
|
package/src/runner/run.test.ts
CHANGED
|
@@ -460,6 +460,7 @@ describe("runEvals · fresh EvalResult.locator 在 reporter 观察到之前已
|
|
|
460
460
|
const { summary, root } = await run([evalDef], [agentRun], {
|
|
461
461
|
carryPlan: {
|
|
462
462
|
plannedFingerprints: new Map(),
|
|
463
|
+
acceptableFingerprints: new Map(),
|
|
463
464
|
carriedAttemptsByKey: new Map([[`${experimentId}|${evalId}`, new Set([0])]]),
|
|
464
465
|
carriedResults: [carried],
|
|
465
466
|
},
|
|
@@ -812,6 +813,7 @@ describe("runEvals · 携入数量少于本次请求的 runs 时,差额必须真
|
|
|
812
813
|
const { summary } = await run([evalDef], [agentRun], {
|
|
813
814
|
carryPlan: {
|
|
814
815
|
plannedFingerprints: new Map(),
|
|
816
|
+
acceptableFingerprints: new Map(),
|
|
815
817
|
carriedAttemptsByKey: new Map([[`${experimentId}|${evalId}`, new Set([0])]]),
|
|
816
818
|
carriedResults: [carried],
|
|
817
819
|
},
|
|
@@ -882,6 +884,7 @@ describe("runEvals · 携入数量少于本次请求的 runs 时,差额必须真
|
|
|
882
884
|
const { summary } = await run([evalDef], [agentRun], {
|
|
883
885
|
carryPlan: {
|
|
884
886
|
plannedFingerprints: new Map(),
|
|
887
|
+
acceptableFingerprints: new Map(),
|
|
885
888
|
carriedAttemptsByKey: new Map([[`${experimentId}|${evalId}`, new Set([0])]]),
|
|
886
889
|
carriedResults: [carried],
|
|
887
890
|
},
|
|
@@ -941,6 +944,7 @@ describe("runEvals · 携入数量少于本次请求的 runs 时,差额必须真
|
|
|
941
944
|
const { summary } = await run([evalDef], [agentRun], {
|
|
942
945
|
carryPlan: {
|
|
943
946
|
plannedFingerprints: new Map(),
|
|
947
|
+
acceptableFingerprints: new Map(),
|
|
944
948
|
carriedAttemptsByKey: new Map([[`${experimentId}|${evalId}`, new Set([1])]]),
|
|
945
949
|
carriedResults: [carried],
|
|
946
950
|
},
|
|
@@ -1068,6 +1072,7 @@ describe("runEvals · 实验级 setup/teardown", () => {
|
|
|
1068
1072
|
const { summary } = await run([evalDef], [agentRun], {
|
|
1069
1073
|
carryPlan: {
|
|
1070
1074
|
plannedFingerprints: new Map(),
|
|
1075
|
+
acceptableFingerprints: new Map(),
|
|
1071
1076
|
carriedAttemptsByKey: new Map([[`${experimentId}|done`, new Set([0])]]),
|
|
1072
1077
|
carriedResults: [carried],
|
|
1073
1078
|
},
|
|
@@ -2197,6 +2202,49 @@ describe("runEvals · 用例锁: 取锁时机", () => {
|
|
|
2197
2202
|
expect(await lockFilesRemaining(root)).toEqual([]);
|
|
2198
2203
|
});
|
|
2199
2204
|
|
|
2205
|
+
it("携带条目合入本次快照时指纹按本次规划重新打戳,不背着产出它那一轮的旧指纹", async () => {
|
|
2206
|
+
const evalId = "carry-restamp-eval";
|
|
2207
|
+
const experimentId = "carry-restamp-exp";
|
|
2208
|
+
const evalDef = makeEval(evalId, async () => {
|
|
2209
|
+
throw new Error("carried attempt must not be dispatched");
|
|
2210
|
+
});
|
|
2211
|
+
const base = {
|
|
2212
|
+
agent: makeAgent("agent-carry-restamp"),
|
|
2213
|
+
runs: 1,
|
|
2214
|
+
earlyExit: true,
|
|
2215
|
+
sandbox: fakeSandboxSpec(),
|
|
2216
|
+
timeoutMs: 5_000,
|
|
2217
|
+
selectedEvalIds: [evalId],
|
|
2218
|
+
experimentId,
|
|
2219
|
+
};
|
|
2220
|
+
// 上一轮的 endpoint 与本轮不同,但它声明成 provenance flag:结果照常携带,
|
|
2221
|
+
// 而它落盘的指纹是**整袋 flags**(含旧 endpoint)算的,与本轮规划的那个不相等。
|
|
2222
|
+
const oldFlags = { endpoint: "https://old.example" };
|
|
2223
|
+
const agentRun: AgentRun = { ...base, flags: { endpoint: "https://new.example" }, provenanceFlags: ["endpoint"] };
|
|
2224
|
+
const oldFingerprint = await computeFingerprint(evalDef, { ...base, flags: oldFlags });
|
|
2225
|
+
const plannedFingerprint = await computeFingerprint(evalDef, agentRun);
|
|
2226
|
+
expect(oldFingerprint).not.toBe(plannedFingerprint);
|
|
2227
|
+
|
|
2228
|
+
const prior: EvalResult = {
|
|
2229
|
+
id: evalId,
|
|
2230
|
+
experimentId,
|
|
2231
|
+
agent: agentRun.agent.name,
|
|
2232
|
+
verdict: "passed",
|
|
2233
|
+
attempt: 0,
|
|
2234
|
+
fingerprint: oldFingerprint,
|
|
2235
|
+
experiment: { flags: oldFlags, runs: 1, earlyExit: false, selectedEvalIds: [evalId] },
|
|
2236
|
+
startedAt: new Date().toISOString(),
|
|
2237
|
+
durationMs: 1,
|
|
2238
|
+
assertions: [],
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
const { summary } = await runWithPriorResults([evalDef], [agentRun], { priorResults: [prior] });
|
|
2242
|
+
|
|
2243
|
+
expect(summary.results).toHaveLength(1);
|
|
2244
|
+
expect(summary.results[0]!.verdict).toBe("passed");
|
|
2245
|
+
expect(summary.results[0]!.fingerprint).toBe(plannedFingerprint);
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2200
2248
|
it("等锁用例不触发实验级 setup:等待期间 setup 计数保持 0,接管后才恰好执行一次", async () => {
|
|
2201
2249
|
vi.useFakeTimers();
|
|
2202
2250
|
try {
|
package/src/runner/run.ts
CHANGED
|
@@ -133,10 +133,23 @@ export async function runEvals(opts: RunOptions): Promise<InvocationSummary> {
|
|
|
133
133
|
// 等),判定本身不可信,必须重跑。跳过/fingerprint 不匹配同样重跑。--force 跳过此逻辑
|
|
134
134
|
// (cli.ts 在 --force 时不传 priorResults,也不算 carryPlan)。
|
|
135
135
|
// carryPlan 优先用调用方(cli.ts,为了 live 表格)已经算好的那份,不重算一遍。
|
|
136
|
-
const { plannedFingerprints, carriedAttemptsByKey, carriedResults } =
|
|
136
|
+
const { plannedFingerprints, acceptableFingerprints, carriedAttemptsByKey, carriedResults: planCarriedResults } =
|
|
137
137
|
opts.carryPlan ??
|
|
138
138
|
(await planCarry(opts.evals, opts.agentRuns, opts.priorResults, opts.config.sandbox, opts.config.timeoutMs));
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* 携带条目合入本次快照时,指纹按**本次**口径重新打戳。携带的含义就是「这条已落盘的结果对
|
|
142
|
+
* 本次规划的输入依然成立」,那它在新快照里就该带本次的指纹——否则携带条目会一直背着产出
|
|
143
|
+
* 它那一轮的指纹漂下去,而新快照记的是本轮的 `ExperimentRunInfo.flags`,两者对不上,
|
|
144
|
+
* 下一轮的反事实重算(见 fingerprint.ts 的 `acceptableFingerprints`)就得靠翻更早的快照
|
|
145
|
+
* 才能对上号。判定面不受影响:能走到这里,说明这条已经过了携带资格判据。
|
|
146
|
+
*/
|
|
147
|
+
const restampCarried = (r: EvalResult): EvalResult => {
|
|
148
|
+
const fp = plannedFingerprints.get(`${r.experimentId ?? ""}|${r.id}`);
|
|
149
|
+
return fp === undefined || fp === r.fingerprint ? r : { ...r, fingerprint: fp };
|
|
150
|
+
};
|
|
151
|
+
const carriedResults = planCarriedResults.map(restampCarried);
|
|
152
|
+
|
|
140
153
|
// 展开 attempts
|
|
141
154
|
// 外层按「round」(run index)迭代,内层按 eval 迭代:同一 key 的第 i+1 次 attempt 排在
|
|
142
155
|
// 所有 eval 的第 i 次之后,earlyExit 开启时第 0 轮通过的 eval,其后续轮大多还没入池就被跳过。
|
|
@@ -1053,9 +1066,9 @@ export async function runEvals(opts: RunOptions): Promise<InvocationSummary> {
|
|
|
1053
1066
|
* + 用例锁的状态下做的,per-case 的问题("这条用例现在还缺哪些 attempt")不能用全根扫描去
|
|
1054
1067
|
* 回答——实测 110 ms/条 vs 0.3 ms/条,后者才付得起「每次取锁都重查」。
|
|
1055
1068
|
*
|
|
1056
|
-
* 判据不重跑 `planCarry`:本次 Invocation 的 `
|
|
1057
|
-
* 逐条 attempt 过 `carriableAttempts`(终态 +
|
|
1058
|
-
*
|
|
1069
|
+
* 判据不重跑 `planCarry`:本次 Invocation 的 `acceptableFingerprints` 整场是常量,重查只需要
|
|
1070
|
+
* 逐条 attempt 过 `carriableAttempts`(终态 + 指纹在可携带集合里 + durationMs ≤ resolved
|
|
1071
|
+
* timeoutMs),与静态规划共用同一个函数。
|
|
1059
1072
|
*/
|
|
1060
1073
|
const recheckCarry = async (
|
|
1061
1074
|
st: CaseLockState,
|
|
@@ -1076,7 +1089,7 @@ export async function runEvals(opts: RunOptions): Promise<InvocationSummary> {
|
|
|
1076
1089
|
const carried = carriableAttempts(
|
|
1077
1090
|
freshPrior,
|
|
1078
1091
|
key,
|
|
1079
|
-
|
|
1092
|
+
acceptableFingerprints.get(key),
|
|
1080
1093
|
resolvedTimeoutMsForCarry(a0.run, a0.evalDef, opts.config.timeoutMs),
|
|
1081
1094
|
);
|
|
1082
1095
|
for (const r of carried) {
|
|
@@ -1084,7 +1097,7 @@ export async function runEvals(opts: RunOptions): Promise<InvocationSummary> {
|
|
|
1084
1097
|
st.pending.delete(r.attempt);
|
|
1085
1098
|
st.carried.add(r.attempt);
|
|
1086
1099
|
newlyCarried.push(r.attempt);
|
|
1087
|
-
lateCarriedResults.push(r);
|
|
1100
|
+
lateCarriedResults.push(restampCarried(r));
|
|
1088
1101
|
if (r.verdict === "passed") {
|
|
1089
1102
|
passedKeys.add(`${experimentId}|${a0.run.agent.name}|${a0.run.model ?? ""}|${evalId}`);
|
|
1090
1103
|
}
|
package/src/runner/types.ts
CHANGED
|
@@ -525,6 +525,21 @@ export interface ExperimentDef {
|
|
|
525
525
|
* (defineExperiment 解析时校验,非 JSON 直接报错),经 ctx.flags 透传给 adapter、
|
|
526
526
|
* t.flags 暴露给 eval,并原样进入结果快照的 ExperimentRunInfo.flags。 */
|
|
527
527
|
flags?: Record<string, JsonValue>;
|
|
528
|
+
/**
|
|
529
|
+
* `flags` 里只作为**出处记录**的键名:照常落盘、照常透给 `ctx.flags` / `t.flags`,但不参与
|
|
530
|
+
* 可比性配置——值变了不作废任何已有结果,已跑完的照常携带(carry)。
|
|
531
|
+
*
|
|
532
|
+
* 给的是「每次跑都可能换、但换了不改变 attempt 里发生什么」的坐标:隧道 / 反向代理 URL、
|
|
533
|
+
* 服务端实例地址、跑批时刻这类。它们要留在 `flags` 里(报告要按 `flag()` 看这轮连的是哪个,
|
|
534
|
+
* eval 或 adapter 也可能要读),又不该像 `webResearch: true → false` 那样让缓存全部失效。
|
|
535
|
+
*
|
|
536
|
+
* 声明前跑出来的结果同样携带得到:携带判定按快照记下的历史 flags 做一次反事实重算,
|
|
537
|
+
* 确认差异完全落在这些键上(见 `runner/fingerprint.ts` 的 `acceptableFingerprints`)。
|
|
538
|
+
* 键不必存在于 `flags` 里——把一个键从 `flags` 移走时留着这条声明,历史结果照样不作废。
|
|
539
|
+
*
|
|
540
|
+
* 完全不需要在运行时被看见的事实用 `labels`,那是报告侧坐标(本来就不进指纹)。
|
|
541
|
+
*/
|
|
542
|
+
provenanceFlags?: readonly string[];
|
|
528
543
|
/**
|
|
529
544
|
* 报告归类标注:实验在各对比轴上的坐标(如 `{ line: "codex", memory: "mempal" }`)。
|
|
530
545
|
* 值域 string | number(解析时校验)。与 `flags` 的分界是「会不会改变 attempt 里发生的事」:
|
|
@@ -698,6 +713,8 @@ export interface AgentRun {
|
|
|
698
713
|
model?: string;
|
|
699
714
|
reasoningEffort?: string;
|
|
700
715
|
flags: Record<string, JsonValue>;
|
|
716
|
+
/** 只作为出处记录、不进指纹的 flag 键(来自 ExperimentDef.provenanceFlags);见该字段说明。 */
|
|
717
|
+
provenanceFlags?: readonly string[];
|
|
701
718
|
runs: number;
|
|
702
719
|
earlyExit: boolean;
|
|
703
720
|
sandbox?: SandboxOption;
|
|
@@ -841,8 +858,14 @@ export interface ActiveAttempt {
|
|
|
841
858
|
/** 展示 label,等价 `runWho()` 的结果;渲染要用,但绝不作为 identity/key。 */
|
|
842
859
|
who: string;
|
|
843
860
|
phase: LifecyclePhase;
|
|
844
|
-
/**
|
|
845
|
-
|
|
861
|
+
/**
|
|
862
|
+
* 这条 attempt 被派发的墙钟时间(epoch ms,取 `attempt:start` 的 `at`)—— active 行时间列的
|
|
863
|
+
* **唯一**基准,`attempt:phase` 不得改写它:live 面板不做 spinner 动画,存活性完全由这一列
|
|
864
|
+
* 持续增长证明(见 docs/feature/experiments/cli.md「active 行的列序」),一列会归零的时间既
|
|
865
|
+
* 证明不了存活,也让人误以为这条 eval 重跑了。阶段各自的耗时不进这里——它由结果的
|
|
866
|
+
* `timing.phases` 完整落盘,live 面板要回答的是「这条还活着吗、跑了多久、正在干什么」。
|
|
867
|
+
*/
|
|
868
|
+
startedAt: number;
|
|
846
869
|
detail?: string;
|
|
847
870
|
}
|
|
848
871
|
|
package/src/view/data.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "../report/runtime/host.ts";
|
|
23
23
|
import { selectCurrentResults, filterExperiments } from "../results/select.ts";
|
|
24
24
|
import { evalPrefixPredicate } from "../shared/aggregate.ts";
|
|
25
|
-
import type { EvalResult } from "../types.ts";
|
|
25
|
+
import type { EvalResult, JsonValue } from "../types.ts";
|
|
26
26
|
import type { SkippedRunNotice, ViewData, ViewReportMeta, ViewReportPageHtml } from "./shared/types.ts";
|
|
27
27
|
import { t } from "../i18n/index.ts";
|
|
28
28
|
import { RESULTS_SCHEMA_VERSION } from "../types.ts";
|
|
@@ -163,8 +163,39 @@ export function viewRoot(input?: string): string {
|
|
|
163
163
|
* 携带条目要能被 view 找回 artifact,这里同时把 artifactBase(相对结果根)拼好(runner 依赖它)。
|
|
164
164
|
*/
|
|
165
165
|
export async function loadLatestResultsPerEval(root = ".niceeval"): Promise<EvalResult[]> {
|
|
166
|
+
return (await loadCarryInputs(root)).results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 携带规划要的两份输入,一次扫描出齐(`openResults` 会 parse 全根每一个 `result.json`,读两遍不划算):
|
|
171
|
+
*
|
|
172
|
+
* - `results` —— 每 `(experimentId, evalId)` 最新一份的 `EvalResult`,口径见 `loadLatestResultsPerEval`。
|
|
173
|
+
* - `flagBagsByExperiment` —— 该实验**全部历史快照**记下过的 `ExperimentRunInfo.flags`(按内容去重)。
|
|
174
|
+
* [provenance flag](../../docs/feature/experiments/library.md) 的反事实重算拿它当候选假设:
|
|
175
|
+
* 「把 flags 换成这一袋,指纹还相等吗」。候选来自哪个快照不重要——重算相等本身就是证明。
|
|
176
|
+
* 必须扫全历史而不是只看结果所在的那一份:携带条目原样带着**产出它那一轮**的指纹合入新快照,
|
|
177
|
+
* 而新快照记的是**本轮**的 flags,两者在坐标轮换后天然对不上;产出那一轮的 flags 只在更早的
|
|
178
|
+
* 快照里留着。
|
|
179
|
+
*/
|
|
180
|
+
export async function loadCarryInputs(
|
|
181
|
+
root = ".niceeval",
|
|
182
|
+
): Promise<{ results: EvalResult[]; flagBagsByExperiment: Map<string, Record<string, JsonValue>[]> }> {
|
|
166
183
|
const results = await openResults(root);
|
|
167
184
|
const out: EvalResult[] = [];
|
|
185
|
+
const flagBagsByExperiment = new Map<string, Record<string, JsonValue>[]>();
|
|
186
|
+
for (const exp of results.experiments) {
|
|
187
|
+
const bags: Record<string, JsonValue>[] = [];
|
|
188
|
+
const seenBags = new Set<string>();
|
|
189
|
+
for (const snapshot of exp.snapshots) {
|
|
190
|
+
const flags = snapshot.experiment?.flags;
|
|
191
|
+
if (flags === undefined) continue;
|
|
192
|
+
const key = JSON.stringify(Object.entries(flags).sort());
|
|
193
|
+
if (seenBags.has(key)) continue;
|
|
194
|
+
seenBags.add(key);
|
|
195
|
+
bags.push(flags);
|
|
196
|
+
}
|
|
197
|
+
if (bags.length > 0) flagBagsByExperiment.set(exp.id, bags);
|
|
198
|
+
}
|
|
168
199
|
for (const exp of results.experiments) {
|
|
169
200
|
// exp.snapshots 已按新→旧排序;同一快照内先收本轮的 eval id,收完再整体入 claimed,
|
|
170
201
|
// 保证同 (experiment, eval) 的多 attempt 整批取自同一个快照。
|
|
@@ -179,7 +210,7 @@ export async function loadLatestResultsPerEval(root = ".niceeval"): Promise<Eval
|
|
|
179
210
|
for (const id of takenThisSnapshot) claimed.add(id);
|
|
180
211
|
}
|
|
181
212
|
}
|
|
182
|
-
return out;
|
|
213
|
+
return { results: out, flagBagsByExperiment };
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
/**
|