Files
ComfyUI_frontend/scripts/perf-stats.ts
Christian Byrne 2af3940867 feat: add trend visualization with sparklines to perf report (#9939)
## Summary

Add historical trend visualization (ASCII sparklines + directional
arrows) to the performance PR report, showing how each metric has moved
over recent commits on main.

## Changes

- **What**: New `sparkline()`, `trendDirection()`, `trendArrow()`
functions in `perf-stats.ts`. New collapsible "Trend" section in the
perf report showing per-metric sparklines, direction indicators, and
latest values. CI workflow updated to download historical data from the
`perf-data` orphan branch and switched to `setup-frontend` action with
`pnpm exec tsx`.

## Review Focus

- The trend section only renders when ≥3 historical data points exist
(gracefully absent otherwise)
- `trendDirection()` uses a split-half mean comparison with ±10%
threshold — review whether this sensitivity is appropriate
- The `git archive` step in `pr-perf-report.yaml` is idempotent and
fails silently if no perf-history data exists yet on the perf-data
branch

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9939-feat-add-trend-visualization-with-sparklines-to-perf-report-3246d73d36508125a6fcc39612f850fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 07:10:30 -07:00

114 lines
2.9 KiB
TypeScript

export interface MetricStats {
mean: number
stddev: number
min: number
max: number
n: number
}
export function computeStats(values: number[]): MetricStats {
const n = values.length
if (n === 0) return { mean: 0, stddev: 0, min: 0, max: 0, n: 0 }
if (n === 1)
return { mean: values[0], stddev: 0, min: values[0], max: values[0], n: 1 }
const mean = values.reduce((a, b) => a + b, 0) / n
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n - 1)
return {
mean,
stddev: Math.sqrt(variance),
min: Math.min(...values),
max: Math.max(...values),
n
}
}
export function zScore(value: number, stats: MetricStats): number | null {
if (stats.stddev === 0 || stats.n < 2) return null
return (value - stats.mean) / stats.stddev
}
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
export function classifyChange(
z: number | null,
historicalCV: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'
}
export function formatSignificance(
sig: Significance,
z: number | null
): string {
switch (sig) {
case 'regression':
return `⚠️ z=${z!.toFixed(1)}`
case 'improvement':
return `z=${z!.toFixed(1)}`
case 'noisy':
return 'variance too high'
case 'neutral':
return z !== null ? `z=${z.toFixed(1)}` : '—'
}
}
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
export function sparkline(values: number[]): string {
if (values.length === 0) return ''
if (values.length === 1) return SPARK_CHARS[3]
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min
return values
.map((v) => {
if (range === 0) return SPARK_CHARS[3]
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1))
return SPARK_CHARS[idx]
})
.join('')
}
export type TrendDirection = 'rising' | 'falling' | 'stable'
export function trendDirection(values: number[]): TrendDirection {
if (values.length < 3) return 'stable'
const half = Math.floor(values.length / 2)
const firstHalf = values.slice(0, half)
const secondHalf = values.slice(-half)
const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
if (firstMean === 0) return secondMean > 0 ? 'rising' : 'stable'
const changePct = ((secondMean - firstMean) / firstMean) * 100
if (changePct > 10) return 'rising'
if (changePct < -10) return 'falling'
return 'stable'
}
export function trendArrow(dir: TrendDirection): string {
switch (dir) {
case 'rising':
return '📈'
case 'falling':
return '📉'
case 'stable':
return '➡️'
}
}