niceeval 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/docs-site/zh/concepts/adapter.mdx +6 -6
  2. package/docs-site/zh/concepts/experiment.mdx +7 -7
  3. package/docs-site/zh/concepts/overview.mdx +1 -1
  4. package/docs-site/zh/concepts/tier.mdx +6 -6
  5. package/docs-site/zh/guides/agent-feedback-loop.mdx +63 -20
  6. package/docs-site/zh/guides/connect-your-agent.mdx +7 -7
  7. package/docs-site/zh/guides/custom-reports.mdx +243 -0
  8. package/docs-site/zh/guides/experiments.mdx +3 -3
  9. package/docs-site/zh/guides/report-components.mdx +252 -0
  10. package/docs-site/zh/guides/reporters.mdx +1 -1
  11. package/docs-site/zh/guides/results-data.mdx +224 -0
  12. package/docs-site/zh/guides/runner.mdx +1 -1
  13. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  14. package/docs-site/zh/guides/viewing-results.mdx +147 -77
  15. package/docs-site/zh/guides/write-experiment.mdx +9 -9
  16. package/docs-site/zh/guides/write-send.mdx +6 -6
  17. package/docs-site/zh/index.mdx +1 -1
  18. package/docs-site/zh/introduction.mdx +1 -1
  19. package/docs-site/zh/reference/cli.mdx +10 -2
  20. package/docs-site/zh/reference/define-agent.mdx +5 -5
  21. package/docs-site/zh/reference/define-config.mdx +1 -1
  22. package/docs-site/zh/reference/define-eval.mdx +3 -3
  23. package/package.json +3 -2
  24. package/src/agents/ai-sdk.test.ts +1 -1
  25. package/src/agents/ai-sdk.ts +2 -2
  26. package/src/agents/bub.ts +14 -4
  27. package/src/agents/claude-code.ts +1 -1
  28. package/src/agents/codex.ts +2 -2
  29. package/src/agents/streaming.test.ts +1 -1
  30. package/src/agents/types.ts +4 -4
  31. package/src/agents/ui-message-stream.test.ts +1 -1
  32. package/src/cli.ts +104 -14
  33. package/src/context/context.test.ts +1 -1
  34. package/src/context/context.ts +3 -3
  35. package/src/context/session.ts +2 -2
  36. package/src/context/types.ts +2 -2
  37. package/src/i18n/en.ts +29 -7
  38. package/src/i18n/zh-CN.ts +24 -2
  39. package/src/report/aggregate.ts +79 -50
  40. package/src/report/components.tsx +190 -0
  41. package/src/report/compute.ts +149 -84
  42. package/src/report/default-report.tsx +222 -0
  43. package/src/report/dual-face.test.tsx +527 -0
  44. package/src/report/format.ts +29 -0
  45. package/src/report/index.ts +79 -24
  46. package/src/report/load.ts +67 -0
  47. package/src/report/metrics.ts +2 -1
  48. package/src/report/param.ts +18 -0
  49. package/src/report/primitives.tsx +117 -0
  50. package/src/report/react/CaseList.tsx +2 -2
  51. package/src/report/react/DeltaTable.tsx +2 -2
  52. package/src/report/react/MetricBars.tsx +109 -0
  53. package/src/report/react/MetricLine.tsx +200 -0
  54. package/src/report/react/MetricMatrix.tsx +1 -1
  55. package/src/report/react/MetricScatter.tsx +1 -1
  56. package/src/report/react/MetricTable.tsx +1 -1
  57. package/src/report/react/RunOverview.tsx +4 -4
  58. package/src/report/react/Scoreboard.tsx +3 -3
  59. package/src/report/react/cell.tsx +1 -1
  60. package/src/report/react/fixtures.ts +98 -36
  61. package/src/report/react/format.ts +3 -28
  62. package/src/report/react/index.tsx +41 -24
  63. package/src/report/react/render.test.tsx +8 -8
  64. package/src/report/react/styles.css +77 -0
  65. package/src/report/report.test.ts +248 -128
  66. package/src/report/report.ts +69 -0
  67. package/src/report/text/faces.ts +356 -0
  68. package/src/report/text/layout.ts +125 -0
  69. package/src/report/text/plot.ts +127 -0
  70. package/src/report/tree.ts +220 -0
  71. package/src/report/types.ts +75 -27
  72. package/src/report/web.ts +40 -0
  73. package/src/results/copy.ts +95 -41
  74. package/src/results/format.ts +12 -11
  75. package/src/results/index.ts +34 -17
  76. package/src/results/open.ts +201 -81
  77. package/src/results/results.test.ts +595 -191
  78. package/src/results/select.ts +107 -55
  79. package/src/results/types.ts +137 -45
  80. package/src/results/writer.ts +258 -0
  81. package/src/runner/attempt.ts +3 -3
  82. package/src/runner/fingerprint.ts +1 -1
  83. package/src/runner/reporters/artifacts.ts +38 -78
  84. package/src/runner/reporters/braintrust.test.ts +2 -2
  85. package/src/runner/reporters/braintrust.ts +3 -3
  86. package/src/runner/run.ts +1 -1
  87. package/src/runner/types.ts +18 -12
  88. package/src/show/compose.ts +215 -0
  89. package/src/show/index.ts +263 -0
  90. package/src/show/render.ts +456 -0
  91. package/src/show/show.test.ts +505 -0
  92. package/src/view/app/App.tsx +85 -24
  93. package/src/view/app/components/CostScoreChart.tsx +29 -10
  94. package/src/view/app/components/ExperimentTable.tsx +27 -9
  95. package/src/view/app/components/GroupSelector.tsx +1 -1
  96. package/src/view/app/i18n.ts +18 -3
  97. package/src/view/app/lib/attempt-route.test.ts +9 -9
  98. package/src/view/app/lib/attempt-route.ts +5 -5
  99. package/src/view/app/lib/outcome.ts +1 -3
  100. package/src/view/app/lib/rows.ts +95 -15
  101. package/src/view/app/main.tsx +18 -6
  102. package/src/view/app/pages/RunsPage.tsx +4 -7
  103. package/src/view/app/pages/TracesPage.tsx +3 -8
  104. package/src/view/app/types.ts +50 -6
  105. package/src/view/client-dist/app.css +1 -1
  106. package/src/view/client-dist/app.js +20 -20
  107. package/src/view/data.test.ts +254 -0
  108. package/src/view/data.ts +336 -0
  109. package/src/view/index.ts +64 -10
  110. package/src/view/server.ts +31 -9
  111. package/src/view/shared/types.ts +47 -41
  112. package/src/view/styles.css +5 -0
  113. package/src/view/template.html +1 -0
  114. package/src/view/view-report.test.ts +308 -0
  115. package/docs-site/.mintignore +0 -13
  116. package/docs-site/AGENTS.md +0 -46
  117. package/docs-site/concepts/adapter.mdx +0 -287
  118. package/docs-site/concepts/assert.mdx +0 -243
  119. package/docs-site/concepts/drive.mdx +0 -118
  120. package/docs-site/concepts/evals.mdx +0 -108
  121. package/docs-site/concepts/experiment.mdx +0 -37
  122. package/docs-site/concepts/hitl.mdx +0 -83
  123. package/docs-site/concepts/judge.mdx +0 -129
  124. package/docs-site/concepts/overview.mdx +0 -98
  125. package/docs-site/concepts/tier.mdx +0 -42
  126. package/docs-site/docs-ref/00-index.md +0 -23
  127. package/docs-site/docs-ref/01-page-types.md +0 -43
  128. package/docs-site/docs-ref/03-style-rules.md +0 -45
  129. package/docs-site/docs-ref/04-code-examples.md +0 -37
  130. package/docs-site/docs-ref/05-dx-failure-paths.md +0 -45
  131. package/docs-site/docs-ref/06-checklists.md +0 -53
  132. package/docs-site/docs.json +0 -256
  133. package/docs-site/example/ai-agent-application.mdx +0 -148
  134. package/docs-site/example/claude-code-codex-plugin.mdx +0 -162
  135. package/docs-site/example/claude-code-codex-skill.mdx +0 -147
  136. package/docs-site/example/showcase.mdx +0 -39
  137. package/docs-site/example/tier1-ai-sdk-v7.mdx +0 -136
  138. package/docs-site/example/tier1-claude-sdk.mdx +0 -119
  139. package/docs-site/example/tier1-codex-sdk.mdx +0 -111
  140. package/docs-site/example/tier1-langgraph.mdx +0 -119
  141. package/docs-site/example/tier1-pi-sdk.mdx +0 -119
  142. package/docs-site/favicon.svg +0 -5
  143. package/docs-site/github-diff.css +0 -135
  144. package/docs-site/github-diff.js +0 -11
  145. package/docs-site/guides/authoring.mdx +0 -129
  146. package/docs-site/guides/ci-integration.mdx +0 -97
  147. package/docs-site/guides/connect-otel.mdx +0 -209
  148. package/docs-site/guides/connect-your-agent.mdx +0 -221
  149. package/docs-site/guides/dataset-fanout.mdx +0 -98
  150. package/docs-site/guides/experiments.mdx +0 -71
  151. package/docs-site/guides/fixtures.mdx +0 -147
  152. package/docs-site/guides/reporters.mdx +0 -113
  153. package/docs-site/guides/runner.mdx +0 -79
  154. package/docs-site/guides/sandbox-agent.mdx +0 -138
  155. package/docs-site/guides/sandbox-backends.mdx +0 -64
  156. package/docs-site/guides/scoring-guide.mdx +0 -92
  157. package/docs-site/guides/viewing-results.mdx +0 -195
  158. package/docs-site/guides/write-experiment.mdx +0 -81
  159. package/docs-site/guides/write-send.mdx +0 -347
  160. package/docs-site/index.mdx +0 -181
  161. package/docs-site/introduction.mdx +0 -141
  162. package/docs-site/quickstart.mdx +0 -136
  163. package/docs-site/reference/builtin-agents.mdx +0 -161
  164. package/docs-site/reference/capabilities.mdx +0 -76
  165. package/docs-site/reference/cli.mdx +0 -120
  166. package/docs-site/reference/define-agent.mdx +0 -168
  167. package/docs-site/reference/define-config.mdx +0 -41
  168. package/docs-site/reference/define-eval.mdx +0 -160
  169. package/docs-site/reference/events.mdx +0 -131
  170. package/docs-site/reference/expect.mdx +0 -112
  171. package/docs-site/tracker.js +0 -8
  172. package/src/report/react/data.ts +0 -17
  173. package/src/view/aggregate.ts +0 -103
  174. package/src/view/loader.test.ts +0 -61
  175. package/src/view/loader.ts +0 -257
@@ -0,0 +1,220 @@
1
+ // 报告的元素树与双面组件基座(docs/reports.md「元素树与两个宿主」「双面组件」)。
2
+ //
3
+ // 报告函数返回的树不是「React 树」,只是 { type, props } 节点 —— 标准 react
4
+ // jsx-runtime 产的元素恰好就是这个形状。本文件是基础实现:零 react 运行时依赖
5
+ // (只有类型层的 `import type`,编译后擦除);text 宿主遍历渲染不需要 react-dom,
6
+ // web 宿主(web.ts)才真正 import react。渲染面是纯同步函数:零 IO、零 await ——
7
+ // 计算全部发生在报告函数体里,可达百 MB 的工件永远不进渲染路径。
8
+
9
+ import type { ReactNode } from "react";
10
+ import type { AttemptRef } from "../results/index.ts";
11
+
12
+ // ───────────────────────── 节点形状 ─────────────────────────
13
+
14
+ /** 标准 jsx-runtime 元素形状;text 宿主只认 type / props,不管 $$typeof。 */
15
+ export interface ReportElement {
16
+ type: unknown;
17
+ props: Record<string, unknown>;
18
+ key?: unknown;
19
+ }
20
+
21
+ /** 报告树节点:元素、文本、数组 / Fragment 的儿子们,或渲染为空的空值。 */
22
+ export type ReportNode = ReportElement | string | number | boolean | null | undefined | ReportNode[];
23
+
24
+ // react/jsx-runtime 的 Fragment 是注册符号,跨 react 版本稳定;不 import react 也认得它
25
+ const REACT_FRAGMENT = Symbol.for("react.fragment");
26
+
27
+ function isReportElement(node: unknown): node is ReportElement {
28
+ return (
29
+ typeof node === "object" &&
30
+ node !== null &&
31
+ !Array.isArray(node) &&
32
+ "type" in node &&
33
+ "props" in node &&
34
+ typeof (node as ReportElement).props === "object"
35
+ );
36
+ }
37
+
38
+ // ───────────────────────── 双面组件 ─────────────────────────
39
+
40
+ /** 挂 faces 的私有键:text 宿主与树校验靠它识别双面组件。 */
41
+ export const COMPONENT_FACES: unique symbol = Symbol.for("niceeval.report.faces");
42
+
43
+ export interface TextContext {
44
+ /** 可用列宽;Row 分栏后变窄。 */
45
+ width: number;
46
+ /** 容器组件渲染 children 用,宽度显式传递。 */
47
+ render(node: ReportNode, width?: number): string;
48
+ /** 下钻命令,通证据室。 */
49
+ attemptCommand(ref: AttemptRef): string;
50
+ }
51
+
52
+ export interface WebContext {
53
+ /** 证据室深链,同 view 的 attempt 路由。 */
54
+ attemptHref(ref: AttemptRef): string;
55
+ }
56
+
57
+ export interface ComponentFaces<P> {
58
+ /** 真 React JSX 在这个面里;返回静态可渲染的 ReactNode。 */
59
+ web(props: P, ctx: WebContext): ReactNode;
60
+ text(props: P, ctx: TextContext): string;
61
+ }
62
+
63
+ /**
64
+ * 双面组件的产物:可直接用于 JSX(React 把它当函数组件调用,走 web 面),
65
+ * text 宿主经 COMPONENT_FACES 调 text 面。
66
+ */
67
+ export type ReportComponent<P> = ((props: P) => ReactNode) & {
68
+ [COMPONENT_FACES]: ComponentFaces<P>;
69
+ displayName?: string;
70
+ };
71
+
72
+ // web 面的环境上下文:web 宿主渲染前设好;宿主之外(组件直接嵌进用户 React 应用)
73
+ // 用默认值 —— attemptHref 默认 view 的 attempt 路由格式(自定义组件显式调 ctx.attemptHref
74
+ // 时总有去处);官方组件的「宿主里自动接证据室」只在宿主上下文激活时发生,
75
+ // 宿主外不传 attemptHref 就是纯展示,不发明断链。
76
+ const DEFAULT_WEB_CONTEXT: WebContext = {
77
+ attemptHref: (ref) => `#/attempt/${ref.run}/${ref.result}`,
78
+ };
79
+ let activeWebContext: WebContext | null = null;
80
+
81
+ /** web 宿主用:在给定 WebContext 下同步渲染(React 静态渲染本身是同步的)。 */
82
+ export function runWithWebContext<T>(ctx: WebContext, fn: () => T): T {
83
+ const prev = activeWebContext;
84
+ activeWebContext = ctx;
85
+ try {
86
+ return fn();
87
+ } finally {
88
+ activeWebContext = prev;
89
+ }
90
+ }
91
+
92
+ /** 官方组件的装配用:宿主上下文激活时才把 ctx.attemptHref 当默认下钻。 */
93
+ export function isHostWebContextActive(): boolean {
94
+ return activeWebContext !== null;
95
+ }
96
+
97
+ /**
98
+ * 定义一个双面组件:faces 两键必填 —— 少实现一个面编译不过,配对是结构义务。
99
+ * 基础实现不 import react;产物以可调用组件的形状兼容 React 渲染。
100
+ */
101
+ export function defineComponent<P>(faces: ComponentFaces<P>): ReportComponent<P> {
102
+ if (typeof faces?.web !== "function" || typeof faces?.text !== "function") {
103
+ throw new Error(
104
+ "defineComponent requires both faces: { web(props, ctx), text(props, ctx) }. " +
105
+ "Every report component must render in both hosts (niceeval view and niceeval show).",
106
+ );
107
+ }
108
+ const component = ((props: P) => faces.web(props, activeWebContext ?? DEFAULT_WEB_CONTEXT)) as ReportComponent<P>;
109
+ component[COMPONENT_FACES] = faces;
110
+ return component;
111
+ }
112
+
113
+ export function facesOf(type: unknown): ComponentFaces<unknown> | undefined {
114
+ if (typeof type !== "function") return undefined;
115
+ return (type as Partial<ReportComponent<unknown>>)[COMPONENT_FACES] as ComponentFaces<unknown> | undefined;
116
+ }
117
+
118
+ // ───────────────────────── 树校验 ─────────────────────────
119
+
120
+ function componentLabel(type: unknown): string {
121
+ if (typeof type === "string") return `<${type}>`;
122
+ if (typeof type === "function") {
123
+ const name = (type as { displayName?: string; name?: string }).displayName || (type as { name?: string }).name;
124
+ return name ? `<${name}>` : "<anonymous component>";
125
+ }
126
+ if (type === REACT_FRAGMENT) return "<>";
127
+ return `<${String(type)}>`;
128
+ }
129
+
130
+ /**
131
+ * 渲染前树校验:页面树里只放双面组件、排版原语与普通组合函数,字符串 intrinsic
132
+ * (<div>)报错、指名组件路径。这是运行时校验而非编译期(标准 JSX 下 TS 把一切
133
+ * JSX 表达式统一成 JSX.Element);两个宿主渲染前跑同一遍 —— 不做单侧宽容,否则
134
+ * 对着 view 写的页面到 show 才炸。校验只下钻 children(children 就是报告树);
135
+ * 普通函数组件调用展开(渲染面纯同步,重复调用无副作用)。
136
+ */
137
+ export function validateReportTree(node: ReportNode, path: string[] = []): void {
138
+ if (node === null || node === undefined || typeof node === "boolean") return;
139
+ if (typeof node === "string" || typeof node === "number") return;
140
+ if (Array.isArray(node)) {
141
+ for (const child of node) validateReportTree(child, path);
142
+ return;
143
+ }
144
+ if (!isReportElement(node)) return;
145
+ const { type, props } = node;
146
+ if (typeof type === "string") {
147
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
148
+ throw new Error(
149
+ `Raw HTML <${type}> has no terminal face; use <Text>, layout primitives, or a defineComponent component.${where}`,
150
+ );
151
+ }
152
+ if (type === REACT_FRAGMENT) {
153
+ validateReportTree(props.children as ReportNode, path);
154
+ return;
155
+ }
156
+ const label = componentLabel(type);
157
+ if (facesOf(type)) {
158
+ // 双面组件是校验的信任边界之内的叶子,但 children 仍是报告树的一部分
159
+ validateReportTree(props.children as ReportNode, [...path, label]);
160
+ return;
161
+ }
162
+ if (typeof type === "function") {
163
+ // 普通函数组件 = 用户拿函数组合页面片段:调用展开继续校验
164
+ const expanded = (type as (p: unknown) => ReportNode)(props);
165
+ validateReportTree(expanded, [...path, label]);
166
+ return;
167
+ }
168
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
169
+ throw new Error(`Unsupported node type ${label} in report tree.${where}`);
170
+ }
171
+
172
+ // ───────────────────────── text 渲染 ─────────────────────────
173
+
174
+ export interface TextRenderOptions {
175
+ /** 终端可用列宽;默认 80。 */
176
+ width?: number;
177
+ /** 下钻命令的生成;宿主注入,默认指向 view 的 attempt 路由。 */
178
+ attemptCommand?: (ref: AttemptRef) => string;
179
+ }
180
+
181
+ export function createTextContext(options?: TextRenderOptions): TextContext {
182
+ const width = Math.max(20, options?.width ?? 80);
183
+ const attemptCommand =
184
+ options?.attemptCommand ?? ((ref: AttemptRef) => `niceeval view "#/attempt/${ref.run}/${ref.result}"`);
185
+ const make = (w: number): TextContext => ({
186
+ width: w,
187
+ attemptCommand,
188
+ render(node, childWidth) {
189
+ return renderNodeToText(node, childWidth === undefined ? this : make(Math.max(10, childWidth)));
190
+ },
191
+ });
192
+ return make(width);
193
+ }
194
+
195
+ /** text 宿主的遍历渲染:双面组件走 text 面,普通函数调用展开,块之间以换行相接。 */
196
+ export function renderNodeToText(node: ReportNode, ctx: TextContext): string {
197
+ if (node === null || node === undefined || typeof node === "boolean") return "";
198
+ if (typeof node === "string" || typeof node === "number") return String(node);
199
+ if (Array.isArray(node)) {
200
+ return node
201
+ .map((child) => renderNodeToText(child, ctx))
202
+ .filter((text) => text.length > 0)
203
+ .join("\n");
204
+ }
205
+ if (!isReportElement(node)) return "";
206
+ const { type, props } = node;
207
+ if (typeof type === "string") {
208
+ // 校验先行会拦住;这里兜底同一条错误,渲染路径自身也不宽容
209
+ throw new Error(
210
+ `Raw HTML <${type}> has no terminal face; use <Text>, layout primitives, or a defineComponent component.`,
211
+ );
212
+ }
213
+ if (type === REACT_FRAGMENT) return renderNodeToText(props.children as ReportNode, ctx);
214
+ const faces = facesOf(type);
215
+ if (faces) return faces.text(props, ctx);
216
+ if (typeof type === "function") {
217
+ return renderNodeToText((type as (p: unknown) => ReportNode)(props), ctx);
218
+ }
219
+ return "";
220
+ }
@@ -1,9 +1,10 @@
1
- // niceeval/report 的公开类型:指标(Metric)、维度(Dimension)与计算函数产物
2
- // ( niceeval/report/react 组件的 props)。数据契约照抄 docs/reports.md
3
- // 「计算函数与数据契约」——这些不是持久化格式,没有 format / schemaVersion 信封;
4
- // 兼容性跟随 npm 版本。
1
+ // niceeval/report 的公开类型:指标(Metric)、维度(Dimension / param())与计算函数
2
+ // 产物(即组件的 data props)。数据契约照 docs/reports.md「计算函数与数据契约」;
3
+ // 这些不是持久化格式,没有 format / schemaVersion 信封,兼容性跟随 npm 版本。
5
4
 
6
- import type { AttemptHandle } from "../results/types.ts";
5
+ import type { AttemptHandle, AttemptRef, SelectionWarning } from "../results/index.ts";
6
+
7
+ export type { AttemptRef, SelectionWarning };
7
8
 
8
9
  // ───────────────────────── 指标与聚合 ─────────────────────────
9
10
 
@@ -24,11 +25,12 @@ export interface MetricAggregate {
24
25
  /**
25
26
  * 指标:纯函数,吃一个 AttemptHandle 吐一个值(null = 此 attempt 测不了这个指标,
26
27
  * 不进聚合;0 = 测了结果是零,照常进),外加名字、两级聚合方式和渲染提示。
27
- * 内置指标与自定义指标是同一个类型,没有特权。
28
+ * 内置指标与自定义指标是同一个类型,没有特权。name 走字面量泛型:列键锚在指标
29
+ * 对象上(`row.cells[passRate.name]`),拼错列名编译不过。
28
30
  */
29
- export interface Metric {
31
+ export interface Metric<Name extends string = string> {
30
32
  /** MetricColumn.key 与列头的来源;同一次计算里重名是错误。 */
31
- name: string;
33
+ name: Name;
32
34
  /** 列头;省略时用 name。 */
33
35
  label?: string;
34
36
  description?: string;
@@ -47,8 +49,10 @@ export interface Metric {
47
49
  display?: (value: number) => string;
48
50
  }
49
51
 
52
+ // ───────────────────────── 维度 ─────────────────────────
53
+
50
54
  /**
51
- * 维度:attempt 分到哪一组。内置维度就是 EvalResult 已有的身份字段;自定义维度是一个函数。
55
+ * 维度:attempt 分到哪一组。内置维度就是结果已有的身份字段;自定义维度是一个函数。
52
56
  * - "evalGroup" = eval id 的第一段:"algebra/quadratic" → "algebra"(考试里的「科目」)
53
57
  * - "snapshot" = "<experimentId> @ <startedAt>",把两次快照并排成行,与 view 的 Compare 同口径
54
58
  */
@@ -61,7 +65,23 @@ export type Dimension =
61
65
  | "snapshot"
62
66
  | { name: string; of: (attempt: AttemptHandle) => string };
63
67
 
64
- // ───────────────────────── 计算产物(组件 props)─────────────────────────
68
+ /**
69
+ * param() 的产物:把 experiment 声明的 params 当维度(series / rows / columns / points
70
+ * 槽,按声明值分组)或轴(MetricLine 的 x 槽,要求数值并驱动刻度)。
71
+ * 未声明该 param 的 experiment 不猜:分组如实归「(unset)」,作轴不画点、注脚报数。
72
+ */
73
+ export interface ParamRef {
74
+ readonly kind: "param";
75
+ readonly name: string;
76
+ /** 组标签 / 轴标签;函数形态把声明值折成组名(如 `(v) => \`${v} agents\``)。 */
77
+ readonly label?: string | ((value: string | number | boolean) => string);
78
+ readonly unit?: string;
79
+ }
80
+
81
+ /** 维度槽的输入:内置/自定义维度,或 experiment 声明的 param。 */
82
+ export type DimensionInput = Dimension | ParamRef;
83
+
84
+ // ───────────────────────── 计算产物(组件 data props)─────────────────────────
65
85
 
66
86
  export interface MetricColumn {
67
87
  /** = metric.name,与 cells 的键对应。 */
@@ -81,20 +101,19 @@ export interface MetricCell {
81
101
  samples: number;
82
102
  /** 组内 attempt 总数;samples < total = 有 attempt 测不了这个指标。 */
83
103
  total: number;
84
- /** 这个格子由哪些 attempt 算出 —— 回到证据的引用。 */
85
- refs?: AttemptRef[];
104
+ /**
105
+ * 这个格子由哪些 attempt 算出 —— 回到证据的引用。必填(可空数组):
106
+ * 「每个数字点进去就是证据」是页面的核心承诺,可选字段会让深链静默缺失。
107
+ */
108
+ refs: AttemptRef[];
86
109
  }
87
110
 
88
- // AttemptRef 的家在 niceeval/results:Reports refs、view 深链、copyRun 用同一个证据身份。
89
- // 这里 import + re-export,公共 API 入口不变。
90
- import type { AttemptRef } from "../results/types.ts";
91
- export type { AttemptRef };
92
-
93
- export interface TableData {
111
+ /** 列键 K 来自 columns 元组的字面量 name:拼错列名编译不过,不是运行时 undefined。 */
112
+ export interface TableData<K extends string = string> {
94
113
  /** 行维度名,如 "agent"。 */
95
114
  dimension: string;
96
115
  columns: MetricColumn[];
97
- rows: { key: string; cells: Record<string, MetricCell> }[];
116
+ rows: { key: string; cells: Record<K, MetricCell> }[];
98
117
  }
99
118
 
100
119
  export interface MatrixData {
@@ -108,8 +127,12 @@ export interface MatrixData {
108
127
  }
109
128
 
110
129
  export interface ScoreboardData {
111
- /** 被打分的维度名,如 "agent"。 */
112
- of: string;
130
+ /**
131
+ * 被打分的维度名,如 "agent"。
132
+ * (计算函数的维度槽叫 rows,与 MetricTable.data 统一;数据形状上行数组已占用
133
+ * rows 一词,维度名沿用 TableData 的 dimension。)
134
+ */
135
+ dimension: string;
113
136
  fullMarks: number;
114
137
  /** 实际生效的权重表(按匹配顺序:最长前缀在前)—— 成绩单可审计。 */
115
138
  weights: { prefix: string; weight: number }[];
@@ -151,6 +174,31 @@ export interface ScatterData {
151
174
  }[];
152
175
  }
153
176
 
177
+ /** MetricLine 的 x 轴:experiment 声明的 param,数值驱动刻度。 */
178
+ export interface LineAxis {
179
+ /** param 名。 */
180
+ key: string;
181
+ label: string;
182
+ unit?: string;
183
+ }
184
+
185
+ export interface LineData {
186
+ x: LineAxis;
187
+ /** 系列维度名(param 或普通维度)。 */
188
+ series?: string;
189
+ y: MetricColumn;
190
+ rows: {
191
+ /** 点的键(experiment id):每个点 = 一个 experiment 的聚合。 */
192
+ key: string;
193
+ series?: string;
194
+ /** param 声明值;未声明或非数值 → null,点不画、注脚报数。 */
195
+ x: number | null;
196
+ /** 已格式化的 x("300 ms");x 为 null 时为空串。 */
197
+ xDisplay: string;
198
+ y: MetricCell;
199
+ }[];
200
+ }
201
+
154
202
  export interface OverviewData {
155
203
  snapshots: { experimentId: string; agent: string; model?: string; startedAt: string }[];
156
204
  totals: {
@@ -164,27 +212,27 @@ export interface OverviewData {
164
212
  costUSD: number | null;
165
213
  durationMs: number;
166
214
  };
167
- /** 选择器的警告透传进来,RunOverview 直接渲染 —— 诚实不靠使用者记得渲染。 */
168
- warnings: string[];
215
+ /** 选集的警告随行(结构化,含渲染好的 message),RunOverview 直接渲染。 */
216
+ warnings: SelectionWarning[];
169
217
  }
170
218
 
171
- export interface DeltaData {
219
+ export interface DeltaData<K extends string = string> {
172
220
  columns: MetricColumn[];
173
221
  rows: {
174
222
  /** pair 的 label,如 "bub"。 */
175
223
  key: string;
176
- /** 基线侧。 */
224
+ /** 基线侧:experiment id 或快照键 "<experimentId> @ <startedAt>"。 */
177
225
  a: { experimentId: string };
178
226
  /** 对比侧。 */
179
227
  b: { experimentId: string };
180
228
  cells: Record<
181
- string,
229
+ K,
182
230
  {
183
231
  a: MetricCell;
184
232
  b: MetricCell;
185
233
  /** b.value - a.value;任一侧 null → null,不硬算。 */
186
234
  delta: number | null;
187
- /** 已带符号("+12%" / "-$0.80"),涨跌好坏由 better 判定。 */
235
+ /** 已带符号("+12%" / "-$0.80" / "±0"),涨跌好坏由 better 判定。 */
188
236
  display: string;
189
237
  }
190
238
  >;
@@ -0,0 +1,40 @@
1
+ // web 宿主(view --report)的装载入口:同一棵树走 web 面,renderToStaticMarkup 吐静态
2
+ // HTML 烘进查看器的报告槽。只有这一侧真正 import react-dom(import 边界即运行时边界),
3
+ // 所以本文件不从 niceeval/report 的入口 re-export —— 宿主与测试按源路径 import。
4
+
5
+ import * as React from "react";
6
+ import type { ReactNode } from "react";
7
+ import { renderToStaticMarkup } from "react-dom/server";
8
+
9
+ // tsx 按「tsconfig 所在目录」为界应用 jsx 配置:CLI 在用户项目 cwd 下跑时,包内
10
+ // .tsx(primitives / react 组件的 web 面)落在用户 tsconfig 覆盖范围之外,esbuild
11
+ // 退化成 classic JSX(编译产物引用全局 React)。这里补上全局 React,web 面在两种
12
+ // 编译模式下都可渲染;只定义一次,不覆盖宿主已有的全局。
13
+ const g = globalThis as { React?: unknown };
14
+ if (g.React === undefined) g.React = React;
15
+ import type { AttemptRef } from "../results/index.ts";
16
+ import { runWithWebContext, validateReportTree, type WebContext } from "./tree.ts";
17
+ import { prepareDefaultReportData, runWithDefaultReportData } from "./default-report.tsx";
18
+ import type { ReportContext, ReportDefinition } from "./report.ts";
19
+
20
+ export interface StaticHtmlOptions {
21
+ /** 证据室深链;缺省用 view 的 attempt 路由 `#/attempt/<run>/<result>`。 */
22
+ attemptHref?: (ref: AttemptRef) => string;
23
+ }
24
+
25
+ /** build → 渲染前树校验(与 text 宿主同一遍)→ 备好官方水位 → 静态渲染 web 面。 */
26
+ export async function renderReportToStaticHtml(
27
+ definition: ReportDefinition,
28
+ ctx: ReportContext,
29
+ options?: StaticHtmlOptions,
30
+ ): Promise<string> {
31
+ const node = await definition.build(ctx);
32
+ validateReportTree(node);
33
+ const defaultData = await prepareDefaultReportData(ctx.selection);
34
+ const webCtx: WebContext = {
35
+ attemptHref: options?.attemptHref ?? ((ref) => `#/attempt/${ref.run}/${ref.result}`),
36
+ };
37
+ return runWithDefaultReportData(defaultData, () =>
38
+ runWithWebContext(webCtx, () => renderToStaticMarkup(node as ReactNode)),
39
+ );
40
+ }
@@ -1,26 +1,28 @@
1
- // copyRun:把选中快照按格式感知地复制到另一个目录(设计见 docs/results-lib.md「复制与瘦身」)。
1
+ // copySnapshots:把选中快照按格式感知地复制到另一个目录(定稿见 docs/results-lib.md「复制与瘦身」)。
2
2
  //
3
3
  // 发布场景的原语:只带指定工件、只带选中的 attempt,布局知识不外泄。
4
4
  // 工件复制忠实于源(copyFile 原字节,不重新序列化、不消毒);summary.json 按选中条目重建,
5
- // 版本元数据保留,榜单计数按选中条目重算 —— 产物是一个 openResults / `niceeval view`
6
- // 都能直接读的合法 run 目录。
5
+ // 版本元数据保留,榜单计数按选中条目重算 —— 产物是一个标准 run 目录,openResults /
6
+ // `niceeval view` 直接能读。唯一随行补记的是挑选时的覆盖事实:每个复制出的快照带上
7
+ // knownEvalIds(复制时刻该实验已知的 eval 并集),发布目录上重新 openResults().latest(),
8
+ // 残缺警告被同一套机制重新算出来,不靠发布者转述。
7
9
 
8
- import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
10
+ import { copyFile, mkdir, readdir, stat, writeFile } from "node:fs/promises";
9
11
  import { dirname, join, resolve } from "node:path";
10
- import type { EvalResult, RunSummary } from "../runner/types.ts";
11
- import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../runner/types.ts";
12
- import type { Usage } from "../o11y/types.ts";
12
+ import type { EvalResult, RunSummary, Usage } from "../types.ts";
13
+ import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../types.ts";
13
14
  import { artifactFileOf, attemptDirOf } from "./format.ts";
14
- import { isNewerRun } from "./select.ts";
15
- import type { ArtifactKind, AttemptHandle, RunHandle, SnapshotHandle } from "./types.ts";
15
+ import { experimentOfSnapshot } from "./open.ts";
16
+ import { isNewerRunDir } from "./select.ts";
17
+ import type { ArtifactKind, AttemptHandle, RunDir, Selection, Snapshot } from "./types.ts";
16
18
  import { ARTIFACT_KINDS } from "./types.ts";
17
19
 
18
- export interface CopyRunOptions {
20
+ export interface CopySnapshotsOptions {
19
21
  /** 要带上的工件种类;省略 = 全部五类。diff 可达百 MB、o11y 查看器不读,发布时常见地不带。 */
20
22
  artifacts?: ArtifactKind[];
21
23
  }
22
24
 
23
- export interface CopiedRun {
25
+ export interface CopySnapshotsResult {
24
26
  /** 目标 run 目录的绝对路径(summary.json 所在目录)。 */
25
27
  dir: string;
26
28
  /** 重建后的 summary(与写入磁盘的一致)。 */
@@ -30,30 +32,31 @@ export interface CopiedRun {
30
32
  }
31
33
 
32
34
  /**
33
- * `snapshots` 里的全部 attempt 复制成 `destDir` 下的一个 run 目录。
34
- * 快照可以来自不同物理 run(latestPerExperiment 的典型输出);两个 attempt 落到同一
35
- * 工件目录时保留最新 run 的那份并出 warning —— 复制前先 dedupeAttempts 可避免。
35
+ * 把选中快照的全部 attempt 复制成 `destDir` 下的一个标准 run 目录。
36
+ * 输入收 Selection 或手工挑的 Snapshot[]( Reports 计算函数同一输入约定);
37
+ * 快照可以来自不同物理 run(latest() 的典型输出)。目标目录非空即报错,不静默覆盖、不合并。
36
38
  */
37
- export async function copyRun(
38
- snapshots: SnapshotHandle | SnapshotHandle[],
39
+ export async function copySnapshots(
40
+ selection: Selection | Snapshot[],
39
41
  destDir: string,
40
- opts?: CopyRunOptions,
41
- ): Promise<CopiedRun> {
42
- const selected = Array.isArray(snapshots) ? snapshots : [snapshots];
42
+ opts?: CopySnapshotsOptions,
43
+ ): Promise<CopySnapshotsResult> {
44
+ const selected = Array.isArray(selection) ? selection : selection.snapshots;
43
45
  if (selected.length === 0) {
44
46
  throw new Error(
45
- "copyRun got no snapshots to copy. Check the experiments filter, or pass snapshots from openResults()/latestPerExperiment().",
47
+ "copySnapshots got no snapshots to copy. Check the experiments filter, or pass snapshots from openResults().latest().",
46
48
  );
47
49
  }
48
50
  const kinds = opts?.artifacts ?? [...ARTIFACT_KINDS];
49
- const dest = resolve(destDir);
50
-
51
- const sourceRuns = uniqueRuns(selected);
52
- for (const run of sourceRuns) {
53
- if (resolve(run.dir) === dest) {
54
- throw new Error(`copyRun destination ${dest} is a source run directory. Pick a different destination.`);
51
+ for (const kind of kinds) {
52
+ if (!ARTIFACT_KINDS.includes(kind)) {
53
+ throw new Error(
54
+ `Unknown artifact kind "${String(kind)}" in copySnapshots options. Valid kinds: ${ARTIFACT_KINDS.join(", ")}.`,
55
+ );
55
56
  }
56
57
  }
58
+ const dest = resolve(destDir);
59
+ await assertEmptyDestination(dest);
57
60
 
58
61
  // 第一趟:按目标工件目录选出胜者(最新 run 的那份),顺序取首次出现处 —— 两趟避免
59
62
  // 「旧的先落盘、新的再去清理覆盖」的中间态。
@@ -70,9 +73,9 @@ export async function copyRun(
70
73
  continue;
71
74
  }
72
75
  warnings.push(
73
- `warning: multiple attempts map to "${relDir}" in the copied run; kept the one from the newest run. Dedupe attempts or re-select snapshots before copyRun to avoid this.`,
76
+ `warning: multiple attempts map to "${relDir}" in the copied run; kept the one from the newest run. Dedupe attempts or re-select snapshots before copySnapshots to avoid this.`,
74
77
  );
75
- if (isNewerRun(attempt.run, existing.run)) winners.set(relDir, attempt);
78
+ if (isNewerRunDir(attempt.runDir, existing.runDir)) winners.set(relDir, attempt);
76
79
  }
77
80
  }
78
81
 
@@ -91,19 +94,37 @@ export async function copyRun(
91
94
  results.push(slimForCopy(attempt.result, relDir, copied));
92
95
  }
93
96
 
94
- const summary = rebuildSummary(selected, sourceRuns, results, dest);
97
+ const summary = rebuildSummary(selected, results, dest);
95
98
  await writeFile(join(dest, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
96
99
  return { dir: dest, summary, warnings };
97
100
  }
98
101
 
102
+ /** 目标目录非空即报错:盘上不该出现「我没写的东西被动过」的惊讶;发布脚本要幂等就自己先清目录。 */
103
+ async function assertEmptyDestination(dest: string): Promise<void> {
104
+ let entries: string[];
105
+ try {
106
+ entries = await readdir(dest);
107
+ } catch (e) {
108
+ if ((e as NodeJS.ErrnoException | null)?.code === "ENOENT") return;
109
+ throw new Error(
110
+ `Destination "${dest}" is not a usable directory (${e instanceof Error ? e.message : String(e)}). Pass a new or empty directory.`,
111
+ );
112
+ }
113
+ if (entries.length > 0) {
114
+ throw new Error(
115
+ `Destination directory "${dest}" is not empty. copySnapshots never overwrites or merges; delete the directory first if you want to replace it.`,
116
+ );
117
+ }
118
+ }
119
+
99
120
  /** 源工件定位:与读取面同一候选顺序(本 run 的 artifactsDir 优先,artifactBase 回退)。 */
100
121
  async function findArtifactFiles(
101
122
  attempt: AttemptHandle,
102
123
  kinds: ArtifactKind[],
103
124
  ): Promise<{ kind: ArtifactKind; source: string }[]> {
104
125
  const candidates: string[] = [];
105
- if (attempt.result.artifactsDir) candidates.push(join(attempt.run.dir, attempt.result.artifactsDir));
106
- if (attempt.result.artifactBase) candidates.push(join(dirname(attempt.run.dir), attempt.result.artifactBase));
126
+ if (attempt.result.artifactsDir) candidates.push(join(attempt.runDir.dir, attempt.result.artifactsDir));
127
+ if (attempt.result.artifactBase) candidates.push(join(dirname(attempt.runDir.dir), attempt.result.artifactBase));
107
128
 
108
129
  const found: { kind: ArtifactKind; source: string }[] = [];
109
130
  for (const kind of kinds) {
@@ -142,15 +163,11 @@ function slimForCopy(r: EvalResult, relDir: string, copied: Set<ArtifactKind>):
142
163
  };
143
164
  }
144
165
 
145
- function rebuildSummary(
146
- selected: SnapshotHandle[],
147
- sourceRuns: RunHandle[],
148
- results: EvalResult[],
149
- dest: string,
150
- ): RunSummary {
166
+ function rebuildSummary(selected: Snapshot[], results: EvalResult[], dest: string): RunSummary {
151
167
  // 版本元数据与 run 级元数据取最新源 run 的(producer 缺失的 legacy 源保持缺失,不冒充)。
168
+ const sourceRuns = uniqueRuns(selected);
152
169
  let newest = sourceRuns[0];
153
- for (const run of sourceRuns) if (isNewerRun(run, newest)) newest = run;
170
+ for (const run of sourceRuns) if (isNewerRunDir(run, newest)) newest = run;
154
171
 
155
172
  const counts = { passed: 0, failed: 0, skipped: 0, errored: 0 };
156
173
  for (const r of results) counts[r.outcome] += 1;
@@ -171,9 +188,46 @@ function rebuildSummary(
171
188
  ...usageAndCost(results),
172
189
  results,
173
190
  outputDir: dest,
191
+ snapshots: snapshotsMeta(selected, newest.summary.startedAt),
174
192
  };
175
193
  }
176
194
 
195
+ /**
196
+ * 快照级元数据补记:startedAt 保留各快照自己的时刻(它们可能来自不同源 run),
197
+ * knownEvalIds = 复制时刻该实验已知的 eval 并集(openResults 产出的快照从所属实验取,
198
+ * 手工构造的快照退回「同 id 输入快照的覆盖 ∪ 携带值」)—— 残缺检测的分母随数据走。
199
+ */
200
+ function snapshotsMeta(selected: Snapshot[], summaryStartedAt: string): NonNullable<RunSummary["snapshots"]> {
201
+ // 同一 experiment 被选了多个快照(未 dedupe)时取最新的那个,与工件胜者规则一致。
202
+ const bySnapshotId = new Map<string, Snapshot>();
203
+ for (const snapshot of selected) {
204
+ const existing = bySnapshotId.get(snapshot.experimentId);
205
+ if (!existing || isNewerRunDir(snapshot.runDir, existing.runDir)) {
206
+ bySnapshotId.set(snapshot.experimentId, snapshot);
207
+ }
208
+ }
209
+
210
+ const meta: NonNullable<RunSummary["snapshots"]> = {};
211
+ for (const [experimentId, snapshot] of bySnapshotId) {
212
+ const union = experimentOfSnapshot(snapshot)?.evalIds ?? fallbackUnion(selected, experimentId);
213
+ meta[experimentId] = {
214
+ ...(snapshot.startedAt !== summaryStartedAt ? { startedAt: snapshot.startedAt } : {}),
215
+ knownEvalIds: union,
216
+ };
217
+ }
218
+ return meta;
219
+ }
220
+
221
+ function fallbackUnion(selected: Snapshot[], experimentId: string): string[] {
222
+ const ids = new Set<string>();
223
+ for (const snapshot of selected) {
224
+ if (snapshot.experimentId !== experimentId) continue;
225
+ for (const ev of snapshot.evals) ids.add(ev.id);
226
+ for (const known of snapshot.knownEvalIds ?? []) ids.add(known);
227
+ }
228
+ return [...ids].sort();
229
+ }
230
+
177
231
  /** usage / 成本按选中条目重算;口径同 runner 的 summarize(没有任何 usage 就不写)。 */
178
232
  function usageAndCost(results: EvalResult[]): Pick<RunSummary, "usage" | "estimatedCostUSD"> {
179
233
  const usages = results.map((r) => r.usage).filter((u): u is Usage => u !== undefined);
@@ -191,10 +245,10 @@ function usageAndCost(results: EvalResult[]): Pick<RunSummary, "usage" | "estima
191
245
  return { usage, estimatedCostUSD: cost || undefined };
192
246
  }
193
247
 
194
- function uniqueRuns(snapshots: SnapshotHandle[]): RunHandle[] {
195
- const runs: RunHandle[] = [];
248
+ function uniqueRuns(snapshots: Snapshot[]): RunDir[] {
249
+ const runs: RunDir[] = [];
196
250
  for (const snapshot of snapshots) {
197
- if (!runs.includes(snapshot.run)) runs.push(snapshot.run);
251
+ if (!runs.includes(snapshot.runDir)) runs.push(snapshot.runDir);
198
252
  }
199
253
  return runs;
200
254
  }