File size: 19,859 Bytes
59a688b
 
4ed61ad
 
 
67ec52c
 
 
 
 
 
 
 
 
 
 
14e1edf
67ec52c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14e1edf
 
 
 
 
 
 
 
 
 
 
4ed61ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b38d50
4ed61ad
 
 
 
482cab0
 
 
 
 
4ed61ad
 
6b38d50
 
fb88477
 
4ed61ad
6b38d50
 
 
 
 
 
 
9e9dfbc
4ed61ad
482cab0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ed61ad
482cab0
 
 
 
 
67ec52c
 
 
 
 
482cab0
 
 
 
 
 
 
 
 
 
4ed61ad
 
 
 
59a688b
 
 
 
 
 
 
4ed61ad
 
59a688b
 
 
 
 
 
14e1edf
 
 
59a688b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ed61ad
 
 
 
 
 
59a688b
 
14e1edf
 
 
 
 
 
 
 
 
 
 
 
 
 
59a688b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14e1edf
 
 
 
59a688b
14e1edf
 
 
 
59a688b
 
 
14e1edf
 
 
 
 
 
 
59a688b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ed61ad
 
 
 
 
 
 
 
 
 
 
8cfd3b2
 
4ed61ad
 
 
 
 
 
8cfd3b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd036ac
 
 
 
67ec52c
 
 
 
 
 
dd036ac
8cfd3b2
14e1edf
 
dd036ac
 
 
 
4ed61ad
dd036ac
 
 
4ed61ad
dd036ac
8cfd3b2
4ed61ad
 
 
dd036ac
4ed61ad
 
8cfd3b2
 
 
14e1edf
 
4ed61ad
 
 
 
 
 
8cfd3b2
4ed61ad
 
8cfd3b2
 
4ed61ad
 
 
 
 
9e9dfbc
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
import type { BenchmarkRecord, Dataset, FacetGroup, FacetValue, Filters, FilterBlocker, FilterScope } from './types'
import { NONE_VALUE } from './types'

const KNOWN_KEYS = new Set(['date', 'host', 'hardware', 'metrics'])

/** Positional dimension names per known kernel op, for shapes like "4096:4096:4096". */
const SHAPE_SPECS: Record<string, string[]> = {
  matmul: ['m', 'k', 'n'],
  attention: ['bs', 'seq', 'heads', 'd'],
  'layer-norm': ['bs', 'seq', 'features'],
  softmax: ['bs', 'seq', 'features'],
  gelu: ['bs', 'seq', 'features'],
  conv1d: ['bs', 'ch', 'len', 'kernel'],
}

/** Compact dimension value for labels: 4096 -> 4k, 131072 -> 128k. */
export function compactDim(value: string): string {
  const n = Number(value)
  if (!Number.isFinite(n) || n < 1000) return value
  if (n % 1024 === 0) return `${n / 1024}k`
  if (n % 1000 === 0) return `${n / 1000}k`
  return value
}

/**
 * Parse a shape tag into named dimensions. Handles "k=v" pairs
 * ("bs=8 seq=4096 heads=32 d=128") and the positional colon form
 * ("4096:4096:4096", named via the op's spec, falling back to d1..dN).
 */
export function parseShape(shape: string, op: string): Record<string, string> | null {
  const dims: Record<string, string> = {}
  if (shape.includes('=')) {
    for (const part of shape.split(/[\s,]+/)) {
      const eq = part.indexOf('=')
      if (eq <= 0) return null
      dims[part.slice(0, eq)] = part.slice(eq + 1)
    }
    return Object.keys(dims).length > 0 ? dims : null
  }
  if (shape.includes(':')) {
    const parts = shape.split(':')
    const spec = SHAPE_SPECS[op]
    const names = spec && spec.length === parts.length ? spec : parts.map((_, i) => `d${i + 1}`)
    parts.forEach((p, i) => (dims[names[i]] = p))
    return dims
  }
  return null
}

/**
 * Replace a record's raw `shape` tag with one `shape.<dim>` pseudo-tag per
 * dimension, so every dim gets its own filter chips, group-by option and
 * table column. No-op when the shape string cannot be parsed.
 */
function expandShapeTag(r: BenchmarkRecord, op: string): void {
  const shape = r.tags.shape
  if (shape === undefined) return
  const dims = parseShape(shape, op)
  if (!dims) return
  delete r.tags.shape
  for (const [dim, value] of Object.entries(dims)) r.tags[`shape.${dim}`] = value
}

/**
 * Canonical shape signature of a record (its "workload" identity): the raw
 * `shape` tag, or the parsed dimensions re-joined in order
 * ("m=1024 k=1024 n=4096"). Powers the combined shape facet.
 */
export function shapeSignature(r: BenchmarkRecord): string | null {
  const dims = Object.entries(r.tags).filter(([k]) => k.startsWith('shape.'))
  if (dims.length > 0) return dims.map(([k, v]) => `${k.slice('shape.'.length)}=${v}`).join(' ')
  return r.tags.shape ?? null
}

/** Parse one JSONL line into a BenchmarkRecord. Returns null for invalid lines. */
export function parseRecord(line: string): BenchmarkRecord | null {
  const trimmed = line.trim()
  if (!trimmed) return null
  let raw: Record<string, unknown>
  try {
    raw = JSON.parse(trimmed)
  } catch {
    return null
  }
  if (typeof raw.date !== 'string' || typeof raw.host !== 'string' || typeof raw.hardware !== 'string') {
    return null
  }

  const tags: Record<string, string> = {}
  const metrics: Record<string, number> = {}

  for (const [key, value] of Object.entries(raw)) {
    if (KNOWN_KEYS.has(key)) continue
    if (typeof value === 'string') tags[key] = value
    else if (typeof value === 'number' && Number.isFinite(value)) metrics[key] = value
  }
  if (raw.metrics && typeof raw.metrics === 'object') {
    for (const [key, value] of Object.entries(raw.metrics as Record<string, unknown>)) {
      if (typeof value === 'number' && Number.isFinite(value)) metrics[key] = value
    }
  }

  return { date: raw.date, host: raw.host, hardware: raw.hardware, tags, metrics }
}

async function fetchJsonl(url: string): Promise<string[]> {
  const res = await fetch(url, { cache: 'no-store' })
  if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`)
  return (await res.text()).split('\n')
}

/** Identity used to dedupe records present in both flat and partitioned files. */
function recordId(r: BenchmarkRecord): string {
  return JSON.stringify([r.date, r.host, r.hardware, r.tags, r.metrics])
}

/** Load the data manifest (data/index.json) then every listed dataset. */
export async function loadDatasets(base: string): Promise<Dataset[]> {
  // no-store: new files dropped into /data must show up without a hard refresh.
  const manifestRes = await fetch(`${base}data/index.json`, { cache: 'no-store' })
  // A missing manifest just means "no data available yet" — show the empty state.
  if (manifestRes.status === 404) return []
  if (!manifestRes.ok) throw new Error(`Failed to fetch data manifest: ${manifestRes.status}`)
  // Dev servers may SPA-fallback to index.html (200) for missing files.
  let manifest: { categories?: Record<string, string[]> }
  try {
    manifest = await manifestRes.json()
  } catch {
    return []
  }
  const categories = manifest.categories ?? {}

  // Group files into logical benchmarks: the partitioned layout spreads one
  // benchmark over <category>/<host>/<date>/<name>.jsonl files (the basename
  // is the benchmark name), so every path sharing category+name is one dataset.
  const groups = new Map<string, { category: string; name: string; paths: string[] }>()
  for (const [category, files] of Object.entries(categories)) {
    for (const path of files) {
      const file = path.split('/').pop() ?? path
      const name = file.replace(/\.jsonl$/, '')
      const key = `${category}/${name}`
      let group = groups.get(key)
      if (!group) groups.set(key, (group = { category, name, paths: [] }))
      group.paths.push(path)
    }
  }

  const datasets = await Promise.all(
    [...groups.values()].map(async ({ category, name, paths }) => {
      const lineSets = await Promise.all(paths.map((path) => fetchJsonl(`${base}data/${path}`)))
      const seen = new Set<string>()
      const records = lineSets
        .flat()
        .map((line) => {
          const r = parseRecord(line)
          if (r) expandShapeTag(r, name)
          return r
        })
        .filter((r): r is BenchmarkRecord => r !== null)
        .filter((r) => {
          const id = recordId(r)
          if (seen.has(id)) return false
          seen.add(id)
          return true
        })
        .sort((a, b) => a.date.localeCompare(b.date))
      return { name, category, records }
    })
  )
  return datasets
}

/**
 * Does one record pass the filter set? `skip` ignores one constraint group,
 * which is how facet counts are computed: a chip's count is the number of
 * records matching every OTHER group's current selection.
 */
export function matchesFilters(r: BenchmarkRecord, f: Filters, skip?: FilterScope): boolean {
  if (skip !== 'dates') {
    if (f.dateFrom && r.date < f.dateFrom) return false
    if (f.dateTo && r.date > f.dateTo) return false
  }
  if (skip !== 'hosts' && f.hosts.length > 0 && !f.hosts.includes(r.host)) return false
  if (skip !== 'hardware' && f.hardware.length > 0 && !f.hardware.includes(r.hardware)) return false
  for (const [key, values] of Object.entries(f.tags)) {
    if (!values || values.length === 0) continue
    if (skip === `tag:${key}`) continue
    // The virtual 'shape' key filters on the full shape signature (workload);
    // records missing the tag match the (none) facet value — never vanish silently.
    const value = (key === 'shape' ? shapeSignature(r) : r.tags[key]) ?? NONE_VALUE
    if (!values.includes(value)) return false
  }
  return true
}

export function applyFilters(records: BenchmarkRecord[], f: Filters): BenchmarkRecord[] {
  return records.filter((r) => matchesFilters(r, f))
}

/** Numeric-aware ordering so shape dims sort 512 < 4096, not "4096" < "512". */
export function valueSort(values: Iterable<string>): string[] {
  const unique = [...new Set(values)]
  const real = unique.filter((v) => v !== NONE_VALUE)
  const numeric = real.length > 0 && real.every((v) => v.trim() !== '' && !isNaN(Number(v)))
  real.sort(numeric ? (a, b) => Number(a) - Number(b) : (a, b) => a.localeCompare(b))
  // The "missing" facet always sorts last.
  return unique.includes(NONE_VALUE) ? [...real, NONE_VALUE] : real
}

export function uniqueSorted(values: string[]): string[] {
  return [...new Set(values)].sort((a, b) => a.localeCompare(b))
}

const GROUP_LABELS: Record<string, string> = { hosts: 'Host', hardware: 'Hardware' }

/** Display label: commit/rev abbreviated, shape signatures and dims compact. */
export function facetLabel(key: string, value: string): string {
  if ((key === 'commit' || key === 'rev') && value.length > 12) return value.slice(0, 8)
  if (key === 'shape' && value.includes('=')) {
    return value
      .split(/\s+/)
      .map((kv) => {
        const eq = kv.indexOf('=')
        return eq > 0 ? `${kv.slice(0, eq)}=${compactDim(kv.slice(eq + 1))}` : kv
      })
      .join(' ')
  }
  if (key.startsWith('shape.')) return compactDim(value)
  return value
}

/** Tag values of one record, with missing tags exposed as NONE_VALUE. */
function tagFacetValue(r: BenchmarkRecord, key: string): string {
  return r.tags[key] ?? NONE_VALUE
}

/**
 * Faceted chip groups: for every group (hosts, hardware, each tag key) list
 * all dataset values with the count of records matching the OTHER groups'
 * current selections. Zero-count values are rendered disabled by the UI.
 */
export function buildFacets(records: BenchmarkRecord[], f: Filters): FacetGroup[] {
  const groups: FacetGroup[] = []

  const make = (
    key: string,
    label: string,
    scope: FilterScope,
    valueOf: (r: BenchmarkRecord) => string
  ): void => {
    const pool = records.filter((r) => matchesFilters(r, f, scope))
    const counts = new Map<string, number>()
    for (const r of pool) {
      const v = valueOf(r)
      counts.set(v, (counts.get(v) ?? 0) + 1)
    }
    const selected =
      key === 'hosts' ? f.hosts : key === 'hardware' ? f.hardware : (f.tags[key.slice('tag:'.length)] ?? [])
    const allValues = valueSort(records.map(valueOf))
    const values: FacetValue[] = allValues.map((v) => ({
      value: v,
      label: v === NONE_VALUE ? NONE_VALUE : facetLabel(key.slice('tag:'.length), v),
      count: counts.get(v) ?? 0,
      selected: selected.includes(v),
    }))
    groups.push({ key, label, values, selectedCount: selected.length, allCount: pool.length })
  }

  make('hosts', GROUP_LABELS.hosts, 'hosts', (r) => r.host)
  make('hardware', GROUP_LABELS.hardware, 'hardware', (r) => r.hardware)

  // The combined 'shape' facet (workload identity) leads; per-dimension
  // groups follow as "shape · <dim>" so dims never lose their context.
  const hasShape = records.some((r) => shapeSignature(r) !== null)
  const tagKeys = valueSort([...records.flatMap((r) => Object.keys(r.tags)), ...(hasShape ? ['shape'] : [])])
  for (const tagKey of tagKeys) {
    const valueOf =
      tagKey === 'shape'
        ? (r: BenchmarkRecord) => shapeSignature(r) ?? NONE_VALUE
        : (r: BenchmarkRecord) => tagFacetValue(r, tagKey)
    const distinct = new Set(records.map(valueOf))
    // A single unambiguous value (and no missing records) is not filterable.
    if (distinct.size < 2) continue
    const label =
      tagKey === 'shape'
        ? 'shape'
        : tagKey.startsWith('shape.')
          ? `shape · ${tagKey.slice('shape.'.length)}`
          : tagKey.replace(/_/g, ' ')
    make(`tag:${tagKey}`, label, `tag:${tagKey}`, valueOf)
  }
  return groups
}

/**
 * Swap inverted date bounds (From > To). Keeps inputs valid as the user types.
 */
export function normalizeFilterDates(f: Filters): Filters {
  if (f.dateFrom && f.dateTo && f.dateFrom > f.dateTo) {
    return { ...f, dateFrom: f.dateTo, dateTo: f.dateFrom }
  }
  return f
}

/**
 * Drop filter values that no longer exist in the dataset (hot reload may
 * remove or replace files), so stale selections can never blank the view.
 * Returns the same object when nothing needed pruning.
 */
export function pruneFilters(f: Filters, records: BenchmarkRecord[]): Filters {
  const hostSet = new Set(records.map((r) => r.host))
  const hwSet = new Set(records.map((r) => r.hardware))
  const hosts = f.hosts.filter((h) => hostSet.has(h))
  const hardware = f.hardware.filter((h) => hwSet.has(h))
  const tags: Record<string, string[]> = {}
  let tagsChanged = false
  for (const [key, values] of Object.entries(f.tags)) {
    const valid = new Set(records.map((r) => tagFacetValue(r, key)))
    const kept = (values ?? []).filter((v) => valid.has(v))
    if (kept.length !== (values ?? []).length) tagsChanged = true
    tags[key] = kept
  }
  const changed = hosts.length !== f.hosts.length || hardware.length !== f.hardware.length || tagsChanged
  return changed ? normalizeFilterDates({ ...f, hosts, hardware, tags }) : normalizeFilterDates(f)
}

/** Drop group-by dimensions that vanished; always keep at least 'host'. */
export function pruneGroupBy(groupBy: string[], records: BenchmarkRecord[]): string[] {
  const tagKeys = new Set(records.flatMap((r) => Object.keys(r.tags)))
  const valid = (k: string) => k === 'host' || k === 'hardware' || tagKeys.has(k)
  const kept = groupBy.filter(valid)
  return kept.length > 0 ? kept : ['host']
}

/**
 * Explain why a filter set matches zero records: every active constraint
 * group is listed with what it restricts ("date range … has no data",
 * "hosts: hf-trx50 only"), so the user sees exactly what blocks. Each entry
 * doubles as a one-click recovery target (clear that group).
 */
export function analyzeFilters(records: BenchmarkRecord[], f: Filters): FilterBlocker[] {
  const active: { scope: FilterScope; label: string }[] = []
  if (f.dateFrom || f.dateTo) active.push({ scope: 'dates', label: 'dates' })
  if (f.hosts.length > 0) active.push({ scope: 'hosts', label: 'hosts' })
  if (f.hardware.length > 0) active.push({ scope: 'hardware', label: 'hardware' })
  for (const [key, values] of Object.entries(f.tags)) {
    if (values && values.length > 0) active.push({ scope: `tag:${key}`, label: key.replace(/^shape\./, '').replace(/_/g, ' ') })
  }

  const minDate = records[0]?.date ?? ''
  const maxDate = records[records.length - 1]?.date ?? ''
  const blockers: FilterBlocker[] = []
  for (const { scope, label } of active) {
    // Records matching every OTHER group — used to say whether the range
    // itself has no data or just combines badly with the other selections.
    const pool = records.filter((r) => matchesFilters(r, f, scope))
    let detail: string
    if (scope === 'dates') {
      const inRange = pool.filter((r) => (!f.dateFrom || r.date >= f.dateFrom) && (!f.dateTo || r.date <= f.dateTo))
      detail = `date range ${f.dateFrom || minDate}..${f.dateTo || maxDate}${inRange.length === 0 ? ' has no data' : ''}`
    } else if (scope === 'hosts' || scope === 'hardware') {
      const sel = scope === 'hosts' ? f.hosts : f.hardware
      detail = `${label}: ${sel.join(', ')}${sel.length === 1 ? ' only' : ''}`
    } else {
      const values = f.tags[scope.slice('tag:'.length)] ?? []
      detail = `${label}: ${values.join(', ')}${values.length === 1 ? ' only' : ''}`
    }
    blockers.push({ key: scope, label, detail })
  }
  if (blockers.length === 0 && active.length > 0) {
    blockers.push({ key: 'all', label: 'all filters', detail: 'the active filters combine to exclude every run' })
  }
  return blockers
}

/** All metric keys present in a record set, sorted for stable display. */
export function metricKeys(records: BenchmarkRecord[]): string[] {
  const keys = new Set<string>()
  for (const r of records) for (const k of Object.keys(r.metrics)) keys.add(k)
  return [...keys].sort()
}

export interface SeriesPoint {
  date: string
  t: number
  y: number
  /** Commits of the runs averaged into this point (when tagged). */
  commits?: string[]
}
export interface Series {
  name: string
  points: SeriesPoint[]
}

/** Latest vs. previous value of one metric within one series. */
export interface MetricChange {
  latest: number
  latestDate: string
  previous: number | null
  previousDate: string | null
  /** Signed percent change (latest vs. previous), null when there is no previous. */
  deltaPct: number | null
  /** Commits behind the compared runs, when records carry a commit tag. */
  latestCommits?: string[]
  previousCommits?: string[]
}
export interface TrendRow {
  /** Series name, from the active group-by dimensions. */
  name: string
  /** Metric key -> latest vs. previous change. */
  changes: Record<string, MetricChange>
}

/**
 * One row per series (per groupBy combo) with the latest and previous value
 * of every metric — the change matrix for spotting regressions at a glance.
 */
export function buildTrends(records: BenchmarkRecord[], metrics: string[], groupBy: string[] = ['host']): TrendRow[] {
  const rows = new Map<string, TrendRow>()
  for (const metric of metrics) {
    for (const s of buildSeries(records, metric, groupBy)) {
      const last = s.points[s.points.length - 1]
      const prev = s.points[s.points.length - 2] ?? null
      let row = rows.get(s.name)
      if (!row) rows.set(s.name, (row = { name: s.name, changes: {} }))
      row.changes[metric] = {
        latest: last.y,
        latestDate: last.date,
        previous: prev?.y ?? null,
        previousDate: prev?.date ?? null,
        deltaPct: prev && prev.y !== 0 ? ((last.y - prev.y) / prev.y) * 100 : null,
        ...(last.commits ? { latestCommits: last.commits } : {}),
        ...(prev?.commits ? { previousCommits: prev.commits } : {}),
      }
    }
  }
  return [...rows.values()]
}

/** Series display name from the selected group-by dimensions. */
function seriesName(r: BenchmarkRecord, groupBy: string[]): string {
  const parts: string[] = []
  for (const key of groupBy) {
    if (key.startsWith('shape.')) {
      // Shape dims read best as labeled, compact pairs: "seq=4k".
      const value = r.tags[key]
      if (value) parts.push(`${key.slice('shape.'.length)}=${compactDim(value)}`)
      continue
    }
    const value = key === 'host' ? r.host : key === 'hardware' ? r.hardware : r.tags[key]
    // Long commit hashes read better abbreviated.
    const hash = key === 'commit' || key === 'rev'
    if (value) parts.push(hash && value.length > 12 ? value.slice(0, 8) : value)
  }
  return parts.length > 0 ? parts.join(' · ') : r.host
}

/**
 * Group records into time series for a given metric, one series per unique
 * combination of the groupBy dimensions ('host', 'hardware', or any tag key).
 * When several runs share series+date, the average is used.
 */
export function buildSeries(records: BenchmarkRecord[], metric: string, groupBy: string[] = ['host']): Series[] {
  const groups = new Map<string, Map<string, { ys: number[]; commits: Set<string> }>>()
  for (const r of records) {
    const y = r.metrics[metric]
    if (y === undefined) continue
    const name = seriesName(r, groupBy)
    if (!groups.has(name)) groups.set(name, new Map())
    const byDate = groups.get(name)!
    if (!byDate.has(r.date)) byDate.set(r.date, { ys: [], commits: new Set() })
    const bucket = byDate.get(r.date)!
    bucket.ys.push(y)
    const commit = r.tags.commit ?? r.tags.rev
    if (commit) bucket.commits.add(commit)
  }

  return [...groups.entries()]
    .map(([name, byDate]) => ({
      name,
      points: [...byDate.entries()]
        .map(([date, b]) => ({
          date,
          t: new Date(`${date}T00:00:00Z`).getTime(),
          y: b.ys.reduce((a, c) => a + c, 0) / b.ys.length,
          ...(b.commits.size > 0 ? { commits: [...b.commits] } : {}),
        }))
        .sort((a, b) => a.t - b.t),
    }))
    .sort((a, b) => a.name.localeCompare(b.name))
}