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
@@ -1,10 +1,15 @@
1
1
  // Chart 原语:消费 Dataset,用 x/y 与 <Series> 映射坐标与 mark(docs/feature/reports/components/charts/README.md)。
2
2
 
3
- import type { ReactNode } from "react";
3
+ import type { CSSProperties, ReactNode } from "react";
4
4
  import type { AttemptLocator } from "../../../record/locator.ts";
5
+ import {
6
+ SeriesPatternDefs,
7
+ seriesClassFromColorVar,
8
+ seriesClassesFromFill,
9
+ } from "../../assets/series-encoding.tsx";
5
10
  import { defineComponent, type ReportNode, type TextContext, type WebContext } from "../tree.ts";
6
11
  import type { ReportTarget } from "../report.ts";
7
- import type { DimensionDeclarations } from "../../presentation.ts";
12
+ import type { DimensionDeclarations, DimensionPresentation } from "../../presentation.ts";
8
13
  import type { Dataset, DatasetField } from "../../model/types.ts";
9
14
  import type { ReportLocale } from "../../model/locale.ts";
10
15
  import { countText, localeText, resolveLocalizedText, type ReportLocale as RL } from "../../model/locale.ts";
@@ -155,12 +160,13 @@ function chartFieldLabel(field: string, meta: DatasetField, locale: RL): string
155
160
  return meta.unit ? `${base}(${meta.unit})` : base;
156
161
  }
157
162
 
158
- function seriesClass(
163
+ /** 解析点所属 series 句柄与下标;单系列隐式图不声明视觉身份。 */
164
+ function seriesPresentationOf(
159
165
  mapped: MappedSeries[],
160
166
  series: MappedSeries,
161
167
  point: MappedSeries["points"][number],
162
168
  ctx: WebContext,
163
- ): string {
169
+ ): DimensionPresentation | undefined {
164
170
  let handle: string;
165
171
  let index: number;
166
172
  if (series.byField !== undefined && point.seriesValue !== undefined) {
@@ -168,12 +174,161 @@ function seriesClass(
168
174
  index = seriesDimensionValues(mapped, series.byField).indexOf(point.seriesValue);
169
175
  } else {
170
176
  const ids = mapped.filter((item) => !item.hidden && item.byField === undefined).map((item) => item.id);
171
- if (ids.length < 2) return "niceeval-series-none";
177
+ if (ids.length < 2) return undefined;
172
178
  handle = IMPLICIT_SERIES_HANDLE;
173
179
  index = ids.indexOf(series.id);
174
180
  }
175
- const colorIndex = ctx.dimension(handle).at(index).colorIndex;
176
- return colorIndex === undefined ? "niceeval-series-none" : `niceeval-series-c${colorIndex - 1}`;
181
+ if (index < 0) return undefined;
182
+ return ctx.dimension(handle).at(index);
183
+ }
184
+
185
+ /** SVG 路径:只挂 series-cN 设 --series;pattern 由 fill 属性 / style 承载。 */
186
+ function seriesColorClass(presentation: DimensionPresentation | undefined): string {
187
+ if (!presentation) return "niceeval-series-none";
188
+ if (presentation.kind === "color") return seriesClassFromColorVar(presentation.color);
189
+ if (presentation.kind !== "series") return "niceeval-series-none";
190
+ // switch 按 mark 收窄;Fill 的 mark 是 "bar"|"area" 联合,if/|| 在部分 TS 版本下剔不干净。
191
+ switch (presentation.mark) {
192
+ case "bar":
193
+ case "area": {
194
+ // 只要色类,不要 HTML 专用的 fill-vN(SVG 用 url(#pattern))。
195
+ const classes = seriesClassesFromFill(presentation.fill).split(" ");
196
+ return classes.find((c) => c.startsWith("niceeval-series-c") || c === "niceeval-series-none") ?? "niceeval-series-none";
197
+ }
198
+ case "line":
199
+ return seriesClassFromColorVar(presentation.stroke);
200
+ case "scatter":
201
+ return seriesClassFromColorVar(presentation.marker.fill);
202
+ }
203
+ }
204
+
205
+ /** HTML 横向柱:色类 + fill-vN 图案类(CSS repeating-linear-gradient 等效 SVG pattern)。 */
206
+ function seriesHtmlBarClass(presentation: DimensionPresentation | undefined): string {
207
+ if (!presentation) return "niceeval-series-none";
208
+ if (presentation.kind === "series" && (presentation.mark === "bar" || presentation.mark === "area")) {
209
+ return seriesClassesFromFill(presentation.fill);
210
+ }
211
+ return seriesColorClass(presentation);
212
+ }
213
+
214
+ /**
215
+ * 作者在 `<Series line>` 上显式声明的 dashed/dotted **优先于** 页级 variant 的 strokeDasharray。
216
+ * 未声明时消费 LineSeriesPresentation / FillSeriesPresentation 的 strokeDasharray(空串 = 实线)。
217
+ */
218
+ function resolveStrokeDasharray(
219
+ series: MappedSeries,
220
+ presentation: DimensionPresentation | undefined,
221
+ ): string | undefined {
222
+ if (series.line !== undefined) return lineDash(series.line);
223
+ if (presentation?.kind === "series" && "strokeDasharray" in presentation) {
224
+ return presentation.strokeDasharray || undefined;
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ /** SVG 柱/面:pattern fill 必须用 style 压过 `.niceeval-chart-bar { fill: var(--series) }`。 */
230
+ function seriesSvgFillStyle(presentation: DimensionPresentation | undefined): CSSProperties | undefined {
231
+ if (presentation?.kind !== "series") return undefined;
232
+ if (presentation.mark !== "bar" && presentation.mark !== "area") return undefined;
233
+ if (!presentation.fill.startsWith("url(")) return undefined;
234
+ return { fill: presentation.fill };
235
+ }
236
+
237
+ function renderMarkerShape(
238
+ presentation: DimensionPresentation | undefined,
239
+ px: number,
240
+ py: number,
241
+ colorClass: string,
242
+ title: ReactNode,
243
+ ): ReactNode {
244
+ const marker =
245
+ presentation?.kind === "series" && (presentation.mark === "scatter" || presentation.mark === "line")
246
+ ? presentation.marker
247
+ : undefined;
248
+ if (!marker) {
249
+ return (
250
+ <circle className={cx("niceeval-chart-dot", colorClass)} cx={px} cy={py} r={4.5}>
251
+ {title}
252
+ </circle>
253
+ );
254
+ }
255
+ // path 在 0..12 viewBox;缩放到 ~9px 并居中到 (px, py)。
256
+ return (
257
+ <path
258
+ className={cx("niceeval-chart-dot", colorClass)}
259
+ d={marker.path}
260
+ transform={`translate(${px} ${py}) scale(0.75) translate(-6 -6)`}
261
+ fill={marker.fill}
262
+ stroke="var(--panel)"
263
+ strokeWidth={1.6}
264
+ >
265
+ {title}
266
+ </path>
267
+ );
268
+ }
269
+
270
+ function legendSwatch(presentation: DimensionPresentation | undefined, mark: MappedSeries["mark"]): ReactNode {
271
+ if (!presentation || presentation.kind !== "series") {
272
+ return <span className={cx("niceeval-chart-legend-swatch", "niceeval-series-none")} />;
273
+ }
274
+ // 图例方块跟 series mark 取义:柱/面用填充图案,线用 dash+marker,散点用 marker 形状。
275
+ if (mark === "bar" || mark === "area") {
276
+ const fillClass =
277
+ presentation.mark === "bar" || presentation.mark === "area"
278
+ ? seriesClassesFromFill(presentation.fill)
279
+ : seriesColorClass(presentation);
280
+ return <span className={cx("niceeval-chart-legend-swatch", fillClass)} />;
281
+ }
282
+ if (mark === "line") {
283
+ // 线系图例需要 stroke + marker;presentation 可能是 line 或 scatter(by 切分后 mark 对齐)。
284
+ if (presentation.mark === "line") {
285
+ return (
286
+ <svg className="niceeval-chart-legend-swatch-svg" width="16" height="10" aria-hidden="true">
287
+ <line
288
+ x1="0"
289
+ y1="5"
290
+ x2="16"
291
+ y2="5"
292
+ stroke={presentation.stroke}
293
+ strokeWidth={2}
294
+ strokeDasharray={presentation.strokeDasharray || undefined}
295
+ />
296
+ <path
297
+ d={presentation.marker.path}
298
+ transform="translate(8 5) scale(0.55) translate(-6 -6)"
299
+ fill={presentation.marker.fill}
300
+ />
301
+ </svg>
302
+ );
303
+ }
304
+ if (presentation.mark === "scatter") {
305
+ return (
306
+ <svg className="niceeval-chart-legend-swatch-svg" width="16" height="10" aria-hidden="true">
307
+ <line x1="0" y1="5" x2="16" y2="5" stroke={presentation.marker.fill} strokeWidth={2} />
308
+ <path
309
+ d={presentation.marker.path}
310
+ transform="translate(8 5) scale(0.55) translate(-6 -6)"
311
+ fill={presentation.marker.fill}
312
+ />
313
+ </svg>
314
+ );
315
+ }
316
+ // fill presentation 配 line mark 的退化:只画色块。
317
+ return <span className={cx("niceeval-chart-legend-swatch", seriesColorClass(presentation))} />;
318
+ }
319
+ // mark === "scatter"
320
+ if (presentation.mark === "scatter" || presentation.mark === "line") {
321
+ return (
322
+ <svg className="niceeval-chart-legend-swatch-svg" width="10" height="10" aria-hidden="true">
323
+ <path
324
+ d={presentation.marker.path}
325
+ transform="translate(5 5) scale(0.7) translate(-6 -6)"
326
+ fill={presentation.marker.fill}
327
+ />
328
+ </svg>
329
+ );
330
+ }
331
+ return <span className={cx("niceeval-chart-legend-swatch", seriesColorClass(presentation))} />;
177
332
  }
178
333
 
179
334
  function metricDisplay(
@@ -210,15 +365,9 @@ function renderLegend(
210
365
  : implicitIds.indexOf(series.id);
211
366
  const presentation = handle ? ctx.dimension(handle).at(index) : undefined;
212
367
  const label = presentation?.label ?? (series.label ? resolveLocalizedText(series.label, locale) : series.id);
213
- const colorClass =
214
- presentation?.colorIndex === undefined
215
- ? "niceeval-series-none"
216
- : `niceeval-series-c${presentation.colorIndex - 1}`;
217
368
  return (
218
- <li
219
- key={`${series.id}:${value}`}
220
- className={cx("niceeval-chart-legend-item", colorClass)}
221
- >
369
+ <li key={`${series.id}:${value}`} className="niceeval-chart-legend-item">
370
+ {legendSwatch(presentation, series.mark)}
222
371
  {label}
223
372
  </li>
224
373
  );
@@ -251,6 +400,8 @@ function renderHorizontalBarsWeb(
251
400
  options.className,
252
401
  )}
253
402
  >
403
+ {/* 横向 HTML 柱不引用 pattern,但同页可能另有 SVG 图;defs 全局一份无害。 */}
404
+ <SeriesPatternDefs />
254
405
  <div className="niceeval-chart-bars-heading">
255
406
  {chartFieldLabel(axes.yField, axes.yMeta, locale)}
256
407
  </div>
@@ -260,7 +411,8 @@ function renderHorizontalBarsWeb(
260
411
  const label = labels.get(rawLabel) ?? rawLabel;
261
412
  const display = metricDisplay(point, "y", axes.yMeta, locale);
262
413
  const href = pointHref(point, ctx, options.pointTarget);
263
- const colorClass = seriesClass(mapped, series, point, ctx);
414
+ const presentation = seriesPresentationOf(mapped, series, point, ctx);
415
+ const colorClass = seriesHtmlBarClass(presentation);
264
416
  const ratio = max > 0 ? Math.max(0, Math.min(1, point.y / max)) : 0;
265
417
  const value = (
266
418
  <span className="niceeval-chart-bar-value">
@@ -374,14 +526,18 @@ function renderChartWeb(
374
526
  const yLabel = chartFieldLabel(axes.yField, axes.yMeta, locale);
375
527
 
376
528
  const drawable = visible.flatMap((series) =>
377
- series.points.map((point) => ({
378
- ...point,
379
- sourceSeriesId: series.id,
380
- label: labelByKey.get(point.pointLabel) ?? point.pointLabel,
381
- px: xScale.scale(point.x),
382
- py: yScale.scale(point.y),
383
- seriesClass: seriesClass(mapped, series, point, ctx),
384
- })),
529
+ series.points.map((point) => {
530
+ const presentation = seriesPresentationOf(mapped, series, point, ctx);
531
+ return {
532
+ ...point,
533
+ sourceSeriesId: series.id,
534
+ label: labelByKey.get(point.pointLabel) ?? point.pointLabel,
535
+ px: xScale.scale(point.x),
536
+ py: yScale.scale(point.y),
537
+ presentation,
538
+ seriesClass: seriesColorClass(presentation),
539
+ };
540
+ }),
385
541
  );
386
542
 
387
543
  const labels = placePointLabels(
@@ -391,6 +547,7 @@ function renderChartWeb(
391
547
 
392
548
  return (
393
549
  <figure className={cx("niceeval-report", "niceeval-chart", "niceeval-chart--scatter", options.className)}>
550
+ <SeriesPatternDefs />
394
551
  <svg className="niceeval-chart-svg" viewBox={`0 0 ${WIDTH} ${HEIGHT}`} role="img" aria-label={`${axes.xField} × ${axes.yField}`}>
395
552
  {options.grid !== false ? (
396
553
  <g className="niceeval-chart-grid">
@@ -457,7 +614,8 @@ function renderChartWeb(
457
614
  (series.byField === undefined || p.seriesValue === value),
458
615
  );
459
616
  const ordered = series.connect ? [...seriesPoints].sort((a, b) => a.x - b.x) : seriesPoints;
460
- const seriesClass = ordered[0]?.seriesClass ?? "niceeval-series-none";
617
+ const seriesClassName = ordered[0]?.seriesClass ?? "niceeval-series-none";
618
+ const seriesPresentation = ordered[0]?.presentation;
461
619
  const baseline = yScale.scale(0);
462
620
  const barGroups = [...new Set(
463
621
  visible
@@ -468,15 +626,18 @@ function renderChartWeb(
468
626
  const groupIndex = Math.max(0, barGroups.indexOf(barGroup));
469
627
  const totalBarWidth = Math.max(8, Math.min(48, PLOT_W / Math.max(1, allPoints.length)));
470
628
  const barWidth = totalBarWidth / Math.max(1, barGroups.length);
629
+ const dash = resolveStrokeDasharray(series, seriesPresentation);
630
+ const areaFillStyle = seriesSvgFillStyle(seriesPresentation);
471
631
  return (
472
632
  <g
473
633
  key={`${series.id}:${value}`}
474
- className={cx("niceeval-chart-series", seriesClass)}
634
+ className={cx("niceeval-chart-series", seriesClassName)}
475
635
  data-series={`${series.id}:${value}`}
476
636
  >
477
637
  {series.mark === "area" && ordered.length > 1 ? (
478
638
  <polygon
479
639
  className="niceeval-chart-area"
640
+ style={areaFillStyle}
480
641
  points={[
481
642
  `${ordered[0]!.px},${baseline}`,
482
643
  ...ordered.map((point) => `${point.px},${point.py}`),
@@ -488,7 +649,7 @@ function renderChartWeb(
488
649
  <polyline
489
650
  className="niceeval-chart-line"
490
651
  points={ordered.map((p) => `${p.px},${p.py}`).join(" ")}
491
- strokeDasharray={lineDash(series.line)}
652
+ strokeDasharray={dash}
492
653
  />
493
654
  ) : null}
494
655
  {ordered.map((p) => {
@@ -502,6 +663,7 @@ function renderChartWeb(
502
663
  shape = (
503
664
  <rect
504
665
  className={cx("niceeval-chart-bar", p.seriesClass)}
666
+ style={seriesSvgFillStyle(p.presentation)}
505
667
  x={p.px - totalBarWidth / 2 + groupIndex * barWidth}
506
668
  y={Math.min(topY, baseY)}
507
669
  width={barWidth}
@@ -511,10 +673,12 @@ function renderChartWeb(
511
673
  </rect>
512
674
  );
513
675
  } else {
514
- shape = (
515
- <circle className={cx("niceeval-chart-dot", p.seriesClass)} cx={p.px} cy={p.py} r={4.5}>
516
- <title>{`${p.pointLabel}\n${axes.xField}: ${metricDisplay(p, "x", axes.xMeta, locale)}\n${axes.yField}: ${metricDisplay(p, "y", axes.yMeta, locale)}`}</title>
517
- </circle>
676
+ shape = renderMarkerShape(
677
+ p.presentation,
678
+ p.px,
679
+ p.py,
680
+ p.seriesClass,
681
+ <title>{`${p.pointLabel}\n${axes.xField}: ${metricDisplay(p, "x", axes.xMeta, locale)}\n${axes.yField}: ${metricDisplay(p, "y", axes.yMeta, locale)}`}</title>,
518
682
  );
519
683
  }
520
684
  return (
@@ -798,7 +962,8 @@ export const Chart = defineComponent<ChartProps>({
798
962
  if (values.length === 0) continue;
799
963
  decls[spec.by] = {
800
964
  dimension: spec.by,
801
- encoding: { kind: "series", mark: spec.mark === "area" ? "line" : spec.mark },
965
+ // area FillSeriesPresentation(填充图案),不再折叠成 line
966
+ encoding: { kind: "series", mark: spec.mark },
802
967
  values,
803
968
  };
804
969
  }
@@ -870,11 +870,16 @@ function renderCellWeb(
870
870
  );
871
871
  }
872
872
  const verdict = cell.verdict ?? "skipped";
873
+ const stale = cell.staleSinceMs !== undefined;
873
874
  // 判定符走 verdictMark 单源,与 locator 格同一张表(errored 是 `!`,不并到 `✗`)。
874
875
  return (
875
- <span className={`niceeval-verdict niceeval-verdict-${verdict}`}>
876
- {verdictMark(verdict === "skipped" ? "skipped" : verdict)}{" "}
877
- {localeText(ctx.locale, `verdict.${verdict === "skipped" ? "skipped" : verdict}`)}
876
+ <span
877
+ className={cx("niceeval-verdict", `niceeval-verdict-${verdict}`, stale ? "niceeval-stale" : undefined)}
878
+ title={stale ? localeText(ctx.locale, "experimentList.historicalTooltip") : undefined}
879
+ >
880
+ {verdictMark(verdict === "skipped" ? "skipped" : verdict)}
881
+ {!cell.bare ? <>{" "}{localeText(ctx.locale, `verdict.${verdict === "skipped" ? "skipped" : verdict}`)}</> : null}
882
+ {stale ? <span className="niceeval-stale-distance">{formatTimeDistance(cell.staleSinceMs!, ctx.locale)}</span> : null}
878
883
  </span>
879
884
  );
880
885
  }
@@ -136,8 +136,9 @@ export interface WebContext {
136
136
  /** chrome 文案的 locale;官方组件渲染面经上下文读取,宿主外默认 "en"。 */
137
137
  locale: ReportLocale;
138
138
  /**
139
- * 取本组件 `dimensions()` 声明的某个句柄在这一页的呈现面( `seriesSlot` / 色板下标 /
140
- * 形状变体)。查别的组件的句柄或没声明的句柄抛 `UndeclaredDimensionValueError`。
139
+ * 取本组件 `dimensions()` 声明的某个句柄在这一页的呈现面(label / color / series
140
+ * fill·stroke·marker 等可直接使用的值)。查别的组件的句柄或没声明的句柄抛
141
+ * `UndeclaredDimensionValueError`。
141
142
  */
142
143
  dimension(handle: string): PresentedDimension;
143
144
  }
@@ -203,6 +204,8 @@ export type ReportComponent<P extends object> = ((props: P) => ReactNode) & {
203
204
  */
204
205
  export interface PageDimensions {
205
206
  dimension(props: object, handle: string): PresentedDimension;
207
+ /** 维度 name → 值 → seriesSlot;测试与调试观察槽位分配,组件渲染不读槽号。 */
208
+ readonly slotsByDimension: ReadonlyMap<string, ReadonlyMap<string, number>>;
206
209
  }
207
210
 
208
211
  /** 渲染上下文上挂当前页分配结果的内部键;不是契约字段,只在宿主与渲染遍历之间传递。 */
@@ -331,6 +334,7 @@ export function collectPageDimensions(
331
334
 
332
335
  const plan = allocatePageDimensions(handles, pins, { face });
333
336
  return {
337
+ slotsByDimension: plan.slotsByDimension,
334
338
  dimension(props: object, handle: string): PresentedDimension {
335
339
  const entry = byProps.get(props);
336
340
  const key = entry?.keys.get(handle);
@@ -69,7 +69,17 @@ export {
69
69
  toTraceNodes,
70
70
  } from "./model/conversions.ts";
71
71
  export { presentDimension, shortestUniqueLabels } from "./presentation.ts";
72
- export type { DimensionDeclaration, DimensionEncoding, PresentedDimension } from "./presentation.ts";
72
+ export type {
73
+ ColorPresentation,
74
+ DimensionDeclaration,
75
+ DimensionEncoding,
76
+ DimensionPresentation,
77
+ FillSeriesPresentation,
78
+ LabelPresentation,
79
+ LineSeriesPresentation,
80
+ PresentedDimension,
81
+ ScatterSeriesPresentation,
82
+ } from "./presentation.ts";
73
83
  export { flag, label, numericFlag, numericLabel, numericRunConfig, runConfig } from "./model/flag.ts";
74
84
  export { evaluationKindComposition } from "./model/evaluation-kind.ts";
75
85
 
@@ -19,6 +19,7 @@ import type {
19
19
  NumericAxis,
20
20
  ReportInput,
21
21
  SeriesInput,
22
+ StaleConclusionReference,
22
23
  } from "./types.ts";
23
24
  import { flagValueOf, labelValueOf, runConfigValueOf } from "./flag.ts";
24
25
  import { metricDisplay } from "./format.ts";
@@ -92,6 +93,31 @@ export function historicalOf(item: Item): boolean {
92
93
  return item.attempt.carried || item.run.startedAt < item.watermark.startedAt;
93
94
  }
94
95
 
96
+ /** 一个 ISO 时刻距渲染时刻(`Date.now()`)的毫秒数,恒不小于 0。 */
97
+ export function msSince(iso: string): number {
98
+ return Math.max(0, Date.now() - Date.parse(iso));
99
+ }
100
+
101
+ /**
102
+ * 覆盖缺口 / 对照矩阵缺席格的「过期结论」参考(docs/feature/reports/components/summaries/
103
+ * experiment-table.md「覆盖缺口的两档占位行」、show/compare.md 同一套口径):候选已经是与
104
+ * 当前基准 configHash 不可比的那些,取其中最近一条;两个消费方(entity-lists 的占位行、
105
+ * DeltaTable 的缺席格)共用同一份「取最新」判据,不各自实现一遍。
106
+ */
107
+ export function staleReferenceOf(candidates: readonly AttemptHandle[]): StaleConclusionReference | undefined {
108
+ if (candidates.length === 0) return undefined;
109
+ const newest = candidates.reduce((a, b) => ((a.result.startedAt ?? "") >= (b.result.startedAt ?? "") ? a : b));
110
+ const startedAt = newest.result.startedAt;
111
+ if (!startedAt) return undefined; // 无时刻的 legacy 落盘算不出时距,不伪造参考
112
+ return {
113
+ locator:
114
+ newest.locator ??
115
+ encodeAttemptLocator({ runId: newest.run.runId, evalId: newest.evalId, attempt: newest.result.attempt }),
116
+ verdict: newest.result.verdict,
117
+ staleSinceMs: msSince(startedAt),
118
+ };
119
+ }
120
+
95
121
  /** 快照键:"<experimentId> @ <startedAt>"("run" 维度与手挑快照数组的对比用)。 */
96
122
  export function snapshotKeyOf(run: Run): string {
97
123
  return `${run.experimentId} @ ${run.startedAt}`;
@@ -98,7 +98,6 @@ const en = {
98
98
  "experimentList.cost": "Cost",
99
99
  "experimentList.result": "Record",
100
100
  "experimentList.status": "Status",
101
- "experimentList.coverage": "Coverage",
102
101
  "experimentList.evalAttempt": "Eval / Attempt",
103
102
  "experimentList.duration": "Duration",
104
103
  "experimentList.filterPlaceholder": "Filter experiments…",
@@ -306,7 +305,6 @@ const zhCN: globalThis.Record<ReportMessageKey, string> = {
306
305
  "experimentList.cost": "成本",
307
306
  "experimentList.result": "结果",
308
307
  "experimentList.status": "状态",
309
- "experimentList.coverage": "覆盖构成",
310
308
  "experimentList.evalAttempt": "题目 / Attempt",
311
309
  "experimentList.duration": "耗时",
312
310
  "experimentList.filterPlaceholder": "筛选实验…",
@@ -275,6 +275,8 @@ export interface DeltaCell {
275
275
  totalCostUSD?: number;
276
276
  /** true 时该格来自跨快照携带的历史执行(时效标注见 experiment-table.md「时效不写字」)。 */
277
277
  historical: boolean;
278
+ /** `historical` 为 true 时,距今的毫秒数(渲染时刻起算);新执行时省略,不伪造 0。 */
279
+ staleSinceMs?: number;
278
280
  }
279
281
 
280
282
  export interface DeltaData {
@@ -292,6 +294,12 @@ export interface DeltaData {
292
294
  cells: globalThis.Record<string, DeltaCell>;
293
295
  /** 键是非基准条件值;任一侧缺数据时无键——delta 不把缺失当 0。 */
294
296
  delta?: globalThis.Record<string, { score?: number; tokens?: number; costUSD?: number }>;
297
+ /**
298
+ * 键是条件值,只在该条件缺席这道题(`cells` 无键)且记录里存在不可比历史判定时才有条目
299
+ * (docs/feature/reports/show/compare.md「— ✓ 12d」);它不进 `totals`、`delta` 与配对覆盖的
300
+ * 任何一个数,只提供 locator 下钻。
301
+ */
302
+ references?: globalThis.Record<string, StaleConclusionReference>;
295
303
  }>;
296
304
  /** 各条件自身覆盖面的描述,分母是该条件有结果的 eval 数;不用于跨条件直接归因。 */
297
305
  totals: globalThis.Record<