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
@@ -2,9 +2,9 @@
2
2
  // 页级呈现分配(两个 keyset、槽位分配、容量拒绝)、`dimensions` 必填与查询封闭性、text 面
3
3
  // 降级、外壳 `dimensionPins` 的占位语义,以及公开 helper `presentDimension` 与页内分配同源。
4
4
  //
5
- // 断言面是映射本身(值 → 标签 / seriesSlot / 色板下标 / 形状变体)与抛出的错误对象,不断言
6
- // 渲染出的颜色值;需要证明「两个 renderer 都跑到」时才走 renderReportToText /
7
- // renderReportToStaticHtml,断言的仍是探针组件打印出的映射文本。
5
+ // 断言面是映射本身(值 → 标签 / seriesSlot / 色板通道 / 可直接使用的 fill·stroke·marker)
6
+ // 抛出的错误对象,不断言渲染出的像素色;需要证明「两个 renderer 都跑到」时才走
7
+ // renderReportToText / renderReportToStaticHtml,断言的仍是探针组件打印出的映射文本。
8
8
 
9
9
  import { describe, expect, it } from "vitest";
10
10
 
@@ -24,14 +24,17 @@ import {
24
24
  VISUAL_SLOT_COUNT,
25
25
  type DimensionEncoding,
26
26
  type DimensionPins,
27
+ type DimensionPresentation,
27
28
  type PresentedDimension,
28
29
  } from "./presentation.ts";
30
+ import { seriesFill, seriesStrokeDasharray } from "./assets/series-encoding.tsx";
29
31
  import { renderReportToText } from "./runtime/text.ts";
30
32
  import { renderReportToStaticHtml } from "./runtime/web.ts";
31
33
 
32
34
  // ───────────────────────── 探针组件 ─────────────────────────
33
35
 
34
36
  const SERIES: DimensionEncoding = { kind: "series", mark: "line" };
37
+ const FILL: DimensionEncoding = { kind: "series", mark: "bar" };
35
38
  const LABEL_ONLY: DimensionEncoding = { kind: "label" };
36
39
 
37
40
  interface ProbeProps {
@@ -44,12 +47,59 @@ interface ProbeProps {
44
47
  query?: string;
45
48
  }
46
49
 
50
+ /** 从呈现值还原槽位通道,便于探针打印与槽位断言同源。 */
51
+ function channelsOf(at: DimensionPresentation): { colorIndex: string; variant: string; detail: string } {
52
+ if (at.kind === "label") return { colorIndex: "-", variant: "-", detail: "label" };
53
+ if (at.kind === "color") {
54
+ const m = /^var\(--niceeval-color-series-([1-6])\)$/.exec(at.color);
55
+ return { colorIndex: m?.[1] ?? "?", variant: "1", detail: at.color };
56
+ }
57
+ if (at.kind === "series" && at.mark === "line") {
58
+ // 反查 24 槽里哪个 (color, variant) 产出这份 stroke + dash
59
+ for (let slot = 1; slot <= VISUAL_SLOT_COUNT; slot++) {
60
+ const ch = seriesChannelsOf(slot);
61
+ if (
62
+ at.stroke === `var(--niceeval-color-series-${ch.colorIndex})` &&
63
+ at.strokeDasharray === seriesStrokeDasharray(ch.variant)
64
+ ) {
65
+ return { colorIndex: String(ch.colorIndex), variant: String(ch.variant), detail: at.strokeDasharray || "solid" };
66
+ }
67
+ }
68
+ return { colorIndex: "?", variant: "?", detail: at.stroke };
69
+ }
70
+ if (at.kind === "series" && (at.mark === "bar" || at.mark === "area")) {
71
+ for (let slot = 1; slot <= VISUAL_SLOT_COUNT; slot++) {
72
+ const ch = seriesChannelsOf(slot);
73
+ if (at.fill === seriesFill(ch.colorIndex, ch.variant)) {
74
+ return { colorIndex: String(ch.colorIndex), variant: String(ch.variant), detail: at.fill };
75
+ }
76
+ }
77
+ return { colorIndex: "?", variant: "?", detail: at.fill };
78
+ }
79
+ if (at.kind === "series" && at.mark === "scatter") {
80
+ for (let slot = 1; slot <= VISUAL_SLOT_COUNT; slot++) {
81
+ const ch = seriesChannelsOf(slot);
82
+ if (at.marker.fill === `var(--niceeval-color-series-${ch.colorIndex})`) {
83
+ // scatter 同色四 variant 靠 path 区分;用 path 末段粗判
84
+ return { colorIndex: String(ch.colorIndex), variant: String(ch.variant), detail: at.marker.path.slice(0, 12) };
85
+ }
86
+ }
87
+ }
88
+ return { colorIndex: "?", variant: "?", detail: "?" };
89
+ }
90
+
47
91
  /** `值|标签|槽|色板下标|形状变体`,槽面缺席时打 `-`——两个 renderer 打同一行,好逐字比。 */
48
- function probeLine(props: ProbeProps, presented: PresentedDimension): string {
92
+ function probeLine(
93
+ props: ProbeProps,
94
+ presented: PresentedDimension,
95
+ slots?: ReadonlyMap<string, number>,
96
+ ): string {
49
97
  return props.values
50
- .map((_value, index) => {
98
+ .map((value, index) => {
51
99
  const at = presented.at(index);
52
- return `${at.value}|${at.label}|${at.seriesSlot ?? "-"}|${at.colorIndex ?? "-"}|${at.variant ?? "-"}`;
100
+ const slot = slots?.get(value);
101
+ const ch = channelsOf(at);
102
+ return `${at.value}|${at.label}|${slot ?? "-"}|${ch.colorIndex}|${ch.variant}`;
53
103
  })
54
104
  .join(";");
55
105
  }
@@ -63,7 +113,26 @@ const Probe = defineComponent<ProbeProps>({
63
113
  },
64
114
  }),
65
115
  text: (props, ctx) => probeLine(props, ctx.dimension(props.query ?? props.handle ?? "keys")),
66
- web: (props, ctx) => <span data-probe={probeLine(props, ctx.dimension(props.query ?? props.handle ?? "keys"))} />,
116
+ web: (props, ctx) => {
117
+ // web 探针从页级 plan 拿槽位,呈现值本身不再暴露 seriesSlot。
118
+ const presented = ctx.dimension(props.query ?? props.handle ?? "keys");
119
+ // slots 只能从 at 反推的 color/variant 对不上唯一槽时(多值同通道不该发生)——
120
+ // 这里用 channels 反查唯一槽:24 组 (color,variant) 两两不同。
121
+ const slots = new Map<string, number>();
122
+ props.values.forEach((value, index) => {
123
+ const at = presented.at(index);
124
+ if (at.kind === "label") return;
125
+ for (let slot = 1; slot <= VISUAL_SLOT_COUNT; slot++) {
126
+ const ch = seriesChannelsOf(slot);
127
+ const recovered = channelsOf(at);
128
+ if (recovered.colorIndex === String(ch.colorIndex) && recovered.variant === String(ch.variant)) {
129
+ slots.set(value, slot);
130
+ break;
131
+ }
132
+ }
133
+ });
134
+ return <span data-probe={probeLine(props, presented, slots)} />;
135
+ },
67
136
  });
68
137
  Probe.displayName = "Probe";
69
138
 
@@ -75,12 +144,12 @@ function elementOf(node: unknown): ReportElement {
75
144
  function slotsOf(node: ReportNode, element: ReportElement, pins: DimensionPins = {}): Map<string, number> {
76
145
  const plan = collectPageDimensions(node, pins, "web");
77
146
  const props = element.props as unknown as ProbeProps;
78
- const presented = plan.dimension(element.props, props.handle ?? "keys");
147
+ const byValue = plan.slotsByDimension.get(props.dimension) ?? new Map();
79
148
  const out = new Map<string, number>();
80
- props.values.forEach((value, index) => {
81
- const slot = presented.at(index).seriesSlot;
149
+ for (const value of props.values) {
150
+ const slot = byValue.get(value);
82
151
  if (slot !== undefined) out.set(value, slot);
83
- });
152
+ }
84
153
  return out;
85
154
  }
86
155
 
@@ -95,11 +164,11 @@ describe("页级呈现分配", () => {
95
164
  const plan = collectPageDimensions([table, chart], {}, "web");
96
165
 
97
166
  const tableKeys = plan.dimension(table.props, "keys");
98
- expect(tableKeys.at(0)).toEqual({ value: "alpha", label: "alpha" });
99
- expect(tableKeys.at(1).seriesSlot).toBeUndefined();
167
+ expect(tableKeys.at(0)).toEqual({ kind: "label", value: "alpha", label: "alpha" });
168
+ expect(tableKeys.at(1).kind).toBe("label");
100
169
 
101
170
  const chartKeys = plan.dimension(chart.props, "keys");
102
- expect(chartKeys.at(0).seriesSlot).toBeGreaterThanOrEqual(1);
171
+ expect(chartKeys.at(0).kind).toBe("series");
103
172
  // label keyset 收了三个值,visual keyset 只有 gamma —— 标签面两组都在
104
173
  expect([...chartKeys.labels.keys()].sort()).toEqual(["alpha", "beta", "gamma"]);
105
174
  });
@@ -116,11 +185,12 @@ describe("页级呈现分配", () => {
116
185
  const chart = elementOf(<Probe dimension="agent" values={chartValues} />);
117
186
  const plan = collectPageDimensions([table, chart], {}, "web");
118
187
  const chartKeys = plan.dimension(chart.props, "keys");
188
+ const slots = plan.slotsByDimension.get("agent")!;
119
189
 
120
190
  // visual keyset 只有 3 个成员:容量按 3 算,不按 30 算
121
- const slots = chartValues.map((_v, i) => chartKeys.at(i).seriesSlot!);
122
- expect(new Set(slots).size).toBe(3);
123
- expect(slots.every((slot) => slot >= 1 && slot <= VISUAL_SLOT_COUNT)).toBe(true);
191
+ const slotList = chartValues.map((v) => slots.get(v)!);
192
+ expect(new Set(slotList).size).toBe(3);
193
+ expect(slotList.every((slot) => slot >= 1 && slot <= VISUAL_SLOT_COUNT)).toBe(true);
124
194
 
125
195
  // label keyset 是 30 个值:one / two / three 三处重名,标签被逼长到两段
126
196
  expect(chartValues.map((_v, i) => chartKeys.at(i).label)).toEqual(["alpha/one", "beta/two", "gamma/three"]);
@@ -132,13 +202,16 @@ describe("页级呈现分配", () => {
132
202
  const chart = elementOf(<Probe dimension="agent" values={["acme/codex", "acme/claude"]} />);
133
203
  const legend = elementOf(<Probe dimension="agent" handle="legend" values={["acme/claude"]} />);
134
204
  const plan = collectPageDimensions([chart, legend], {}, "web");
205
+ const slots = plan.slotsByDimension.get("agent")!;
135
206
 
207
+ expect(slots.get("acme/claude")).toBe(slots.get("acme/claude"));
136
208
  const fromChart = plan.dimension(chart.props, "keys").at(1);
137
209
  const fromLegend = plan.dimension(legend.props, "legend").at(0);
138
- expect(fromLegend.seriesSlot).toBe(fromChart.seriesSlot);
139
210
  expect(fromLegend.label).toBe("claude");
140
211
  // 取键用完整值:缩短后的 "claude" 不是另一个身份
141
212
  expect(fromLegend.value).toBe("acme/claude");
213
+ // 两处呈现同一视觉身份(stroke 相等)
214
+ expect(fromLegend).toEqual(fromChart);
142
215
  });
143
216
 
144
217
  it("24 个值全部落槽且两两不同,24 个 (色, variant) 组合也两两不同", () => {
@@ -158,6 +231,19 @@ describe("页级呈现分配", () => {
158
231
  expect(channels.size).toBe(VISUAL_SLOT_COUNT);
159
232
  });
160
233
 
234
+ it("槽序表:1–6 第一变体、7–12 第二变体,与 docs 一致", () => {
235
+ expect(seriesChannelsOf(1)).toEqual({ colorIndex: 1, variant: 1 });
236
+ expect(seriesChannelsOf(6)).toEqual({ colorIndex: 6, variant: 1 });
237
+ expect(seriesChannelsOf(7)).toEqual({ colorIndex: 1, variant: 2 });
238
+ expect(seriesChannelsOf(14)).toEqual({ colorIndex: 2, variant: 3 });
239
+ expect(seriesChannelsOf(20)).toEqual({ colorIndex: 2, variant: 4 });
240
+ // baseline/obelisk 撞色根因:同 colorIndex 不同 variant,fill 必须可辨
241
+ expect(seriesFill(2, 1)).toBe("var(--niceeval-color-series-2)");
242
+ expect(seriesFill(2, 3)).toBe("url(#niceeval-series-pat-v3-c2)");
243
+ expect(seriesFill(2, 4)).toBe("url(#niceeval-series-pat-v4-c2)");
244
+ expect(seriesFill(2, 3)).not.toBe(seriesFill(2, 4));
245
+ });
246
+
161
247
  it("visual keyset 超过 24 按完整用户反馈拒绝该页,fix 行不提 dimensionPins", () => {
162
248
  const values = Array.from({ length: 27 }, (_, i) => `agent-${i}`);
163
249
  const chart = elementOf(<Probe dimension="agent" values={values} />);
@@ -172,6 +258,40 @@ describe("页级呈现分配", () => {
172
258
  expect(message).toContain("fix: filter the series, or split them into facets/pages");
173
259
  expect(message).not.toContain("dimensionPins");
174
260
  });
261
+
262
+ it("FillSeriesPresentation 按 mark 产出可直接用的 fill(含 pattern url)", () => {
263
+ const chart = elementOf(
264
+ <Probe dimension="memory" encoding={FILL} values={["baseline", "obelisk"]} />,
265
+ );
266
+ const plan = collectPageDimensions(chart, {}, "web");
267
+ const presented = plan.dimension(chart.props, "keys");
268
+ const slots = plan.slotsByDimension.get("memory")!;
269
+ // 复现真实撞色对:同页散列后 baseline=14 / obelisk=20 时 color 同 variant 不同
270
+ for (const value of ["baseline", "obelisk"] as const) {
271
+ // 不强钉槽位,只断言呈现是 fill 支且 fill 可区分
272
+ const at = presented.at(["baseline", "obelisk"].indexOf(value));
273
+ expect(at.kind).toBe("series");
274
+ if (at.kind === "series" && (at.mark === "bar" || at.mark === "area")) {
275
+ expect(at.fill.startsWith("var(") || at.fill.startsWith("url(#niceeval-series-pat-")).toBe(true);
276
+ expect(at.stroke).toMatch(/^var\(--niceeval-color-series-[1-6]\)$/);
277
+ }
278
+ }
279
+ // 若两值恰好同色不同 variant,fill 字符串必须不同
280
+ const a = presented.at(0);
281
+ const b = presented.at(1);
282
+ if (
283
+ a.kind === "series" &&
284
+ b.kind === "series" &&
285
+ (a.mark === "bar" || a.mark === "area") &&
286
+ (b.mark === "bar" || b.mark === "area")
287
+ ) {
288
+ const sa = slots.get("baseline")!;
289
+ const sb = slots.get("obelisk")!;
290
+ if (seriesChannelsOf(sa).colorIndex === seriesChannelsOf(sb).colorIndex) {
291
+ expect(a.fill).not.toBe(b.fill);
292
+ }
293
+ }
294
+ });
175
295
  });
176
296
 
177
297
  // ───────────────────────── dimensions 必填与查询封闭性 ─────────────────────────
@@ -307,8 +427,48 @@ describe("presentDimension", () => {
307
427
  const chart = elementOf(<Probe dimension="agent" values={values} />);
308
428
  const inPage = slotsOf(chart, chart);
309
429
  const standalone = presentDimension({ dimension: "agent", encoding: SERIES, values });
430
+ // standalone 没有暴露 seriesSlot,用 stroke/dash 与页内对照
431
+ for (const [index, value] of values.entries()) {
432
+ const pageSlot = inPage.get(value)!;
433
+ const at = standalone.at(index);
434
+ expect(at.kind).toBe("series");
435
+ if (at.kind === "series" && at.mark === "line") {
436
+ const ch = seriesChannelsOf(pageSlot);
437
+ expect(at.stroke).toBe(`var(--niceeval-color-series-${ch.colorIndex})`);
438
+ expect(at.strokeDasharray).toBe(seriesStrokeDasharray(ch.variant));
439
+ }
440
+ }
441
+ });
442
+ });
443
+
444
+ // ───────────────────────── MemoryBench 可辨性自检 ─────────────────────────
445
+
446
+ describe("MemoryBench leaderboard 五条件可辨", () => {
447
+ it("memory 五值拿到两两可辨的 fill 身份(baseline 与 obelisk 同色不同 variant)", () => {
448
+ const values = ["baseline", "mempal", "nowledge", "obelisk", "remem"] as const;
449
+ const chart = elementOf(<Probe dimension="memory" encoding={FILL} values={values} />);
450
+ const slots = slotsOf(chart, chart);
451
+ const presented = presentDimension({
452
+ dimension: "memory",
453
+ encoding: { kind: "series", mark: "bar" },
454
+ values,
455
+ });
456
+ const fills = new Map<string, string>();
310
457
  for (const [index, value] of values.entries()) {
311
- expect(standalone.at(index).seriesSlot).toBe(inPage.get(value));
458
+ const at = presented.at(index);
459
+ expect(at.kind).toBe("series");
460
+ if (at.kind === "series" && at.mark === "bar") {
461
+ fills.set(value, at.fill);
462
+ }
312
463
  }
464
+ // 五个 fill 两两不同
465
+ expect(new Set(fills.values()).size).toBe(5);
466
+ // 与截图复算一致:baseline slot 14 → c2 v3 pattern;obelisk slot 20 → c2 v4 pattern
467
+ expect(slots.get("baseline")).toBe(14);
468
+ expect(slots.get("obelisk")).toBe(20);
469
+ expect(seriesChannelsOf(14)).toEqual({ colorIndex: 2, variant: 3 });
470
+ expect(seriesChannelsOf(20)).toEqual({ colorIndex: 2, variant: 4 });
471
+ expect(fills.get("baseline")).toBe("url(#niceeval-series-pat-v3-c2)");
472
+ expect(fills.get("obelisk")).toBe("url(#niceeval-series-pat-v4-c2)");
313
473
  });
314
474
  });
@@ -1,4 +1,10 @@
1
1
  import { shortestUniqueLabels as labels } from "./model/format.ts";
2
+ import {
3
+ seriesColorVar,
4
+ seriesFill,
5
+ seriesMarker,
6
+ seriesStrokeDasharray,
7
+ } from "./assets/series-encoding.tsx";
2
8
 
3
9
  export type DimensionEncoding =
4
10
  | { readonly kind: "label" }
@@ -21,7 +27,11 @@ export const VISUAL_SLOT_COUNT = 24;
21
27
 
22
28
  export type SeriesVariant = 1 | 2 | 3 | 4;
23
29
 
24
- /** 槽 1..24 → (色板下标 1..6, 形状变体 1..4),与 README 槽序表逐项对应。 */
30
+ /**
31
+ * 槽 1..24 → (色板下标 1..6, 形状变体 1..4)。
32
+ * 与 components/README「视觉编码容量」槽序表一致:1–6 第一变体、7–12 第二变体,依此类推。
33
+ * `colorIndex = (slot-1) % 6 + 1`,`variant = floor((slot-1)/6) + 1`。
34
+ */
25
35
  export function seriesChannelsOf(slot: number): { colorIndex: number; variant: SeriesVariant } {
26
36
  if (!Number.isInteger(slot) || slot < 1 || slot > VISUAL_SLOT_COUNT) {
27
37
  throw new Error(`seriesSlot must be an integer in [1, ${VISUAL_SLOT_COUNT}], got ${slot}.`);
@@ -29,21 +39,78 @@ export function seriesChannelsOf(slot: number): { colorIndex: number; variant: S
29
39
  const zero = slot - 1;
30
40
  return {
31
41
  colorIndex: (zero % 6) + 1,
32
- variant: ((((zero % 4) + Math.floor(zero / 12)) % 4) + 1) as SeriesVariant,
42
+ variant: (Math.floor(zero / 6) + 1) as SeriesVariant,
33
43
  };
34
44
  }
35
45
 
36
- export interface PresentedValue {
46
+ // ─── docs/feature/reports/library/presentation.md 的呈现家族 ───
47
+
48
+ export interface PresentationIdentity {
49
+ /** 完整维度值,作为排序、筛选、React key 与证据身份。 */
37
50
  readonly value: string;
51
+ /** 当前页完整 label keyset 内生成的显示名。 */
38
52
  readonly label: string;
39
- readonly seriesSlot?: number;
40
- readonly colorIndex?: number;
41
- readonly variant?: SeriesVariant;
42
53
  }
43
54
 
55
+ export interface LabelPresentation extends PresentationIdentity {
56
+ readonly kind: "label";
57
+ }
58
+
59
+ export interface ColorPresentation extends PresentationIdentity {
60
+ readonly kind: "color";
61
+ /** `var(--niceeval-color-series-N)`。 */
62
+ readonly color: string;
63
+ }
64
+
65
+ export interface LineSeriesPresentation extends PresentationIdentity {
66
+ readonly kind: "series";
67
+ readonly mark: "line";
68
+ readonly stroke: string;
69
+ readonly strokeDasharray: string;
70
+ readonly marker: {
71
+ readonly path: string;
72
+ readonly viewBox: string;
73
+ readonly fill: string;
74
+ readonly stroke: string;
75
+ };
76
+ }
77
+
78
+ export interface ScatterSeriesPresentation extends PresentationIdentity {
79
+ readonly kind: "series";
80
+ readonly mark: "scatter";
81
+ readonly marker: LineSeriesPresentation["marker"];
82
+ }
83
+
84
+ export interface FillSeriesPresentation extends PresentationIdentity {
85
+ readonly kind: "series";
86
+ readonly mark: "bar" | "area";
87
+ /** 颜色,或可直接使用的 `url(#pattern-id)`。 */
88
+ readonly fill: string;
89
+ readonly stroke: string;
90
+ readonly strokeDasharray: string;
91
+ }
92
+
93
+ export type DimensionPresentation =
94
+ | LabelPresentation
95
+ | ColorPresentation
96
+ | LineSeriesPresentation
97
+ | ScatterSeriesPresentation
98
+ | FillSeriesPresentation;
99
+
100
+ export type PresentationFor<E extends DimensionEncoding> = E extends { kind: "label" }
101
+ ? LabelPresentation
102
+ : E extends { kind: "color" }
103
+ ? ColorPresentation
104
+ : LineSeriesPresentation | ScatterSeriesPresentation | FillSeriesPresentation;
105
+
106
+ /**
107
+ * 句柄上的呈现面。`at(i)` 返回可直接交给 SVG/CSS 的值;text 面恒为 label 支。
108
+ * `labels` 是本维度整页 label keyset 的最短唯一后缀表,便于探针与调试。
109
+ */
44
110
  export interface PresentedDimension {
111
+ readonly length: number;
45
112
  readonly labels: ReadonlyMap<string, string>;
46
- at(index: number): PresentedValue;
113
+ at(index: number): DimensionPresentation;
47
114
  }
48
115
 
49
116
  export class UndeclaredDimensionValueError extends Error {
@@ -83,6 +150,41 @@ export interface PageDimensionPlan {
83
150
  dimension(handle: string): PresentedDimension;
84
151
  }
85
152
 
153
+ function presentChannels(
154
+ value: string,
155
+ label: string,
156
+ seriesSlot: number,
157
+ encoding: DimensionEncoding,
158
+ ): DimensionPresentation {
159
+ const { colorIndex, variant } = seriesChannelsOf(seriesSlot);
160
+ if (encoding.kind === "color") {
161
+ return { kind: "color", value, label, color: seriesColorVar(colorIndex) };
162
+ }
163
+ if (encoding.kind !== "series") {
164
+ return { kind: "label", value, label };
165
+ }
166
+ const stroke = seriesColorVar(colorIndex);
167
+ const strokeDasharray = seriesStrokeDasharray(variant);
168
+ const marker = seriesMarker(colorIndex, variant);
169
+ switch (encoding.mark) {
170
+ case "line":
171
+ return { kind: "series", mark: "line", value, label, stroke, strokeDasharray, marker };
172
+ case "scatter":
173
+ return { kind: "series", mark: "scatter", value, label, marker };
174
+ case "bar":
175
+ case "area":
176
+ return {
177
+ kind: "series",
178
+ mark: encoding.mark,
179
+ value,
180
+ label,
181
+ fill: seriesFill(colorIndex, variant),
182
+ stroke,
183
+ strokeDasharray,
184
+ };
185
+ }
186
+ }
187
+
86
188
  /**
87
189
  * 页级呈现分配:收集整页 dimensions() 声明,产出 label / visual 两套 keyset 上的映射。
88
190
  * pins 原样占位;未钉键以稳定散列为起点、撞槽按显示键字典序线性探测。
@@ -134,8 +236,9 @@ export function allocatePageDimensions(
134
236
  const values = declaration.values;
135
237
  const encoding = declaration.encoding;
136
238
  byHandle.set(handle, {
239
+ length: values.length,
137
240
  labels: dimLabels,
138
- at(index: number): PresentedValue {
241
+ at(index: number): DimensionPresentation {
139
242
  const value = values[index];
140
243
  if (value === undefined) {
141
244
  throw new UndeclaredDimensionValueError(
@@ -145,8 +248,9 @@ export function allocatePageDimensions(
145
248
  );
146
249
  }
147
250
  const label = dimLabels.get(value) ?? value;
251
+ // text 面恒返回 label 支:不上 ANSI 色,拿不到颜色、线型或 pattern。
148
252
  if (options.face === "text" || encoding.kind === "label") {
149
- return { value, label };
253
+ return { kind: "label", value, label };
150
254
  }
151
255
  const seriesSlot = slots?.get(value);
152
256
  if (seriesSlot === undefined) {
@@ -156,8 +260,7 @@ export function allocatePageDimensions(
156
260
  index,
157
261
  );
158
262
  }
159
- const channels = seriesChannelsOf(seriesSlot);
160
- return { value, label, seriesSlot, colorIndex: channels.colorIndex, variant: channels.variant };
263
+ return presentChannels(value, label, seriesSlot, encoding);
161
264
  },
162
265
  });
163
266
  }
@@ -74,7 +74,17 @@ export type { AttemptEvidence, AttemptEvidenceCapabilities } from "../../record/
74
74
  // 格式化与呈现工具箱(docs/feature/reports/library/presentation.md):自有 React 页面与报告面同实现。
75
75
  export { formatAxisTick, formatInstant, formatMetricValue, formatTimeDistance, missingText } from "../model/format.ts";
76
76
  export { presentDimension, shortestUniqueLabels } from "../presentation.ts";
77
- export type { DimensionDeclaration, DimensionEncoding, PresentedDimension } from "../presentation.ts";
77
+ export type {
78
+ ColorPresentation,
79
+ DimensionDeclaration,
80
+ DimensionEncoding,
81
+ DimensionPresentation,
82
+ FillSeriesPresentation,
83
+ LabelPresentation,
84
+ LineSeriesPresentation,
85
+ PresentedDimension,
86
+ ScatterSeriesPresentation,
87
+ } from "../presentation.ts";
78
88
 
79
89
  // locale(官方组件 chrome 文案的语言;LocalizedText 的按 locale 解析也用它)
80
90
  export {
@@ -27,10 +27,11 @@ import type { DatasetField,
27
27
  ScoreboardData,
28
28
  SeriesInput,
29
29
  StabilityMatrixData,
30
+ StaleConclusionReference,
30
31
  } from "../model/types.ts";
31
32
  import type { JsonValue, Verdict } from "../../types.ts";
32
33
  import type { AttemptLocator } from "../../record/locator.ts";
33
- import type { Run } from "../../record/types.ts";
34
+ import type { AttemptHandle, Run } from "../../record/types.ts";
34
35
  import { comparabilityConfigOf, deepEqualJson } from "../../sample/index.ts";
35
36
  import { foldEvalVerdict } from "../../shared/verdict.ts";
36
37
  import {
@@ -49,10 +50,12 @@ import {
49
50
  groupItems,
50
51
  historicalOf,
51
52
  locatorOf,
53
+ msSince,
52
54
  refDisplayKey,
53
55
  resolveInput,
54
56
  seriesKey,
55
57
  seriesName,
58
+ staleReferenceOf,
56
59
  toColumn,
57
60
  type Item,
58
61
  } from "../model/aggregate.ts";
@@ -565,6 +568,10 @@ async function buildDeltaCell(items: readonly Item[]): Promise<DeltaCell> {
565
568
  const verdict: Verdict = foldEvalVerdict(items.map((item) => ({ verdict: item.attempt.result.verdict })));
566
569
  const refs = new Set<AttemptLocator>();
567
570
  let historical = false;
571
+ // 距今最久的历史执行决定这一格的时距——多个 attempt 折进同一格时,时距说的是「这批结论里
572
+ // 最旧的证据有多旧」,不是最新那条(与 experiment-table 的 attempt 行时距同一份数据来源,
573
+ // 只是这里先跨 attempt 折叠成一格)。
574
+ let staleStartedAt: string | undefined;
568
575
  let scoreSum = 0;
569
576
  let scoreCount = 0;
570
577
  let tokensSum = 0;
@@ -573,7 +580,11 @@ async function buildDeltaCell(items: readonly Item[]): Promise<DeltaCell> {
573
580
  let costCount = 0;
574
581
  for (const item of items) {
575
582
  refs.add(locatorOf(item));
576
- if (historicalOf(item)) historical = true;
583
+ if (historicalOf(item)) {
584
+ historical = true;
585
+ const startedAt = item.attempt.result.startedAt ?? item.run.startedAt;
586
+ if (staleStartedAt === undefined || startedAt < staleStartedAt) staleStartedAt = startedAt;
587
+ }
577
588
  if (evaluationKind === "points") {
578
589
  const value = await evaluateMetric(totalScoreMetric, item.attempt);
579
590
  if (value !== null) {
@@ -602,11 +613,22 @@ async function buildDeltaCell(items: readonly Item[]): Promise<DeltaCell> {
602
613
  ...(tokensCount > 0 ? { totalTokens: tokensSum } : {}),
603
614
  ...(costCount > 0 ? { totalCostUSD: costSum } : {}),
604
615
  historical,
616
+ ...(staleStartedAt !== undefined ? { staleSinceMs: msSince(staleStartedAt) } : {}),
605
617
  };
606
618
  }
607
619
 
620
+ /** 每个 experiment 当前基准的 configHash:该 experiment 在 `runs` 里最新快照的 configHash。 */
621
+ function latestConfigHashByExperiment(runs: readonly Run[]): Map<string, string | undefined> {
622
+ const latest = new Map<string, Run>();
623
+ for (const run of runs) {
624
+ const existing = latest.get(run.experimentId);
625
+ if (existing === undefined || run.startedAt > existing.startedAt) latest.set(run.experimentId, run);
626
+ }
627
+ return new Map([...latest].map(([id, run]) => [id, run.configHash]));
628
+ }
629
+
608
630
  export async function deltaTableData(input: ReportInput, options: DeltaTableOptions): Promise<DeltaData> {
609
- const { runs, attempts } = resolveInput(input);
631
+ const { runs, attempts, historyAttempts, fresh } = resolveInput(input);
610
632
  const items = filterItems(collectItems(runs, attempts), options.evals);
611
633
 
612
634
  let conditions: string[];
@@ -673,6 +695,14 @@ export async function deltaTableData(input: ReportInput, options: DeltaTableOpti
673
695
  for (const byEval of byConditionByEval.values()) for (const evalId of byEval.keys()) evalIdsSet.add(evalId);
674
696
  const knownEvalIds = [...evalIdsSet].sort();
675
697
 
698
+ // 每个条件背后的 experiment id 集合(by: "experiment" 时条件字符串本身通常就是唯一一个 id);
699
+ // 过期结论参考按各自 experiment 的当前基准 configHash 判定。
700
+ const experimentIdsByCondition = new Map<string, Set<string>>();
701
+ for (const [condition, conditionItems] of itemsByCondition) {
702
+ experimentIdsByCondition.set(condition, new Set(conditionItems.map((item) => experimentIdOf(item))));
703
+ }
704
+ const anchorConfigHashByExperiment = latestConfigHashByExperiment(runs);
705
+
676
706
  const baseline = conditions[0];
677
707
  const rows: DeltaData["rows"] = [];
678
708
  for (const evalId of knownEvalIds) {
@@ -701,7 +731,32 @@ export async function deltaTableData(input: ReportInput, options: DeltaTableOpti
701
731
  }
702
732
  }
703
733
  }
704
- rows.push({ key: evalId, flipped, cells, ...(delta ? { delta } : {}) });
734
+
735
+ // 过期结论参考(show/compare.md「— ✓ 12d」):只在该条件缺席这道题、且记录里存在与该
736
+ // experiment 当前基准不可比的历史判定时才给,口径与 experiment-table 两档占位行一致——
737
+ // `fresh` 为 true 时不给参考,读者已声明只看新执行。它只写进 references,不进 cells,
738
+ // 因此天然不参与 totals / delta / 配对覆盖的任何一个数。
739
+ let references: DeltaData["rows"][number]["references"] | undefined;
740
+ if (!fresh) {
741
+ for (const condition of conditions) {
742
+ if (cells[condition] !== undefined) continue;
743
+ const experimentIds = experimentIdsByCondition.get(condition);
744
+ if (!experimentIds || experimentIds.size === 0) continue;
745
+ const candidates = historyAttempts.filter(
746
+ (a) =>
747
+ experimentIds.has(a.experimentId) &&
748
+ a.evalId === evalId &&
749
+ a.run.configHash !== anchorConfigHashByExperiment.get(a.experimentId),
750
+ );
751
+ const reference = staleReferenceOf(candidates);
752
+ if (reference) {
753
+ references ??= {};
754
+ references[condition] = reference;
755
+ }
756
+ }
757
+ }
758
+
759
+ rows.push({ key: evalId, flipped, cells, ...(delta ? { delta } : {}), ...(references ? { references } : {}) });
705
760
  }
706
761
 
707
762
  // 各条件自身覆盖面:分母是该条件有结果的 eval 数,不看其它条件是否也覆盖了这道题。