niceeval 0.11.4-canary.21 → 0.12.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 (54) hide show
  1. package/dist/report/assets/series-encoding.d.ts +36 -0
  2. package/dist/report/assets/series-encoding.js +104 -0
  3. package/dist/report/components/entity-lists/compute.js +5 -17
  4. package/dist/report/components/entity-lists/content.d.ts +2 -0
  5. package/dist/report/components/entity-lists/content.js +22 -7
  6. package/dist/report/definition/cell.d.ts +4 -0
  7. package/dist/report/definition/cell.js +5 -1
  8. package/dist/report/definition/primitives/chart.js +125 -25
  9. package/dist/report/definition/primitives.js +3 -2
  10. package/dist/report/definition/tree.d.ts +5 -2
  11. package/dist/report/definition/tree.js +1 -0
  12. package/dist/report/index.d.ts +1 -1
  13. package/dist/report/model/aggregate.d.ts +10 -1
  14. package/dist/report/model/aggregate.js +24 -0
  15. package/dist/report/model/locale.d.ts +0 -1
  16. package/dist/report/model/locale.js +0 -2
  17. package/dist/report/model/types.d.ts +8 -0
  18. package/dist/report/presentation.d.ts +53 -6
  19. package/dist/report/presentation.js +40 -5
  20. package/dist/report/react/index.d.ts +1 -1
  21. package/dist/report/slices/compute.js +52 -4
  22. package/dist/report/slices/content.d.ts +2 -1
  23. package/dist/report/slices/content.js +48 -15
  24. package/dist/report/slices/validate.js +23 -0
  25. package/package.json +1 -1
  26. package/src/report/assets/series-encoding.tsx +177 -0
  27. package/src/report/assets/styles.css +47 -5
  28. package/src/report/components/compute.test.ts +15 -9
  29. package/src/report/components/entity-lists/compute.ts +5 -19
  30. package/src/report/components/entity-lists/content.test.ts +13 -8
  31. package/src/report/components/entity-lists/content.ts +24 -7
  32. package/src/report/definition/cell.ts +8 -1
  33. package/src/report/definition/primitives/chart.tsx +197 -32
  34. package/src/report/definition/primitives.tsx +8 -3
  35. package/src/report/definition/tree.ts +6 -2
  36. package/src/report/index.ts +11 -1
  37. package/src/report/model/aggregate.ts +26 -0
  38. package/src/report/model/locale.ts +0 -2
  39. package/src/report/model/types.ts +8 -0
  40. package/src/report/presentation.test.tsx +179 -19
  41. package/src/report/presentation.ts +114 -11
  42. package/src/report/react/index.tsx +11 -1
  43. package/src/report/slices/compute.ts +59 -4
  44. package/src/report/slices/content.ts +55 -16
  45. package/src/report/slices/delta-table.test.ts +138 -3
  46. package/src/report/slices/validate.test.ts +29 -0
  47. package/src/report/slices/validate.ts +16 -0
  48. package/src/runner/gate-lease.test.ts +38 -14
  49. package/src/runner/lock.test.ts +40 -16
  50. package/src/sandbox/compose.ts +3 -1
  51. package/src/sandbox/dockerfile-build.ts +4 -1
  52. package/src/sandbox/runtime.ts +4 -1
  53. package/src/sandbox/vercel.test.ts +12 -3
  54. package/src/show/index.ts +10 -9
@@ -0,0 +1,177 @@
1
+ // 24 视觉身份 = 六色 × 四变体的可直接使用 SVG/CSS 值。
2
+ // 契约:docs/feature/reports/library/presentation.md「实验颜色与维度呈现」、
3
+ // docs/feature/reports/components/README.md「视觉编码容量(24 个身份)」。
4
+ // pattern id 全局唯一、颜色走主题令牌 --niceeval-color-series-N,换 basalt/chalk 自动换色。
5
+
6
+ import type { ReactNode } from "react";
7
+ import type { SeriesVariant } from "../presentation.ts";
8
+
9
+ /** 槽位色板下标 1..6 → CSS 变量,原样交给 fill / stroke / color。 */
10
+ export function seriesColorVar(colorIndex: number): string {
11
+ if (!Number.isInteger(colorIndex) || colorIndex < 1 || colorIndex > 6) {
12
+ throw new Error(`colorIndex must be an integer in [1, 6], got ${colorIndex}.`);
13
+ }
14
+ return `var(--niceeval-color-series-${colorIndex})`;
15
+ }
16
+
17
+ /** SVG pattern id:niceeval-series-pat-v{2|3|4}-c{1..6}。variant 1 是实心,无 pattern。 */
18
+ export function seriesPatternId(colorIndex: number, variant: SeriesVariant): string {
19
+ if (variant === 1) {
20
+ throw new Error("variant 1 is solid fill; it has no pattern id.");
21
+ }
22
+ return `niceeval-series-pat-v${variant}-c${colorIndex}`;
23
+ }
24
+
25
+ export function seriesFill(colorIndex: number, variant: SeriesVariant): string {
26
+ if (variant === 1) return seriesColorVar(colorIndex);
27
+ return `url(#${seriesPatternId(colorIndex, variant)})`;
28
+ }
29
+
30
+ /** 线型变体 → strokeDasharray;variant 1 为空串(实线)。 */
31
+ export function seriesStrokeDasharray(variant: SeriesVariant): string {
32
+ switch (variant) {
33
+ case 1:
34
+ return "";
35
+ case 2:
36
+ return "6 4";
37
+ case 3:
38
+ return "2 3";
39
+ case 4:
40
+ return "8 3 2 3";
41
+ }
42
+ }
43
+
44
+ export interface SeriesMarkerShape {
45
+ readonly path: string;
46
+ readonly viewBox: string;
47
+ }
48
+
49
+ /** 四种 marker 形状(viewBox 0 0 12 12),变体按 docs 槽序表 1–4。 */
50
+ export function seriesMarkerShape(variant: SeriesVariant): SeriesMarkerShape {
51
+ switch (variant) {
52
+ case 1:
53
+ // 圆
54
+ return { path: "M6 1.5a4.5 4.5 0 1 0 0.01 0z", viewBox: "0 0 12 12" };
55
+ case 2:
56
+ // 方
57
+ return { path: "M2.5 2.5h7v7h-7z", viewBox: "0 0 12 12" };
58
+ case 3:
59
+ // 菱
60
+ return { path: "M6 1.5 10.5 6 6 10.5 1.5 6z", viewBox: "0 0 12 12" };
61
+ case 4:
62
+ // 三角
63
+ return { path: "M6 1.5 10.5 10.5 1.5 10.5z", viewBox: "0 0 12 12" };
64
+ }
65
+ }
66
+
67
+ export function seriesMarker(colorIndex: number, variant: SeriesVariant): {
68
+ readonly path: string;
69
+ readonly viewBox: string;
70
+ readonly fill: string;
71
+ readonly stroke: string;
72
+ } {
73
+ const shape = seriesMarkerShape(variant);
74
+ const color = seriesColorVar(colorIndex);
75
+ return {
76
+ path: shape.path,
77
+ viewBox: shape.viewBox,
78
+ fill: color,
79
+ stroke: color,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * 从 presentation.fill 反推 HTML 柱需要的系列 class。
85
+ * SVG 用 url(#pattern)/var();HTML 柱不能引用 SVG pattern,改挂 series-cN + fill-vN 类,
86
+ * 由 styles.css 用 repeating-linear-gradient 画等效图案,颜色仍走 --series 令牌。
87
+ */
88
+ export function seriesClassesFromFill(fill: string): string {
89
+ const pattern = /^url\(#niceeval-series-pat-v([2-4])-c([1-6])\)$/.exec(fill);
90
+ if (pattern) {
91
+ const variant = pattern[1]!;
92
+ const colorIndex = Number(pattern[2]);
93
+ return `niceeval-series-c${colorIndex - 1} niceeval-series-fill-v${variant}`;
94
+ }
95
+ const solid = /^var\(--niceeval-color-series-([1-6])\)$/.exec(fill);
96
+ if (solid) {
97
+ return `niceeval-series-c${Number(solid[1]) - 1}`;
98
+ }
99
+ return "niceeval-series-none";
100
+ }
101
+
102
+ /** 从 color 呈现或 series 的 stroke/fill 反推 series-cN 类(图例色点回落)。 */
103
+ export function seriesClassFromColorVar(color: string): string {
104
+ const solid = /^var\(--niceeval-color-series-([1-6])\)$/.exec(color);
105
+ if (solid) return `niceeval-series-c${Number(solid[1]) - 1}`;
106
+ return "niceeval-series-none";
107
+ }
108
+
109
+ /**
110
+ * 页内注入一次的 SVG pattern defs。
111
+ * 18 个 pattern(3 非实心变体 × 6 色);id 与 seriesFill() 产出的 url(#…) 对齐。
112
+ * 子元素直接写 var(--niceeval-color-series-N),不走 currentColor
113
+ * (SVG pattern 内部 currentColor 取自 pattern 自身,引用者传不进来)。
114
+ */
115
+ export function SeriesPatternDefs(): ReactNode {
116
+ const patterns: ReactNode[] = [];
117
+ for (let colorIndex = 1; colorIndex <= 6; colorIndex++) {
118
+ const color = seriesColorVar(colorIndex);
119
+ // v2:对角斜线
120
+ patterns.push(
121
+ <pattern
122
+ key={`v2-c${colorIndex}`}
123
+ id={seriesPatternId(colorIndex, 2)}
124
+ patternUnits="userSpaceOnUse"
125
+ width="6"
126
+ height="6"
127
+ >
128
+ <rect width="6" height="6" fill={color} fillOpacity={0.22} />
129
+ <path
130
+ d="M-1 1l2-2M0 6l6-6M5 7l2-2"
131
+ stroke={color}
132
+ strokeWidth={1.4}
133
+ fill="none"
134
+ />
135
+ </pattern>,
136
+ );
137
+ // v3:水平条纹
138
+ patterns.push(
139
+ <pattern
140
+ key={`v3-c${colorIndex}`}
141
+ id={seriesPatternId(colorIndex, 3)}
142
+ patternUnits="userSpaceOnUse"
143
+ width="6"
144
+ height="6"
145
+ >
146
+ <rect width="6" height="6" fill={color} fillOpacity={0.18} />
147
+ <path d="M0 1.5h6M0 4.5h6" stroke={color} strokeWidth={1.5} fill="none" />
148
+ </pattern>,
149
+ );
150
+ // v4:点阵
151
+ patterns.push(
152
+ <pattern
153
+ key={`v4-c${colorIndex}`}
154
+ id={seriesPatternId(colorIndex, 4)}
155
+ patternUnits="userSpaceOnUse"
156
+ width="6"
157
+ height="6"
158
+ >
159
+ <rect width="6" height="6" fill={color} fillOpacity={0.16} />
160
+ <circle cx="2" cy="2" r="1.15" fill={color} />
161
+ <circle cx="5" cy="5" r="1.15" fill={color} />
162
+ </pattern>,
163
+ );
164
+ }
165
+ return (
166
+ <svg
167
+ className="niceeval-series-defs"
168
+ width={0}
169
+ height={0}
170
+ aria-hidden="true"
171
+ focusable="false"
172
+ style={{ position: "absolute", width: 0, height: 0, overflow: "hidden" }}
173
+ >
174
+ <defs>{patterns}</defs>
175
+ </svg>
176
+ );
177
+ }
@@ -110,6 +110,41 @@
110
110
  .niceeval-report .niceeval-series-c5 { --series: var(--c5); }
111
111
  .niceeval-report .niceeval-series-none { --series: var(--muted); }
112
112
 
113
+ /*
114
+ * HTML 横向柱的 variant 填充图案(CSS 等效 SVG <pattern>)。
115
+ * variant 1 = 实心(默认 .niceeval-chart-bar-fill);2 对角斜线 / 3 水平条纹 / 4 点阵。
116
+ * 颜色始终读 --series,换主题令牌自动换色,不写死 hex。
117
+ */
118
+ .niceeval-report .niceeval-series-fill-v2 {
119
+ background-color: transparent;
120
+ background-image: repeating-linear-gradient(
121
+ -45deg,
122
+ color-mix(in srgb, var(--series) 78%, var(--panel)),
123
+ color-mix(in srgb, var(--series) 78%, var(--panel)) 2px,
124
+ color-mix(in srgb, var(--series) 28%, var(--panel)) 2px,
125
+ color-mix(in srgb, var(--series) 28%, var(--panel)) 5px
126
+ );
127
+ }
128
+ .niceeval-report .niceeval-series-fill-v3 {
129
+ background-color: transparent;
130
+ background-image: repeating-linear-gradient(
131
+ 0deg,
132
+ color-mix(in srgb, var(--series) 78%, var(--panel)),
133
+ color-mix(in srgb, var(--series) 78%, var(--panel)) 2px,
134
+ color-mix(in srgb, var(--series) 28%, var(--panel)) 2px,
135
+ color-mix(in srgb, var(--series) 28%, var(--panel)) 5px
136
+ );
137
+ }
138
+ .niceeval-report .niceeval-series-fill-v4 {
139
+ background-color: color-mix(in srgb, var(--series) 28%, var(--panel));
140
+ background-image: radial-gradient(
141
+ circle at 25% 25%,
142
+ color-mix(in srgb, var(--series) 88%, var(--panel)) 1.2px,
143
+ transparent 1.3px
144
+ );
145
+ background-size: 6px 6px;
146
+ }
147
+
113
148
  /* ---- 表格通用:圆角 panel、细分隔线、tabular-nums 数字、uppercase 小标签 ---- */
114
149
  table.niceeval-report,
115
150
  .niceeval-report table {
@@ -1183,12 +1218,19 @@ table.niceeval-report,
1183
1218
  align-items: center;
1184
1219
  gap: 5px;
1185
1220
  }
1186
- .niceeval-chart .niceeval-chart-legend-item::before {
1187
- content: "";
1188
- width: 6px;
1189
- height: 6px;
1221
+ /* 图例色块由 renderer 显式输出(含 variant 图案 / 线型 / marker),不再用 ::before 单色方块。 */
1222
+ .niceeval-chart .niceeval-chart-legend-swatch {
1223
+ display: inline-block;
1224
+ width: 10px;
1225
+ height: 10px;
1226
+ flex: 0 0 auto;
1190
1227
  border-radius: var(--radius);
1191
- background: var(--series);
1228
+ background: color-mix(in srgb, var(--series) 74%, var(--panel));
1229
+ }
1230
+ .niceeval-chart .niceeval-chart-legend-swatch-svg {
1231
+ display: block;
1232
+ flex: 0 0 auto;
1233
+ overflow: visible;
1192
1234
  }
1193
1235
  .niceeval-chart .niceeval-chart-missing {
1194
1236
  max-width: 880px;
@@ -18,7 +18,7 @@ import type { AttemptHandle, Sample, SampleIssue, Run } from "../../record/index
18
18
  import { attemptHandleOf, scopeOf } from "./scope.harness.ts";
19
19
  import { makeSample } from "../../sample/index.ts";
20
20
  import { encodeAttemptLocator } from "../../record/locator.ts";
21
- import { experimentListContent } from "./entity-lists/content.ts";
21
+ import { COVERAGE_ROW_PREFIX, experimentListContent } from "./entity-lists/content.ts";
22
22
  import { formatCellText, type Cell } from "../definition/cell.ts";
23
23
  import type { Record } from "../../record/types.ts";
24
24
  import {
@@ -844,10 +844,16 @@ describe("实体列表 data", () => {
844
844
  ]);
845
845
  }
846
846
 
847
+ /** experiment 行的覆盖构成副行(subRows 末尾,key 以 `coverage:` 起头)的 record 格。 */
848
+ function coverageCellOf(content: ReturnType<typeof experimentListContent>, experimentId: string): Cell {
849
+ const row = content.rows[0]!.subRows!.find((r) => r.key === `${COVERAGE_ROW_PREFIX}${experimentId}`)!;
850
+ return row.cells.record!;
851
+ }
852
+
847
853
  it("四段互斥且合计等于已知题数;一题同时有新执行与携带 attempt 时只落新执行段", async () => {
848
854
  const [item] = await experimentListData(compositionScope());
849
855
  const content = experimentListContent([item!]);
850
- const coverageCell = content.rows[0]!.cells.coverage;
856
+ const coverageCell = coverageCellOf(content, "exp/composition");
851
857
  if (coverageCell.kind !== "composition") throw new Error("expected composition cell");
852
858
  const byLabel = new Map(
853
859
  coverageCell.segments.map((segment) => [
@@ -867,7 +873,7 @@ describe("实体列表 data", () => {
867
873
  const s = snap({ experimentId: "exp/all-fresh", results: [res("q", "passed")] });
868
874
  const [item] = await experimentListData([s]);
869
875
  const content = experimentListContent([item!]);
870
- const coverageCell = content.rows[0]!.cells.coverage;
876
+ const coverageCell = coverageCellOf(content, "exp/all-fresh");
871
877
  if (coverageCell.kind !== "composition") throw new Error("expected composition cell");
872
878
  expect(coverageCell.segments.filter((s) => s.count > 0)).toHaveLength(1);
873
879
  expect(formatCellText(coverageCell, "en")).toBe("1 fresh");
@@ -877,7 +883,7 @@ describe("实体列表 data", () => {
877
883
  it("段名走 LocalizedText:zh-CN 与 en 取同一份 segments,只有文案不同,计数与段序逐字相同", async () => {
878
884
  const [item] = await experimentListData(compositionScope());
879
885
  const content = experimentListContent([item!]);
880
- const coverageCell = content.rows[0]!.cells.coverage;
886
+ const coverageCell = coverageCellOf(content, "exp/composition");
881
887
  if (coverageCell.kind !== "composition") throw new Error("expected composition cell");
882
888
  const en = formatCellText(coverageCell, "en");
883
889
  const zh = formatCellText(coverageCell, "zh-CN");
@@ -886,13 +892,13 @@ describe("实体列表 data", () => {
886
892
  expect(coverageCell.segments.map((s) => s.count)).toEqual([1, 1, 1, 1]);
887
893
  });
888
894
 
889
- it("构成格不携带业务语义:Eval / Attempt 行没有这一格(notApplicable),换一套无关段名照常渲染", async () => {
895
+ it("构成格不携带业务语义:它是 experiment 副行独有的格,换一套无关段名照常渲染,渲染面不认识段含义", async () => {
890
896
  const [item] = await experimentListData(compositionScope());
891
897
  const content = experimentListContent([item!]);
892
- const evalRow = content.rows[0]!.subRows!.find((row) => row.key === "retry")!;
893
- expect(evalRow.cells.coverage).toEqual({ kind: "notApplicable" });
894
- const attemptRow = evalRow.subRows![0]!;
895
- expect(attemptRow.cells.coverage).toEqual({ kind: "notApplicable" });
898
+ // 副行紧跟在 Eval / 组行之后,key 前缀把它与题目行区分开——它不是又一道 "retry" 之类的题。
899
+ const rowKeys = content.rows[0]!.subRows!.map((r) => r.key);
900
+ expect(rowKeys.at(-1)).toBe(`${COVERAGE_ROW_PREFIX}exp/composition`);
901
+ expect(rowKeys).toContain("retry");
896
902
 
897
903
  const arbitrarySegments: Cell = {
898
904
  kind: "composition",
@@ -18,7 +18,6 @@ import type {
18
18
  } from "../../model/types.ts";
19
19
  import type { EvalResult } from "../../../types.ts";
20
20
  import type { AttemptHandle, Run } from "../../../record/types.ts";
21
- import { encodeAttemptLocator } from "../../../record/locator.ts";
22
21
  import { comparabilityConfigOf, deepEqualJson } from "../../../sample/index.ts";
23
22
  import { foldEvalVerdict } from "../../../shared/verdict.ts";
24
23
  import {
@@ -30,7 +29,9 @@ import {
30
29
  groupItems,
31
30
  historicalOf,
32
31
  locatorOf,
32
+ msSince,
33
33
  resolveInput,
34
+ staleReferenceOf,
34
35
  type Item,
35
36
  } from "../../model/aggregate.ts";
36
37
  import { attemptCostUSD, costUSD, durationMs, examScore, passRate, tokens, totalScore } from "../../model/metrics.ts";
@@ -95,16 +96,11 @@ async function attemptListItemOf(item: Item): Promise<AttemptListItem> {
95
96
  costUSD: attemptCostUSD(result),
96
97
  startedAt,
97
98
  historical,
98
- ...(historical ? { staleSinceMs: staleSinceMsOf(startedAt) } : {}),
99
+ ...(historical ? { staleSinceMs: msSince(startedAt) } : {}),
99
100
  locator: locatorOf(item),
100
101
  };
101
102
  }
102
103
 
103
- /** 一个 ISO 时刻距渲染时刻(`Date.now()`)的毫秒数,恒不小于 0。 */
104
- function staleSinceMsOf(startedAtIso: string): number {
105
- return Math.max(0, Date.now() - Date.parse(startedAtIso));
106
- }
107
-
108
104
  /**
109
105
  * 「只看新执行」开关在场的判据(docs/feature/reports/components/summaries/experiment-table.md
110
106
  * 「只看新执行」):Sample 里既没有历史执行也没有过期结论时不画开关——一个永远不改变行集的
@@ -238,18 +234,8 @@ function staleReferencesFor(
238
234
  }
239
235
  const out: globalThis.Record<string, StaleConclusionReference> = {};
240
236
  for (const [evalId, candidates] of candidatesByEval) {
241
- const newest = candidates.reduce((a, b) =>
242
- (a.result.startedAt ?? "") >= (b.result.startedAt ?? "") ? a : b,
243
- );
244
- const startedAt = newest.result.startedAt;
245
- if (!startedAt) continue; // 无时刻的 legacy 落盘算不出时距,不伪造参考
246
- out[evalId] = {
247
- locator:
248
- newest.locator ??
249
- encodeAttemptLocator({ runId: newest.run.runId, evalId: newest.evalId, attempt: newest.result.attempt }),
250
- verdict: newest.result.verdict,
251
- staleSinceMs: staleSinceMsOf(startedAt),
252
- };
237
+ const reference = staleReferenceOf(candidates);
238
+ if (reference) out[evalId] = reference;
253
239
  }
254
240
  return out;
255
241
  }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { describe, expect, it } from "vitest";
8
8
  import type { AttemptListItem, ExperimentListEvalRow, ExperimentListItem, MetricValue } from "../../model/types.ts";
9
- import { attemptListContent, evalListContent, experimentListContent } from "./content.ts";
9
+ import { attemptListContent, COVERAGE_ROW_PREFIX, evalListContent, experimentListContent } from "./content.ts";
10
10
  import type { Cell, TableContentRow } from "../../definition/cell.ts";
11
11
  import { resolveLocalizedText } from "../../model/locale.ts";
12
12
  import type { AttemptLocator } from "../../../record/locator.ts";
@@ -105,6 +105,11 @@ function entityText(cell: Cell | undefined): string {
105
105
  return "";
106
106
  }
107
107
 
108
+ /** experiment 行的 subRows 排除末尾的覆盖构成副行,只留 Eval / 组行(Eval 分组层的断言面)。 */
109
+ function realSubRows(row: TableContentRow): TableContentRow[] {
110
+ return (row.subRows ?? []).filter((r) => !r.key.startsWith(COVERAGE_ROW_PREFIX));
111
+ }
112
+
108
113
  describe("experimentListContent Eval 分组层", () => {
109
114
  it("按目录前缀分区:组行带聚合读数,子行去掉前缀但 key 仍是完整 evalId", () => {
110
115
  const content = experimentListContent([
@@ -118,7 +123,7 @@ describe("experimentListContent Eval 分组层", () => {
118
123
  missingEvalIds: [],
119
124
  }),
120
125
  ]);
121
- const sub = content.rows[0]!.subRows!;
126
+ const sub = realSubRows(content.rows[0]!);
122
127
  expect(sub.map((row) => row.variant)).toEqual(["group", "group"]);
123
128
  expect(sub.map((row) => row.key)).toEqual(["group:downshift", "group:weather"]);
124
129
  // 两组通过率都是 50%,按 groupKey 字典序收口
@@ -145,7 +150,7 @@ describe("experimentListContent Eval 分组层", () => {
145
150
  missingEvalIds: [],
146
151
  }),
147
152
  ]);
148
- const sub = content.rows[0]!.subRows!;
153
+ const sub = realSubRows(content.rows[0]!);
149
154
  expect(sub.every((row) => row.variant !== "group")).toBe(true);
150
155
  expect(sub.map((row) => row.key)).toEqual(["algebra/retry", "algebra/simple"]);
151
156
  expect(sub.map((row) => entityText(row.cells.entity))).toEqual(["algebra/retry", "algebra/simple"]);
@@ -161,7 +166,7 @@ describe("experimentListContent Eval 分组层", () => {
161
166
  missingEvalIds: [],
162
167
  }),
163
168
  ]);
164
- const sub = content.rows[0]!.subRows!;
169
+ const sub = realSubRows(content.rows[0]!);
165
170
  expect(sub.every((row) => row.variant !== "group")).toBe(true);
166
171
  expect(sub.map((row) => entityText(row.cells.entity))).toEqual(["algebra/retry", "weather/tool"]);
167
172
  });
@@ -177,7 +182,7 @@ describe("experimentListContent Eval 分组层", () => {
177
182
  missingEvalIds: [],
178
183
  }),
179
184
  ]);
180
- const sub = content.rows[0]!.subRows!;
185
+ const sub = realSubRows(content.rows[0]!);
181
186
  expect(sub.map((row) => row.key)).toEqual(["group:downshift", "standalone"]);
182
187
  expect(sub[0]!.variant).toBe("group");
183
188
  expect(sub[1]!.variant).toBeUndefined();
@@ -197,7 +202,7 @@ describe("experimentListContent Eval 分组层", () => {
197
202
  missingEvalIds: ["weather/gap", "ghost/a", "ghost/b"],
198
203
  }),
199
204
  ]);
200
- const sub = content.rows[0]!.subRows!;
205
+ const sub = realSubRows(content.rows[0]!);
201
206
  // weather 有 2 道实题 + 1 占位 → 保留组;ghost 两道全缺失也保留组
202
207
  expect(sub.map((row) => row.key)).toEqual(["group:weather", "group:ghost"]);
203
208
 
@@ -295,7 +300,7 @@ describe("experimentListContent Eval 分组层", () => {
295
300
  missingEvalIds: [],
296
301
  }),
297
302
  ]);
298
- const sub = content.rows[0]!.subRows!;
303
+ const sub = realSubRows(content.rows[0]!);
299
304
  // 顶层只有 pkg → 剥壳;下层 sub/other 各两题 → 插组
300
305
  expect(sub.map((row) => row.key)).toEqual(["group:pkg/other", "group:pkg/sub"]);
301
306
  expect(sub.map((row) => entityText(row.cells.entity))).toEqual(["other (2 evals)", "sub (2 evals)"]);
@@ -314,7 +319,7 @@ describe("experimentListContent Eval 分组层", () => {
314
319
  missingEvalIds: [],
315
320
  }),
316
321
  ]);
317
- const sub = content.rows[0]!.subRows!;
322
+ const sub = realSubRows(content.rows[0]!);
318
323
  expect(sub.every((row) => row.variant !== "group")).toBe(true);
319
324
  expect(sub.map((row) => entityText(row.cells.entity))).toEqual(["111/111/aaa", "111/111/bbb"]);
320
325
  });
@@ -37,7 +37,6 @@ const HEADER = {
37
37
  verdict: localizedMessage("experimentList.status"),
38
38
  result: localizedMessage("experimentList.result"),
39
39
  score: localizedMessage("experimentList.totalScore"),
40
- coverage: localizedMessage("experimentList.coverage"),
41
40
  };
42
41
 
43
42
  /**
@@ -479,13 +478,35 @@ function coverageSegments(item: ExperimentListItem): { label: LocalizedText; cou
479
478
  }));
480
479
  }
481
480
 
482
- /** experiment evalRows + missingEvalIds 递归嵌套的 subRows。 */
481
+ /** 覆盖构成副行的 key 前缀;测试与消费方靠它把这一行从 Eval / 组行里筛出去。 */
482
+ export const COVERAGE_ROW_PREFIX = "coverage:";
483
+
484
+ /**
485
+ * 覆盖构成副行(docs/feature/reports/components/summaries/experiment-table.md「覆盖构成」):
486
+ * experiment 行 subRows 的最后一条,把已知题按结论出身分成四段互斥的构成格,交给中立的
487
+ * `composition` 格——渲染在同一个 `record` 列位置(与 Eval / Attempt 行的判定构成、占位行的
488
+ * missing 格同一个槽位,三种形态各自对应不同的行语义,不是同一行的三种读法)。
489
+ * 它不是 Eval / 组行,没有身份(entity 是 notApplicable),不参与嵌套排序或收起判定。
490
+ */
491
+ function coverageRow(item: ExperimentListItem, view: HierarchyView): TableContentRow {
492
+ const bag: CellBag = {
493
+ entity: { kind: "notApplicable" },
494
+ record: { kind: "composition", segments: coverageSegments(item) },
495
+ };
496
+ return {
497
+ key: `${COVERAGE_ROW_PREFIX}${item.experimentId}`,
498
+ cells: projectCells(bag, view.columns),
499
+ };
500
+ }
501
+
502
+ /** experiment 的 evalRows + missingEvalIds → 递归嵌套的 subRows,末尾追加覆盖构成副行。 */
483
503
  function experimentSubRows(item: ExperimentListItem, view: HierarchyView): TableContentRow[] {
484
504
  const members: LeafMember[] = [
485
505
  ...item.evalRows.map((row): LeafMember => ({ kind: "eval", row })),
486
506
  ...item.missingEvalIds.map((evalId): LeafMember => ({ kind: "missing", evalId })),
487
507
  ];
488
- return nestLevel(members, "", "", item, view);
508
+ const nested = nestLevel(members, "", "", item, view);
509
+ return members.length > 0 ? [...nested, coverageRow(item, view)] : nested;
489
510
  }
490
511
 
491
512
  function experimentRow(item: ExperimentListItem, view: HierarchyView): TableContentRow {
@@ -499,9 +520,6 @@ function experimentRow(item: ExperimentListItem, view: HierarchyView): TableCont
499
520
  tokens: measureCell(item.tokens),
500
521
  costUSD: measureCell(item.costUSD),
501
522
  record: verdictCell(item.evalVerdicts),
502
- // 覆盖构成是 experiment 这一行独有的事实(experiment-table.md「覆盖构成」),Eval / Attempt /
503
- // 路径段组行没有这一格——projectCells 按列集自动填 notApplicable,不额外分支。
504
- coverage: { kind: "composition", segments: coverageSegments(item) },
505
523
  };
506
524
  return {
507
525
  key: item.experimentId,
@@ -522,7 +540,6 @@ function experimentColumns(composition: EvaluationKindComposition): ColumnSpec[]
522
540
  { key: "tokens", better: "lower", header: HEADER.tokens },
523
541
  { key: "costUSD", better: "lower", header: HEADER.costUSD },
524
542
  { key: "record", header: HEADER.record },
525
- { key: "coverage", header: HEADER.coverage },
526
543
  ];
527
544
  }
528
545
 
@@ -37,6 +37,10 @@ export type Cell =
37
37
  readonly refs?: readonly AttemptLocator[];
38
38
  // 两种形态:counts = 判定构成计票(experiment / Eval 行);verdict = 单判定(attempt 行)。
39
39
  // 格子只带值,计票怎么来的(折叠、分桶)在实体投影侧,渲染面不算数。
40
+ /** 单判定形态历史执行的距今毫秒数;新执行时省略,不伪造 0(与 locator 格同一条纪律)。 */
41
+ readonly staleSinceMs?: number;
42
+ /** 单判定形态省略判定词、只留判定符(如对照矩阵逐格只放得下一个符号的场景)。 */
43
+ readonly bare?: boolean;
40
44
  }
41
45
  | { readonly kind: "score"; readonly earned: number; readonly possible?: number }
42
46
  | { readonly kind: "summary"; readonly text: string; readonly more?: number }
@@ -138,8 +142,11 @@ export function formatCellText(cell: Cell | null | undefined, locale?: ReportLoc
138
142
  }
139
143
  if (cell.verdict !== undefined) {
140
144
  const v = cell.verdict === "skipped" ? "skipped" : cell.verdict;
145
+ const stale = cell.staleSinceMs !== undefined ? ` ${formatTimeDistance(cell.staleSinceMs, loc)}` : "";
141
146
  // 判定符与判定词同场,与 locator 格、web 面同一条纪律:单色打印下照样读得出。
142
- return `${verdictMark(v)} ${localeText(loc, `verdict.${v}`)}`;
147
+ // bare 省略判定词,只留判定符(+ 可选时距),供逐格空间紧张的场景(如对照矩阵)使用。
148
+ if (cell.bare) return `${verdictMark(v)}${stale}`;
149
+ return `${verdictMark(v)} ${localeText(loc, `verdict.${v}`)}${stale}`;
143
150
  }
144
151
  return "—";
145
152
  }