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 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.",
@@ -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 由文件路径推导。";
@@ -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` 与本次规划的 `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, fingerprint: string | undefined, timeoutMs: number): EvalResult[];
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>>;
@@ -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
- /** 进入当前 phase 的墙钟时间(epoch ms),用于渲染阶段耗时;每次 phase 变化都会更新。 */
798
- phaseStartedAt: number;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niceeval",
3
- "version": "0.11.0",
3
+ "version": "0.11.1-canary.8",
4
4
  "description": "Agent-native eval tool — eval agents, services, functions, and coding-agent fixtures",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  import {
44
44
  buildView,
45
45
  startViewServer,
46
- loadLatestResultsPerEval,
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 priorResults = flags.force ? undefined : await loadLatestResultsPerEval(join(cwd, ".niceeval"));
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", phaseStartedAt: 0 }]]),
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", phaseStartedAt: 0, detail: longDetail }],
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", phaseStartedAt: 0 }]]),
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", phaseStartedAt: 0 }]]),
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", phaseStartedAt: 1 }]]),
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", phaseStartedAt: 0 }]]),
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
- const elapsed = formatElapsed(io.clock.now() - active.phaseStartedAt).padStart(6);
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
- phaseStartedAt: event.at,
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
- active.set(key, { ...existing, phase: event.phase, phaseStartedAt: event.at, detail: undefined });
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` 与本次规划的 `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
- fingerprint: string | undefined,
126
+ fingerprints: ReadonlySet<string> | undefined,
100
127
  timeoutMs: number,
101
128
  ): EvalResult[] {
102
- if (!priorResults?.length || fingerprint === undefined) return [];
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 !== fingerprint) continue;
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
- computeFingerprint(evalDef, run, sourceCache, configSandbox).then((fp) => {
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 [key, fingerprint] of plannedFingerprints) {
166
- const carried = carriableAttempts(priorResults, key, fingerprint, plannedTimeoutMs.get(key) ?? Infinity);
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 永远同一指纹。 */
@@ -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 的 `plannedFingerprints` 整场是常量,重查只需要
1057
- * 逐条 attempt 过 `carriableAttempts`(终态 + 指纹相等 + durationMs ≤ resolved timeoutMs),
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
- plannedFingerprints.get(key),
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
  }
@@ -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
- /** 进入当前 phase 的墙钟时间(epoch ms),用于渲染阶段耗时;每次 phase 变化都会更新。 */
845
- phaseStartedAt: number;
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
  /**
package/src/view/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  ViewInputError,
17
17
  incompatibleHint,
18
18
  incompatibleViewCommand,
19
+ loadCarryInputs,
19
20
  loadLatestResultsPerEval,
20
21
  loadViewScan,
21
22
  type IncompatibleRun,