niceeval 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/dist/agents/types.d.ts +72 -6
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -0
  9. package/dist/report/built-ins/experiment-comparison.js +119 -0
  10. package/dist/report/built-ins/index.d.ts +2 -1
  11. package/dist/report/built-ins/index.js +2 -2
  12. package/dist/report/components.d.ts +10 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +68 -66
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -14
  19. package/dist/report/format.js +28 -30
  20. package/dist/report/index.d.ts +5 -4
  21. package/dist/report/index.js +6 -5
  22. package/dist/report/locale.d.ts +23 -3
  23. package/dist/report/locale.js +47 -6
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +66 -15
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +4 -4
  28. package/dist/report/react/AttemptList.js +8 -10
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +4 -2
  34. package/dist/report/react/ExperimentList.js +57 -7
  35. package/dist/report/react/MetricScatter.js +6 -16
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +30 -18
  40. package/dist/report/react/format.d.ts +1 -1
  41. package/dist/report/react/format.js +1 -1
  42. package/dist/report/react/index.d.ts +1 -1
  43. package/dist/report/report.d.ts +5 -1
  44. package/dist/report/report.js +6 -2
  45. package/dist/report/text/faces.d.ts +1 -1
  46. package/dist/report/text/faces.js +100 -61
  47. package/dist/report/text/table.js +36 -5
  48. package/dist/report/types.d.ts +40 -34
  49. package/dist/results/types.d.ts +11 -0
  50. package/dist/runner/feedback/sink.d.ts +110 -0
  51. package/dist/runner/types.d.ts +513 -22
  52. package/dist/sandbox/docker.d.ts +23 -2
  53. package/dist/sandbox/e2b.d.ts +15 -1
  54. package/dist/sandbox/errors.d.ts +30 -3
  55. package/dist/sandbox/io-retry.d.ts +17 -0
  56. package/dist/sandbox/registry.d.ts +2 -0
  57. package/dist/sandbox/resolve.d.ts +18 -5
  58. package/dist/sandbox/retry.d.ts +11 -1
  59. package/dist/sandbox/types.d.ts +39 -5
  60. package/dist/sandbox/vercel.d.ts +7 -1
  61. package/dist/scoring/coverage.d.ts +30 -0
  62. package/dist/scoring/display.d.ts +21 -0
  63. package/dist/scoring/display.js +120 -0
  64. package/dist/scoring/types.d.ts +103 -20
  65. package/dist/shared/aggregate.d.ts +1 -0
  66. package/dist/shared/aggregate.js +3 -3
  67. package/dist/shared/types.d.ts +28 -0
  68. package/dist/tty-line.d.ts +0 -4
  69. package/dist/util.d.ts +23 -0
  70. package/docs-site/zh/concepts/adapter.mdx +24 -6
  71. package/docs-site/zh/concepts/assert.mdx +11 -10
  72. package/docs-site/zh/concepts/evals.mdx +7 -6
  73. package/docs-site/zh/concepts/experiment.mdx +1 -1
  74. package/docs-site/zh/concepts/overview.mdx +7 -7
  75. package/docs-site/zh/guides/agent-feedback-loop.mdx +35 -31
  76. package/docs-site/zh/guides/authoring.mdx +33 -0
  77. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  78. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  79. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  80. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  81. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  82. package/docs-site/zh/guides/debugging.mdx +210 -0
  83. package/docs-site/zh/guides/experiments.mdx +10 -3
  84. package/docs-site/zh/guides/fixtures.mdx +3 -1
  85. package/docs-site/zh/guides/official-adapters.mdx +27 -3
  86. package/docs-site/zh/guides/publish-report.mdx +30 -16
  87. package/docs-site/zh/guides/report-components.mdx +49 -37
  88. package/docs-site/zh/guides/reporters.mdx +2 -2
  89. package/docs-site/zh/guides/results-data.mdx +42 -8
  90. package/docs-site/zh/guides/runner.mdx +17 -7
  91. package/docs-site/zh/guides/sandbox-agent.mdx +57 -7
  92. package/docs-site/zh/guides/sandbox-providers.mdx +258 -10
  93. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  94. package/docs-site/zh/guides/viewing-results.mdx +85 -41
  95. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  96. package/docs-site/zh/guides/write-send.mdx +19 -2
  97. package/docs-site/zh/index.mdx +1 -1
  98. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  99. package/docs-site/zh/reference/capabilities.mdx +2 -2
  100. package/docs-site/zh/reference/cli.mdx +35 -9
  101. package/docs-site/zh/reference/define-agent.mdx +60 -5
  102. package/docs-site/zh/reference/define-config.mdx +1 -1
  103. package/docs-site/zh/reference/define-eval.mdx +42 -9
  104. package/docs-site/zh/reference/events.mdx +2 -2
  105. package/docs-site/zh/reference/expect.mdx +36 -6
  106. package/package.json +5 -1
  107. package/src/agents/ai-sdk-otel.test.ts +1 -0
  108. package/src/agents/ai-sdk.test.ts +3 -0
  109. package/src/agents/ai-sdk.ts +3 -0
  110. package/src/agents/bub-install-spec.test.ts +34 -0
  111. package/src/agents/bub-install-spec.ts +32 -0
  112. package/src/agents/bub.ts +31 -32
  113. package/src/agents/claude-code.test.ts +130 -9
  114. package/src/agents/claude-code.ts +76 -4
  115. package/src/agents/codex.test.ts +189 -40
  116. package/src/agents/codex.ts +155 -14
  117. package/src/agents/coding-cli-versions.test.ts +15 -0
  118. package/src/agents/coding-cli-versions.ts +3 -0
  119. package/src/agents/index.ts +11 -0
  120. package/src/agents/langgraph.test.ts +204 -0
  121. package/src/agents/langgraph.ts +495 -0
  122. package/src/agents/marketplace.ts +85 -0
  123. package/src/agents/native-config.test.ts +179 -0
  124. package/src/agents/native-config.ts +267 -0
  125. package/src/agents/openai-compat.test.ts +1 -0
  126. package/src/agents/openclaw.test.ts +31 -0
  127. package/src/agents/openclaw.ts +171 -0
  128. package/src/agents/plugin-config.test.ts +1 -0
  129. package/src/agents/sdk-streams.test.ts +79 -0
  130. package/src/agents/sdk-streams.ts +55 -10
  131. package/src/agents/skills.test.ts +1 -0
  132. package/src/agents/streaming.test.ts +3 -9
  133. package/src/agents/types.ts +73 -6
  134. package/src/agents/ui-message-stream.test.ts +3 -0
  135. package/src/cli.ts +411 -108
  136. package/src/context/context.test.ts +51 -12
  137. package/src/context/context.ts +161 -29
  138. package/src/context/session.test.ts +1 -0
  139. package/src/context/session.ts +114 -6
  140. package/src/context/types.ts +30 -12
  141. package/src/define.test.ts +13 -8
  142. package/src/define.ts +25 -4
  143. package/src/expect/index.ts +53 -23
  144. package/src/i18n/en.ts +65 -4
  145. package/src/i18n/zh-CN.ts +66 -4
  146. package/src/o11y/cost.test.ts +1 -0
  147. package/src/o11y/execution-tree.test.ts +1 -20
  148. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  149. package/src/o11y/otlp/parse.test.ts +1 -0
  150. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  151. package/src/o11y/parsers/bub.test.ts +1 -0
  152. package/src/o11y/parsers/claude-code.test.ts +1 -34
  153. package/src/o11y/parsers/openclaw.test.ts +154 -0
  154. package/src/o11y/parsers/openclaw.ts +310 -0
  155. package/src/o11y/prices.json +746 -311
  156. package/src/o11y/tool-names.test.ts +1 -0
  157. package/src/o11y/types.ts +16 -2
  158. package/src/report/aggregate.ts +34 -5
  159. package/src/report/built-in-user-parity.test.tsx +127 -173
  160. package/src/report/built-ins/experiment-comparison.tsx +179 -0
  161. package/src/report/built-ins/index.ts +7 -2
  162. package/src/report/components.tsx +11 -3
  163. package/src/report/compute.ts +80 -74
  164. package/src/report/dual-render.test.tsx +222 -91
  165. package/src/report/flag.ts +30 -2
  166. package/src/report/format.ts +36 -27
  167. package/src/report/index.ts +23 -6
  168. package/src/report/locale.ts +49 -6
  169. package/src/report/metrics.ts +68 -15
  170. package/src/report/primitives.tsx +6 -0
  171. package/src/report/react/AttemptList.tsx +9 -36
  172. package/src/report/react/EvalList.tsx +0 -0
  173. package/src/report/react/ExperimentComparison.tsx +68 -0
  174. package/src/report/react/ExperimentList.tsx +173 -55
  175. package/src/report/react/MetricScatter.tsx +13 -25
  176. package/src/report/react/chart-math.test.ts +85 -0
  177. package/src/report/react/chart-math.ts +101 -22
  178. package/src/report/react/enhance.js +72 -1
  179. package/src/report/react/fixtures.ts +34 -21
  180. package/src/report/react/format.ts +1 -1
  181. package/src/report/react/index.tsx +0 -1
  182. package/src/report/react/render.test.tsx +30 -69
  183. package/src/report/react/styles.css +112 -14
  184. package/src/report/report.test.ts +308 -105
  185. package/src/report/report.ts +6 -2
  186. package/src/report/text/faces.ts +111 -67
  187. package/src/report/text/table.ts +42 -5
  188. package/src/report/types.ts +42 -34
  189. package/src/results/annotated-source.test.ts +62 -9
  190. package/src/results/annotated-source.ts +64 -6
  191. package/src/results/attempt-evidence.test.ts +9 -7
  192. package/src/results/attempt-evidence.ts +15 -8
  193. package/src/results/attempt-source.ts +6 -3
  194. package/src/results/copy.ts +145 -55
  195. package/src/results/host-equivalence.test.ts +11 -9
  196. package/src/results/index.ts +2 -0
  197. package/src/results/locator.test.ts +1 -22
  198. package/src/results/open.ts +7 -1
  199. package/src/results/publish.ts +149 -0
  200. package/src/results/results.test.ts +85 -51
  201. package/src/results/truncate.ts +90 -0
  202. package/src/results/types.ts +7 -0
  203. package/src/results/writer.ts +31 -13
  204. package/src/runner/attempt.test.ts +138 -7
  205. package/src/runner/attempt.ts +603 -104
  206. package/src/runner/discover.test.ts +47 -0
  207. package/src/runner/discover.ts +36 -2
  208. package/src/runner/eval-source.test.ts +1 -27
  209. package/src/runner/feedback/agent.test.ts +504 -0
  210. package/src/runner/feedback/agent.ts +409 -0
  211. package/src/runner/feedback/ci.test.ts +562 -0
  212. package/src/runner/feedback/ci.ts +401 -0
  213. package/src/runner/feedback/coordinator.test.ts +317 -0
  214. package/src/runner/feedback/coordinator.ts +397 -0
  215. package/src/runner/feedback/failure.ts +40 -0
  216. package/src/runner/feedback/human.test.ts +616 -0
  217. package/src/runner/feedback/human.ts +535 -0
  218. package/src/runner/feedback/index.ts +66 -0
  219. package/src/runner/feedback/io.ts +78 -0
  220. package/src/runner/feedback/profile.test.ts +50 -0
  221. package/src/runner/feedback/profile.ts +58 -0
  222. package/src/runner/feedback/reducer.test.ts +395 -0
  223. package/src/runner/feedback/reducer.ts +260 -0
  224. package/src/runner/feedback/renderer.ts +82 -0
  225. package/src/runner/feedback/sink.ts +203 -0
  226. package/src/runner/feedback/testing.ts +106 -0
  227. package/src/runner/ledger.test.ts +230 -0
  228. package/src/runner/ledger.ts +329 -0
  229. package/src/runner/report.test.ts +128 -3
  230. package/src/runner/report.ts +33 -9
  231. package/src/runner/reporters/artifacts.ts +8 -2
  232. package/src/runner/reporters/braintrust.test.ts +8 -7
  233. package/src/runner/reporters/braintrust.ts +9 -2
  234. package/src/runner/reporters/index.ts +2 -2
  235. package/src/runner/reporters/json.test.ts +162 -0
  236. package/src/runner/reporters/json.ts +35 -8
  237. package/src/runner/reporters/shared.ts +1 -5
  238. package/src/runner/run.test.ts +760 -3
  239. package/src/runner/run.ts +242 -36
  240. package/src/runner/sandbox-prep.ts +3 -42
  241. package/src/runner/timing.ts +158 -0
  242. package/src/runner/types.ts +518 -22
  243. package/src/sandbox/checkpoint.test.ts +55 -0
  244. package/src/sandbox/checkpoint.ts +29 -8
  245. package/src/sandbox/cli-commands.ts +407 -0
  246. package/src/sandbox/docker.ts +115 -16
  247. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  248. package/src/sandbox/e2b-agent-template.ts +94 -0
  249. package/src/sandbox/e2b.ts +74 -9
  250. package/src/sandbox/errors.ts +111 -4
  251. package/src/sandbox/index.ts +2 -0
  252. package/src/sandbox/io-retry.test.ts +58 -0
  253. package/src/sandbox/io-retry.ts +45 -0
  254. package/src/sandbox/keep-registry.test.ts +86 -0
  255. package/src/sandbox/keep-registry.ts +142 -0
  256. package/src/sandbox/keep.ts +178 -0
  257. package/src/sandbox/paths.test.ts +1 -0
  258. package/src/sandbox/paths.ts +19 -8
  259. package/src/sandbox/registry.ts +20 -3
  260. package/src/sandbox/resolve.ts +76 -11
  261. package/src/sandbox/retry.test.ts +70 -0
  262. package/src/sandbox/retry.ts +46 -4
  263. package/src/sandbox/types.ts +44 -6
  264. package/src/sandbox/vercel.ts +43 -20
  265. package/src/scoring/collector.ts +60 -17
  266. package/src/scoring/coverage.ts +95 -0
  267. package/src/scoring/diff.ts +81 -0
  268. package/src/scoring/display.test.ts +121 -0
  269. package/src/scoring/display.ts +133 -0
  270. package/src/scoring/evidence.test.ts +189 -0
  271. package/src/scoring/judge.test.ts +142 -0
  272. package/src/scoring/judge.ts +15 -18
  273. package/src/scoring/scoped.ts +217 -50
  274. package/src/scoring/types.ts +117 -20
  275. package/src/scoring/verdict.ts +16 -4
  276. package/src/shared/aggregate.ts +3 -2
  277. package/src/shared/types.ts +31 -0
  278. package/src/show/compose.ts +2 -2
  279. package/src/show/index.ts +29 -16
  280. package/src/show/render.ts +626 -308
  281. package/src/show/show.test.ts +251 -36
  282. package/src/tty-line.ts +8 -26
  283. package/src/util.test.ts +1 -0
  284. package/src/util.ts +41 -0
  285. package/src/view/app/components/AttemptModal.tsx +153 -2
  286. package/src/view/app/components/CodeView.tsx +32 -11
  287. package/src/view/app/components/CopyControls.tsx +2 -2
  288. package/src/view/app/i18n.ts +6 -0
  289. package/src/view/app/lib/attempt-route.test.ts +1 -0
  290. package/src/view/app/lib/verdict.ts +7 -9
  291. package/src/view/artifact-serving.test.ts +2 -1
  292. package/src/view/client-dist/app.css +1 -1
  293. package/src/view/client-dist/app.js +17 -17
  294. package/src/view/data.test.ts +2 -1
  295. package/src/view/data.ts +17 -7
  296. package/src/view/index.ts +12 -1
  297. package/src/view/server.ts +2 -0
  298. package/src/view/shared/types.ts +1 -1
  299. package/src/view/styles.css +3 -0
  300. package/src/view/view-report.test.ts +11 -10
  301. package/dist/o11y/execution-tree.d.ts +0 -103
  302. package/dist/o11y/otlp/select.d.ts +0 -22
  303. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +0 -1
  304. package/dist/report/built-ins/cost-pass-rate-comparison.js +0 -17
  305. package/dist/results/annotated-source.d.ts +0 -61
  306. package/dist/results/attempt-evidence.d.ts +0 -69
  307. package/dist/results/attempt-source.d.ts +0 -15
  308. package/src/report/built-ins/cost-pass-rate-comparison.tsx +0 -23
  309. package/src/runner/reporters/console.ts +0 -70
  310. package/src/runner/reporters/live.test.ts +0 -56
  311. package/src/runner/reporters/live.ts +0 -247
  312. package/src/runner/reporters/quiet.test.ts +0 -66
  313. package/src/runner/reporters/quiet.ts +0 -49
  314. package/src/runner/reporters/table.ts +0 -277
@@ -1,3 +1,4 @@
1
+ // cases: docs/engineering/unit-tests/adapters/cases.md
1
2
  // 工具名归一的回归锁:canonical ToolName 是跨 agent 断言(calledTool("file_read"))
2
3
  // 的命中基础。这里逐 agent 锁死关键映射,防止基表 / per-agent 差异表重构时悄悄漂移
3
4
  // (曾发生:Claude Code 的裸名 Read/Write/Edit 在一次表合并中归一成了 "unknown")。
package/src/o11y/types.ts CHANGED
@@ -53,11 +53,22 @@ export interface InputRequest {
53
53
  readonly options?: readonly { id: string; label?: string }[];
54
54
  }
55
55
 
56
+ /**
57
+ * 落盘截断的结构化标记(见 docs/feature/results/architecture.md「大值截断」):
58
+ * `path` 是被截断的位置(事件里是字段名,如 "output";span 里是 attribute key,如 "output.value"),
59
+ * `originalBytes` 是截断前的 UTF-8 字节数。view 显示「输出过大,已截断」靠它,不正则匹配 marker。
60
+ */
61
+ export interface Truncation {
62
+ path: string;
63
+ originalBytes: number;
64
+ }
65
+
56
66
  /**
57
67
  * 标准事件流的词汇(对标 docs/feature/adapters/architecture/events.md)。adapter 唯一的硬活就是把
58
68
  * 各 agent 五花八门的原始 transcript 映射成 StreamEvent[];映射完,整套断言免费。
69
+ * `truncated` 只由 results writer 在落盘时刻写入(运行时全量,落盘截断);adapter 不产出它。
59
70
  */
60
- export type StreamEvent =
71
+ export type StreamEvent = { truncated?: Truncation[] } & (
61
72
  /** 一条文本消息(assistant 回复或 user 输入);`loc` 是可选的源码位置,用于把消息叠回 eval 源码。 */
62
73
  | { type: "message"; role: "assistant" | "user"; text: string; loc?: SourceLoc }
63
74
  /** 发起一次工具/动作调用;`tool` 是归一化后的规范工具名,原始名保留在 DerivedFacts.ToolCall.originalName。 */
@@ -85,7 +96,8 @@ export type StreamEvent =
85
96
  /** 上下文被压缩/摘要(如超长会话截断历史);`reason` 是可选的压缩原因说明。 */
86
97
  | { type: "compaction"; reason?: string }
87
98
  /** 运行中出现的错误。 */
88
- | { type: "error"; message: string };
99
+ | { type: "error"; message: string }
100
+ );
89
101
 
90
102
  /** core 从事件流折叠出的结构化事实(deriveRunFacts)。 */
91
103
  export interface ToolCall {
@@ -145,6 +157,8 @@ export interface TraceSpan {
145
157
  kind?: SpanKind;
146
158
  /** OTLP span 属性(gen_ai.* / tool 名 / token 等),按 key 摊平。raw 属性始终保留供下钻。 */
147
159
  attributes?: Record<string, JsonValue>;
160
+ /** 落盘截断标记(只由 results writer 在序列化时写入,运行时全量;见 Truncation)。 */
161
+ truncated?: Truncation[];
148
162
  }
149
163
 
150
164
  /** 给人 / 给 EVAL.ts 看的 o11y 摘要(注入沙箱 __niceeval__/results.json)。 */
@@ -9,6 +9,8 @@ import type { AttemptHandle, Selection, SelectionWarning, Snapshot } from "../re
9
9
  import { encodeAttemptLocator, type AttemptLocator } from "../results/locator.ts";
10
10
  import type {
11
11
  Aggregator,
12
+ AxisInput,
13
+ ConfigRef,
12
14
  DimensionInput,
13
15
  Metric,
14
16
  MetricCell,
@@ -115,29 +117,56 @@ function isFlagRef(dimension: DimensionInput): dimension is FlagRef {
115
117
  return typeof dimension === "object" && "kind" in dimension && dimension.kind === "flag";
116
118
  }
117
119
 
120
+ function isConfigRef(dimension: DimensionInput): dimension is ConfigRef {
121
+ return typeof dimension === "object" && "kind" in dimension && dimension.kind === "config";
122
+ }
123
+
118
124
  /** experiment 声明的 flags(经 runner 原样透传进持久化字段 ExperimentRunInfo.flags)。 */
119
125
  function flagsOf(attempt: AttemptHandle): Record<string, unknown> | undefined {
120
126
  return attempt.result.experiment?.flags;
121
127
  }
122
128
 
129
+ /**
130
+ * config() 的取值:读快照的 `ExperimentRunInfo` 投影(快照优先;第三方落盘只把投影拼在
131
+ * 条目上时回退 result.experiment),外加桥接到快照顶层权威字段的 `model` / `agent` 两个键
132
+ * (与 "model" / "agent" 内置维度同一读法,不另造第二套口径)。未投影 → undefined。
133
+ */
134
+ function configValueOf(ref: ConfigRef, item: Item): unknown {
135
+ if (ref.name === "model") return item.attempt.result.model ?? item.snapshot.model;
136
+ if (ref.name === "agent") return item.attempt.result.agent || item.snapshot.agent;
137
+ const info = item.snapshot.experiment ?? item.attempt.result.experiment;
138
+ // ExperimentRunInfo 是穷尽可序列化投影;按字段名取值,新增投影字段无需改这里
139
+ return (info as Record<string, unknown> | undefined)?.[ref.name];
140
+ }
141
+
123
142
  /** flag 声明值 → 组标签:label 函数优先,其余 String();未声明 → FLAG_UNSET。 */
124
143
  export function flagGroupKey(ref: FlagRef, item: Item): string {
125
- const value = flagsOf(item.attempt)?.[ref.name];
144
+ return refGroupKey(ref, flagsOf(item.attempt)?.[ref.name]);
145
+ }
146
+
147
+ /** config 投影值 → 组标签,规则与 flagGroupKey 一致;未投影 → FLAG_UNSET。 */
148
+ export function configGroupKey(ref: ConfigRef, item: Item): string {
149
+ return refGroupKey(ref, configValueOf(ref, item));
150
+ }
151
+
152
+ function refGroupKey(ref: FlagRef | ConfigRef, value: unknown): string {
126
153
  if (value === undefined) return FLAG_UNSET;
127
- // 持久化字段是 Record<string, unknown>;声明侧的合法值就是这三种标量
154
+ // 声明侧的合法标量就是这三种;非标量投影(sandbox / selectedEvalIds…)如实 JSON 化,不猜
128
155
  if (typeof ref.label === "function") return ref.label(value as string | number | boolean);
156
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
129
157
  return String(value);
130
158
  }
131
159
 
132
- /** flag 作轴:要求数值;未声明或非数值 → null(点不画,注脚报数)。 */
133
- export function flagAxisValue(ref: FlagRef, item: Item): number | null {
134
- const value = flagsOf(item.attempt)?.[ref.name];
160
+ /** flag / config 作轴:要求数值;未声明、未投影或非数值 → null(点不画,注脚报数)。 */
161
+ export function axisValue(ref: AxisInput, item: Item): number | null {
162
+ const value = ref.kind === "flag" ? flagsOf(item.attempt)?.[ref.name] : configValueOf(ref, item);
135
163
  return typeof value === "number" ? value : null;
136
164
  }
137
165
 
138
166
  export function dimensionKey(dimension: DimensionInput, item: Item): string {
139
167
  if (typeof dimension !== "string") {
140
168
  if (isFlagRef(dimension)) return flagGroupKey(dimension, item);
169
+ if (isConfigRef(dimension)) return configGroupKey(dimension, item);
141
170
  return dimension.of(item.attempt);
142
171
  }
143
172
  const result = item.attempt.result;
@@ -1,29 +1,30 @@
1
- // Phase 4 验收:证明内置报告 CostPassRateComparison 与一份普通用户 --report 文件走的是
1
+ // cases: docs/engineering/unit-tests/reports/cases.md
2
+ // Phase 4 验收:证明内置报告 ExperimentComparison 与一份普通用户 --report 文件走的是
2
3
  // 完全同一套机制 —— 不是长得像,而是同一条 build → resolveReportTree → validate → render 管线。
3
4
  //
4
5
  // 证据链(plan/built-in-reports-user-parity.md「Phase 4」):
5
- // 1. test/fixtures/report/cost-pass-rate-comparison-public-copy.tsx 只从公开 barrel import,
6
+ // 1. test/fixtures/report/experiment-comparison-public-copy.tsx 只从公开 barrel import,
6
7
  // 与 built-in 逐节点同构(见文件头)。
7
8
  // 2. 同一 ReportContext 下分别 build + resolveReportTree,深比较 resolved 树:组件类型 / 顺序 /
8
9
  // resolved data / props keys 全部结构化相等(不靠 definition 引用相等,两份是独立文件)。
9
10
  // 3. 两份分别过 renderReportToText / renderReportToStaticHtml,比较事实(散点键 / 表格行 /
10
11
  // attempt 深链),不做 HTML 字面 snapshot。
11
12
  // 4. 覆盖 plan 的 9 个必测场景。
12
- // 5. 架构不变量:renderer 源码零 "CostPassRateComparison" / "built-ins" 分支。
13
- // 6. show / view 只在「无 --report」分支选内置报告,--report 只换 definition,不碰
14
- // Selection / resolve / validate / render。
13
+ // 5. 架构不变量:renderer 模块的 import 依赖图不含 built-ins
14
+ // (show / view 的宿主选择与装载边界等价由 src/show/show.test.ts、src/view/view-report.test.ts 覆盖。)
15
15
 
16
16
  import { readFileSync } from "node:fs";
17
17
  import { renderToStaticMarkup } from "react-dom/server";
18
- import { describe, expect, it, vi } from "vitest";
18
+ import { describe, expect, it } from "vitest";
19
19
 
20
20
  import type { EvalResult, Verdict } from "../types.ts";
21
21
  import type { AttemptHandle, Results, Selection, Snapshot } from "../results/index.ts";
22
22
  import type { ExperimentListItem, ScatterData } from "./types.ts";
23
+ import type { ExperimentComparisonData } from "./built-ins/index.ts";
23
24
  import { encodeAttemptLocator } from "../results/locator.ts";
24
25
  import { Col, ExperimentList, defineReport } from "./index.ts";
25
- import { CostPassRateComparison } from "./built-ins/index.ts";
26
- import publicCopy from "../../test/fixtures/report/cost-pass-rate-comparison-public-copy.tsx";
26
+ import { ExperimentComparison } from "./built-ins/index.ts";
27
+ import publicCopy from "../../test/fixtures/report/experiment-comparison-public-copy.tsx";
27
28
  import { renderReportToText } from "./report.ts";
28
29
  import { renderReportToStaticHtml } from "./web.ts";
29
30
  import {
@@ -34,7 +35,6 @@ import {
34
35
  validateReportTree,
35
36
  type ReportNode,
36
37
  } from "./tree.ts";
37
- import * as compute from "./compute.ts";
38
38
 
39
39
  // ───────────────────────── fake Selection / Snapshot(按 results 读取契约造)─────────────────────────
40
40
 
@@ -139,7 +139,10 @@ function richContext() {
139
139
  agent: "codex",
140
140
  model: "gpt-codex",
141
141
  results: [
142
- res("algebra/x", "errored", { error: "adapter crashed", estimatedCostUSD: 0.3 }),
142
+ res("algebra/x", "errored", {
143
+ error: { code: "unexpected-error", message: "adapter crashed", phase: "eval.run" },
144
+ estimatedCostUSD: 0.3,
145
+ }),
143
146
  res("algebra/y", "skipped"),
144
147
  res("algebra/z", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.15 } }),
145
148
  ],
@@ -251,6 +254,12 @@ function experimentListOf(tree: ReportNode): ExperimentListItem[] | undefined {
251
254
  return collect(tree).find((c) => c.label === "ExperimentList")?.props.items as ExperimentListItem[] | undefined;
252
255
  }
253
256
 
257
+ function comparisonDataOf(tree: ReportNode): ExperimentComparisonData | undefined {
258
+ return collect(tree).find((c) => c.label === "ExperimentComparison")?.props.data as
259
+ | ExperimentComparisonData
260
+ | undefined;
261
+ }
262
+
254
263
  /** HTML 里所有 attempt 深链,排序后便于集合比较。 */
255
264
  function hrefsOf(html: string): string[] {
256
265
  return [...html.matchAll(/href="(#\/attempt\/[^"]+)"/g)].map((m) => m[1]).sort();
@@ -278,28 +287,27 @@ function expectedLocatorFor(attempt: AttemptHandle): string {
278
287
 
279
288
  describe("resolved 树结构化等价(built-in ≡ 包外 public-copy)", () => {
280
289
  it("两份是独立 ReportDefinition,不是同一引用", () => {
281
- expect(CostPassRateComparison).not.toBe(publicCopy);
282
- expect(typeof CostPassRateComparison.build).toBe("function");
290
+ expect(ExperimentComparison).not.toBe(publicCopy);
291
+ expect(typeof ExperimentComparison.build).toBe("function");
283
292
  expect(typeof publicCopy.build).toBe("function");
284
293
  });
285
294
 
286
295
  for (const { name, ctx } of SCENARIOS) {
287
296
  it(`同一 ReportContext 下 resolved 树逐节点相等 — ${name}`, async () => {
288
297
  const c = ctx();
289
- const builtIn = await resolvedTreeOf(CostPassRateComparison, c);
298
+ const builtIn = await resolvedTreeOf(ExperimentComparison, c);
290
299
  const user = await resolvedTreeOf(publicCopy, ctx()); // 各用一份等价 ctx,证明不依赖共享状态
291
300
 
292
- // 组件类型与顺序:<Col> [MetricScatter, ExperimentList],别无它物。
301
+ // build 面只把预计算分组数据交给公开组合件,没有 renderer 私有节点。
293
302
  const labels = collect(builtIn).map((n) => n.label);
294
- expect(labels).toEqual(["Col", "MetricScatter", "ExperimentList"]);
303
+ expect(labels).toEqual(["ExperimentComparison"]);
295
304
  expect(collect(user).map((n) => n.label)).toEqual(labels);
296
305
 
297
306
  // 逐节点结构化相等:组件类型、顺序、resolved data、props keys(pointHref/filter/…)全一致。
298
307
  expect(factify(user)).toEqual(factify(builtIn));
299
308
 
300
309
  // resolved data 本身也逐字段相等(不只是树同构)。
301
- expect(scatterOf(user)).toEqual(scatterOf(builtIn));
302
- expect(experimentListOf(user)).toEqual(experimentListOf(builtIn));
310
+ expect(comparisonDataOf(user)).toEqual(comparisonDataOf(builtIn));
303
311
  });
304
312
  }
305
313
  });
@@ -308,16 +316,20 @@ describe("resolved 树结构化等价(built-in ≡ 包外 public-copy)", () => {
308
316
 
309
317
  describe("必测场景:resolved data 事实", () => {
310
318
  it("场景 1+2:多 experiment/agent,成本与通过率都有值;缺成本的点如实 x=null,实验列表仍出项", async () => {
311
- const tree = await resolvedTreeOf(CostPassRateComparison, richContext());
312
- const scatter = scatterOf(tree)!;
313
- const experiments = experimentListOf(tree)!;
314
-
315
- // 散点:points=experiment、series=agent、x=cost、y=pass-rate
319
+ const tree = await resolvedTreeOf(ExperimentComparison, richContext());
320
+ const data = comparisonDataOf(tree)!;
321
+ expect(data.groups.map((group) => group.key)).toEqual(["compare", "solo"]);
322
+ const compare = data.groups[0]!;
323
+ const soloGroup = data.groups[1]!;
324
+ const scatter = compare.scatter;
325
+ const experiments = [...compare.experiments, ...soloGroup.experiments];
326
+
327
+ // 散点:points=experiment、series=agent、x=cost、y=end-to-end-pass-rate
316
328
  expect(scatter.points).toBe("experiment");
317
329
  expect(scatter.series).toBe("agent");
318
330
  expect(scatter.x.key).toBe("cost");
319
- expect(scatter.y.key).toBe("pass-rate");
320
- expect(scatter.rows.map((r) => r.key).sort()).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
331
+ expect(scatter.y.key).toBe("end-to-end-pass-rate");
332
+ expect(scatter.rows.map((r) => r.key).sort()).toEqual(["compare/bub-low", "compare/codex-mid"]);
321
333
 
322
334
  const bub = scatter.rows.find((r) => r.key === "compare/bub-low")!;
323
335
  expect(bub.series).toBe("bub");
@@ -327,10 +339,11 @@ describe("必测场景:resolved data 事实", () => {
327
339
  const codex = scatter.rows.find((r) => r.key === "compare/codex-mid")!;
328
340
  expect(codex.series).toBe("codex");
329
341
  expect(codex.x.value).toBeCloseTo(0.225, 10); // errored 0.3 与 passed 0.15,skipped 不计
342
+ // endToEndPassRate:errored → 0,skipped → null,algebra/z passed → 1,跨题均值 0.5
330
343
  expect(codex.y.value).toBeCloseTo(0.5, 10);
331
344
 
332
345
  // 场景 2:solo/no-cost 缺成本 → x 为 null(点在,不可画),y 仍有值
333
- const solo = scatter.rows.find((r) => r.key === "solo/no-cost")!;
346
+ const solo = soloGroup.scatter.rows.find((r) => r.key === "solo/no-cost")!;
334
347
  expect(solo.x.value).toBeNull();
335
348
  expect(solo.y.value).toBeCloseTo(0.5, 10);
336
349
  // ≥2 可画点
@@ -341,52 +354,32 @@ describe("必测场景:resolved data 事实", () => {
341
354
  expect(experiments.find((e) => e.experimentId === "solo/no-cost")!.cost.value).toBeNull();
342
355
  });
343
356
 
344
- it("场景 3:恰好一个可画点(另一实验缺成本)", async () => {
345
- const tree = await resolvedTreeOf(CostPassRateComparison, oneDrawableContext());
346
- const scatter = scatterOf(tree)!;
347
- const drawable = scatter.rows.filter((r) => r.x.value !== null && r.y.value !== null);
348
- expect(drawable).toHaveLength(1);
349
- expect(drawable[0].key).toBe("only/priced");
350
- });
351
-
352
- it("场景 4:空 Selection —— 散点无 rows、实验列表为空数组", async () => {
353
- const tree = await resolvedTreeOf(CostPassRateComparison, emptyContext());
354
- expect(scatterOf(tree)!.rows).toEqual([]);
355
- expect(experimentListOf(tree)).toEqual([]);
357
+ it("场景 4:空 Selection —— 没有可比组", async () => {
358
+ const tree = await resolvedTreeOf(ExperimentComparison, emptyContext());
359
+ expect(comparisonDataOf(tree)!.groups).toEqual([]);
356
360
  });
357
361
 
358
362
  it("场景 4 变体:全 skipped —— 每个点 x/y 都不可测,0 可画点", async () => {
359
- const tree = await resolvedTreeOf(CostPassRateComparison, allSkippedContext());
360
- const scatter = scatterOf(tree)!;
363
+ const tree = await resolvedTreeOf(ExperimentComparison, allSkippedContext());
364
+ const scatter = comparisonDataOf(tree)!.groups[0]!.scatter;
361
365
  expect(scatter.rows.every((r) => r.x.value === null && r.y.value === null)).toBe(true);
362
366
  expect(scatter.rows.filter((r) => r.x.value !== null && r.y.value !== null)).toHaveLength(0);
363
367
  });
364
368
 
365
369
  it("场景 5:failed/errored/skipped 混合 —— ExperimentList 展开区结果摘要正确(eval 级折叠计票)", async () => {
366
- const tree = await resolvedTreeOf(CostPassRateComparison, richContext());
367
- const codex = experimentListOf(tree)!.find((e) => e.experimentId === "compare/codex-mid")!;
370
+ const tree = await resolvedTreeOf(ExperimentComparison, richContext());
371
+ const codex = comparisonDataOf(tree)!.groups
372
+ .find((group) => group.key === "compare")!
373
+ .experiments.find((e) => e.experimentId === "compare/codex-mid")!;
368
374
  // algebra/x errored、algebra/y skipped、algebra/z passed → eval 级折叠 {passed:1, errored:1, skipped:1}
369
375
  expect(codex.verdicts).toEqual({ passed: 1, failed: 0, errored: 1, skipped: 1 });
370
376
  const errored = codex.evalRows.find((e) => e.evalId === "algebra/x")!;
371
377
  expect(errored.verdict).toBe("errored");
372
- expect(errored.reason).toBe("adapter crashed");
378
+ expect(errored).not.toHaveProperty("reason");
379
+ expect(errored.attempts[0]!.error?.message).toBe("adapter crashed");
373
380
  expect(codex.evalRows.find((e) => e.evalId === "algebra/y")!.verdict).toBe("skipped");
374
381
  });
375
382
 
376
- it("场景 6:同 experiment 多 eval、多 attempts —— 展开层与 attempt locator 正确", async () => {
377
- const tree = await resolvedTreeOf(CostPassRateComparison, richContext());
378
- const bub = experimentListOf(tree)!.find((e) => e.experimentId === "compare/bub-low")!;
379
- expect(bub.evalRows.map((e) => e.evalId).sort()).toEqual(["algebra/x", "algebra/y"]);
380
- const multi = bub.evalRows.find((e) => e.evalId === "algebra/x")!;
381
- // 多轮 attempt 折成 passed(任一轮通过),两次 attempt 都在展开区的 attempts 里
382
- expect(multi.verdict).toBe("passed");
383
- expect(multi.attempts.map((a) => a.attempt)).toEqual([0, 1]);
384
- expect(multi.attempts.map((a) => a.verdict)).toEqual(["failed", "passed"]);
385
- // attempt locator 唯一、格式正确 —— 回到证据的引用不丢
386
- const locators = multi.attempts.map((a) => a.locator);
387
- expect(new Set(locators).size).toBe(2);
388
- for (const locator of locators) expect(locator).toMatch(/^@1[0-9a-z]{7}$/);
389
- });
390
383
  });
391
384
 
392
385
  // ═════════════════════════ 3. text / web 渲染的事实相同 ═════════════════════════
@@ -394,12 +387,12 @@ describe("必测场景:resolved data 事实", () => {
394
387
  describe("built-in 与 public-copy 渲染出的事实相同", () => {
395
388
  for (const { name, ctx } of SCENARIOS) {
396
389
  it(`text / web 事实一致 — ${name}`, async () => {
397
- const builtinText = await renderReportToText(CostPassRateComparison, ctx(), { width: 100 });
390
+ const builtinText = await renderReportToText(ExperimentComparison, ctx(), { width: 100 });
398
391
  const userText = await renderReportToText(publicCopy, ctx(), { width: 100 });
399
392
  // 同一机制、同一 options:text 输出逐字相等(最强的事实级证据)。
400
393
  expect(userText).toBe(builtinText);
401
394
 
402
- const builtinHtml = await renderReportToStaticHtml(CostPassRateComparison, ctx());
395
+ const builtinHtml = await renderReportToStaticHtml(ExperimentComparison, ctx());
403
396
  const userHtml = await renderReportToStaticHtml(publicCopy, ctx());
404
397
  // 事实比较(不做整段 HTML snapshot):散点点键、attempt 深链集合相同。
405
398
  expect(scatterKeysOf(userHtml)).toEqual(scatterKeysOf(builtinHtml));
@@ -411,7 +404,7 @@ describe("built-in 与 public-copy 渲染出的事实相同", () => {
411
404
  const ctx = richContext();
412
405
  // renderReportToStaticHtml 默认 attemptHref = view 的 `#/attempt/@<locator>`(不透明单段路由,
413
406
  // 与 view/data.ts renderReportSlot 不传 attemptHref 时同一条默认路径)。
414
- const builtinHtml = await renderReportToStaticHtml(CostPassRateComparison, ctx);
407
+ const builtinHtml = await renderReportToStaticHtml(ExperimentComparison, ctx);
415
408
  const userHtml = await renderReportToStaticHtml(publicCopy, richContext());
416
409
  const builtinHrefs = hrefsOf(builtinHtml);
417
410
  const userHrefs = hrefsOf(userHtml);
@@ -435,36 +428,15 @@ describe("built-in 与 public-copy 渲染出的事实相同", () => {
435
428
 
436
429
  // ═════════════════════════ 4. 散点空 / 单点 / 多点行为(text + web 同一事实)═════════════════════════
437
430
 
438
- describe("散点空态 / 单点 / 多点行为", () => {
439
- it("0 可画点(空 Selection):text No data、web nre-scatter-empty nre-missing", async () => {
440
- const text = await renderReportToText(CostPassRateComparison, emptyContext(), { width: 100 });
441
- expect(text).toContain("No data to plot");
442
- expect(text).not.toContain("better upper right");
443
- const html = await renderReportToStaticHtml(CostPassRateComparison, emptyContext());
444
- expect(html).toContain("nre-scatter-empty");
445
- expect(html).toContain("nre-missing");
446
- });
447
-
448
- it("恰好 1 可画点:text/web 都说「至少 2 个实验」而不是画孤点", async () => {
449
- const text = await renderReportToText(CostPassRateComparison, oneDrawableContext(), { width: 100 });
450
- expect(text).toContain("At least 2 experiments needed to compare");
451
- expect(text).not.toContain("better → upper right");
452
- const html = await renderReportToStaticHtml(CostPassRateComparison, oneDrawableContext());
453
- expect(html).toContain("nre-scatter-empty");
454
- // 单点不是「缺数据」空态:散点空态本身不带 nre-missing 变体类(与 0 可画点的
455
- // "nre-scatter-empty nre-missing" 区分开)。ExperimentList 展开区自己的缺成本格子
456
- // 也合法渲染 nre-missing,不属于这条散点专属的断言范围。
457
- expect(html).not.toContain("nre-scatter-empty nre-missing");
458
- expect(html).not.toContain("<svg");
431
+ describe("分组空态 / 单点 / 多点行为", () => {
432
+ it("空 Selection:text / web 都显示没有实验组,不伪造空 scatter", async () => {
433
+ const text = await renderReportToText(ExperimentComparison, emptyContext(), { width: 100 });
434
+ expect(text).toContain("No experiment groups");
435
+ const html = await renderReportToStaticHtml(ExperimentComparison, emptyContext());
436
+ expect(html).toContain("nre-experiment-groups-empty");
437
+ expect(html).not.toContain("nre-metric-scatter");
459
438
  });
460
439
 
461
- it("≥2 可画点:正常绘图(text 有 better → upper right,web 有 svg)", async () => {
462
- const text = await renderReportToText(CostPassRateComparison, richContext(), { width: 100 });
463
- expect(text).toContain("better → upper right");
464
- const html = await renderReportToStaticHtml(CostPassRateComparison, richContext());
465
- expect(html).toContain("<svg");
466
- expect(html).not.toContain("nre-scatter-empty");
467
- });
468
440
  });
469
441
 
470
442
  // ═════════════════════════ 5. 场景 7:数据只 resolve 一次,locale 只改 chrome ═════════════════════════
@@ -472,13 +444,14 @@ describe("散点空态 / 单点 / 多点行为", () => {
472
444
  describe("场景 7:en / zh-CN 数据 resolve 一次、chrome 分别本地化", () => {
473
445
  it("resolve 一次的同一棵树,en / zh-CN 各渲染一遍:data 事实相同,只有 chrome 文案不同", async () => {
474
446
  // 关键:build + resolveReportTree 只做一次,拿到唯一的 resolved 树;两个 locale 复用它。
475
- const node = (await CostPassRateComparison.build(richContext())) as ReportNode;
447
+ const node = (await ExperimentComparison.build(richContext())) as ReportNode;
476
448
  const resolved = await resolveReportTree(node);
477
449
  validateReportTree(resolved);
478
450
 
479
451
  // 同一棵 resolved 树 → data 只算了一次(下方 spy 版另证调用次数)。
480
- const scatter = scatterOf(resolved)!;
481
- const experiments = experimentListOf(resolved)!;
452
+ const comparison = comparisonDataOf(resolved)!;
453
+ const scatterRows = comparison.groups.flatMap((group) => group.scatter.rows);
454
+ const experiments = comparison.groups.flatMap((group) => group.experiments);
482
455
 
483
456
  const enText = renderNodeToText(resolved, createTextContext({ width: 100, locale: "en" }));
484
457
  const zhText = renderNodeToText(resolved, createTextContext({ width: 100, locale: "zh-CN" }));
@@ -492,55 +465,61 @@ describe("场景 7:en / zh-CN 数据 resolve 一次、chrome 分别本地化", (
492
465
  );
493
466
 
494
467
  // chrome 分语言
495
- expect(enText).toContain("Pass rate");
496
- expect(zhText).toContain("成功率");
497
- expect(enHtml).toContain("Pass rate");
498
- expect(zhHtml).toContain("成功率");
468
+ expect(enText).toContain("End-to-end pass rate");
469
+ expect(zhText).toContain("端到端成功率");
470
+ expect(enHtml).toContain("End-to-end pass rate");
471
+ expect(zhHtml).toContain("端到端成功率");
499
472
  expect(enText).not.toEqual(zhText);
500
473
 
501
474
  // 但两语言下的事实(散点键 / x / y、attempt 深链)完全相同 —— 数据没被重算成不同结果。
502
475
  expect(scatterKeysOf(zhHtml)).toEqual(scatterKeysOf(enHtml));
503
476
  expect(hrefsOf(zhHtml)).toEqual(hrefsOf(enHtml));
504
477
  // 从同一棵 resolved 树取的 data,en/zh 渲染不改变它
505
- expect(scatter.rows.map((r) => r.key)).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
506
- const [bub, codex, solo] = scatter.rows;
478
+ expect(scatterRows.map((r) => r.key)).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
479
+ const [bub, codex, solo] = scatterRows;
507
480
  expect(bub.x.value).toBeCloseTo(0.1, 10);
508
481
  expect(bub.y.value).toBeCloseTo(0.75, 10);
509
482
  expect(codex.x.value).toBeCloseTo(0.225, 10);
510
- expect(codex.y.value).toBeCloseTo(0.5, 10);
483
+ expect(codex.y.value).toBeCloseTo(0.5, 10); // endToEndPassRate:errored=0,skipped=null,passed=1
511
484
  expect(solo.x.value).toBeNull();
512
485
  expect(solo.y.value).toBeCloseTo(0.5, 10);
486
+ // ExperimentList 按 endToEndPassRate 从高到低:bub-low(0.75)> codex-mid(0.5)=solo(0.5),同分按 id。
513
487
  expect(experiments.map((e) => e.experimentId)).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
514
488
  });
515
489
 
516
- it("spy 佐证:build() 里各计算函数恰好调用一次,随后两 locale 渲染不再重算", async () => {
517
- const scatterSpy = vi.spyOn(compute, "scatterData");
518
- // 注意:spy 挂在 ExperimentList.data 本身,不是 compute.experimentListData —— components.tsx
519
- // `Object.assign(component, { data: experimentListData })` 把函数值复制成一个普通属性,
520
- // 那份拷贝在 components.tsx 求值时(模块加载期)就定死了,晚于此的 vi.spyOn(compute, …) патch
521
- // 不到已经复制走的引用。直接调用方(built-ins/cost-pass-rate-comparison.tsx)读的是
522
- // `ExperimentList.data` 这个属性,所以要在这个属性上打桩。MetricScatter 不受影响:它的
523
- // resolve() 住在 components.tsx 里,调用的是 scatterData 这个 import 绑定本身(ESM 实时绑定),
524
- // vi.spyOn(compute, "scatterData") 打得中。
525
- const experimentListSpy = vi.spyOn(ExperimentList, "data");
526
- try {
527
- // ExperimentList 没有 resolve 面,.data() 直接在 build() 里 await——计算发生在 build,
528
- // 不是 resolveReportTree;MetricScatter 仍是 selection-form,resolve 阶段才调它的 .data()。
529
- const node = (await CostPassRateComparison.build(richContext())) as ReportNode;
530
- expect(experimentListSpy).toHaveBeenCalledTimes(1);
531
- const resolved = await resolveReportTree(node);
532
- expect(scatterSpy).toHaveBeenCalledTimes(1);
533
- expect(experimentListSpy).toHaveBeenCalledTimes(1);
534
-
535
- // 渲染面纯同步、零 IO:两 locale 渲染同一棵 resolved 树,不再触发任何计算。
536
- renderNodeToText(resolved, createTextContext({ locale: "en" }));
537
- renderNodeToText(resolved, createTextContext({ locale: "zh-CN" }));
538
- expect(scatterSpy).toHaveBeenCalledTimes(1);
539
- expect(experimentListSpy).toHaveBeenCalledTimes(1);
540
- } finally {
541
- scatterSpy.mockRestore();
542
- experimentListSpy.mockRestore();
490
+ it("公共边界佐证:build + resolve 后,两 locale 渲染不再读取 Selection(不重算)", async () => {
491
+ // 观察面是公共的 Selection 契约:所有计算都必须经由 selection.snapshots 取数,
492
+ // filter 产物共享同一计数器,渲染阶段任何形式的重算都会让读数增长。
493
+ const counter = { reads: 0 };
494
+ function countingSelection(snapshots: Snapshot[]): Selection {
495
+ const self = {
496
+ warnings: [] as Selection["warnings"],
497
+ get snapshots() {
498
+ counter.reads += 1;
499
+ return snapshots;
500
+ },
501
+ filter: (predicate: (s: Snapshot) => boolean) => countingSelection(snapshots.filter(predicate)),
502
+ };
503
+ return self as unknown as Selection;
543
504
  }
505
+ const counting = countingSelection(richContext().selection.snapshots);
506
+
507
+ const node = (await ExperimentComparison.build({ selection: counting, results: {} as Results })) as ReportNode;
508
+ const resolved = await resolveReportTree(node);
509
+ // 计算阶段确实经由这个公共边界取过数 —— 计数器没有空转。
510
+ expect(counter.reads).toBeGreaterThan(0);
511
+ const readsAfterResolve = counter.reads;
512
+
513
+ // 渲染面纯同步、零 IO:两 locale、两宿主渲染同一棵 resolved 树,不再触发任何计算。
514
+ renderNodeToText(resolved, createTextContext({ locale: "en" }));
515
+ renderNodeToText(resolved, createTextContext({ locale: "zh-CN" }));
516
+ runWithWebContext({ attemptHref: (locator) => `#/attempt/${locator}`, locale: "en" }, () =>
517
+ renderToStaticMarkup(resolved as never),
518
+ );
519
+ runWithWebContext({ attemptHref: (locator) => `#/attempt/${locator}`, locale: "zh-CN" }, () =>
520
+ renderToStaticMarkup(resolved as never),
521
+ );
522
+ expect(counter.reads).toBe(readsAfterResolve);
544
523
  });
545
524
  });
546
525
 
@@ -563,19 +542,6 @@ describe("场景 9:最小用户报告只放 <ExperimentList> —— 不计算散
563
542
  expect(experimentListOf(tree)).toBeDefined();
564
543
  });
565
544
 
566
- it("spy 佐证:scatterData 从未被调用,experimentListData 调一次", async () => {
567
- const scatterSpy = vi.spyOn(compute, "scatterData");
568
- // 见上一个 describe 块的注释:直接调用方读的是 ExperimentList.data 属性,打桩要打在这上面。
569
- const experimentListSpy = vi.spyOn(ExperimentList, "data");
570
- try {
571
- await resolvedTreeOf(minimal, richContext());
572
- expect(scatterSpy).not.toHaveBeenCalled();
573
- expect(experimentListSpy).toHaveBeenCalledTimes(1);
574
- } finally {
575
- scatterSpy.mockRestore();
576
- experimentListSpy.mockRestore();
577
- }
578
- });
579
545
 
580
546
  it("用户换指标/维度无需框架新增分支:改 points/series 只改 resolved data,不改机制", async () => {
581
547
  // 一份「用户自定义」报告:同一 MetricScatter 组件,换成 model 维度 + 只画一个指标轴组合。
@@ -598,46 +564,34 @@ describe("场景 9:最小用户报告只放 <ExperimentList> —— 不计算散
598
564
  });
599
565
  });
600
566
 
601
- // ═════════════════════════ 7. 架构不变量:renderer built-in 分支 ═════════════════════════
567
+ // ═════════════════════════ 7. 架构不变量:renderer 不依赖 built-in(import 依赖图)═════════════════════════
602
568
 
603
569
  describe("架构不变量:renderer 不认识 built-in", () => {
604
570
  const RENDERER_FILES = ["./report.ts", "./web.ts", "./tree.ts"];
571
+
572
+ /** 只解析 import / export…from / 动态 import() 语句的模块 specifier,不 grep 实现文本。 */
573
+ function importSpecifiersOf(rel: string): string[] {
574
+ const src = readFileSync(new URL(rel, import.meta.url), "utf8");
575
+ const specifiers: string[] = [];
576
+ const patterns = [
577
+ /(?:^|\n)\s*import\s+[^"'()]*?from\s*["']([^"']+)["']/g, // import x from "…"
578
+ /(?:^|\n)\s*import\s*["']([^"']+)["']/g, // import "…"(副作用导入)
579
+ /(?:^|\n)\s*export\s+[^"'()]*?from\s*["']([^"']+)["']/g, // export … from "…"
580
+ /import\(\s*["']([^"']+)["']\s*\)/g, // 动态 import("…")
581
+ ];
582
+ for (const re of patterns) {
583
+ for (const m of src.matchAll(re)) specifiers.push(m[1]);
584
+ }
585
+ return specifiers;
586
+ }
587
+
605
588
  for (const rel of RENDERER_FILES) {
606
- it(`${rel} 源码里没有 "CostPassRateComparison" / "built-ins" 分支`, () => {
607
- const src = readFileSync(new URL(rel, import.meta.url), "utf8");
608
- expect(src).not.toContain("CostPassRateComparison");
609
- expect(src).not.toContain("built-ins");
589
+ it(`${rel} import 依赖图不含 built-ins 模块`, () => {
590
+ const specifiers = importSpecifiersOf(rel);
591
+ expect(specifiers.length).toBeGreaterThan(0); // 解析确实取到了依赖,守护没有空转
592
+ for (const spec of specifiers) {
593
+ expect(spec).not.toMatch(/built-ins/);
594
+ }
610
595
  });
611
596
  }
612
597
  });
613
-
614
- // ═════════════════════════ 8. 宿主选择:show 索引与可替换报告槽分开 ═════════════════════════
615
-
616
- describe("宿主选择:裸 show 是诊断索引,--report 与 view 走报告 definition", () => {
617
- it("show / view 源码:裸 show 选择专用索引,--report 才装载 definition", () => {
618
- const showSrc = readFileSync(new URL("../show/index.ts", import.meta.url), "utf8");
619
- expect(showSrc).toMatch(/flags\.report === undefined[\s\S]*showIndexText\(selection, io\.width\)/);
620
- expect(showSrc).toMatch(/const definition = await loadReportFile\(cwd, flags\.report\)/);
621
- expect(showSrc).not.toMatch(/import \{ CostPassRateComparison \}/);
622
- // Selection 在报告槽之前、与 report 无关地由 selectCurrentResults 合成。
623
- expect(showSrc).toMatch(/selectCurrentResults\(results, \{ experiment: flags\.experiment, patterns \}\)/);
624
-
625
- const viewSrc = readFileSync(new URL("../view/data.ts", import.meta.url), "utf8");
626
- // view 的报告槽:report 有值走 loadReportFile,否则 CostPassRateComparison;Selection 同样先于报告槽合成。
627
- expect(viewSrc).toMatch(/report\s*\?[\s\S]*loadReportFile[\s\S]*:\s*\(await import\([^)]*built-ins[^)]*\)\)\.CostPassRateComparison/);
628
- expect(viewSrc).toMatch(/selectCurrentResults\(results, \{ experiment: opts\.experiment, patterns \}\)/);
629
- });
630
-
631
- it("CostPassRateComparison 与 public-copy 作为显式报告时事实完全相同", async () => {
632
- for (const { ctx } of SCENARIOS) {
633
- const bareText = await renderReportToText(CostPassRateComparison, ctx(), { width: 100 });
634
- const reportText = await renderReportToText(publicCopy, ctx(), { width: 100 });
635
- expect(reportText).toBe(bareText);
636
-
637
- const bareHtml = await renderReportToStaticHtml(CostPassRateComparison, ctx());
638
- const reportHtml = await renderReportToStaticHtml(publicCopy, ctx());
639
- expect(scatterKeysOf(reportHtml)).toEqual(scatterKeysOf(bareHtml));
640
- expect(hrefsOf(reportHtml)).toEqual(hrefsOf(bareHtml));
641
- }
642
- });
643
- });