Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/check-core/src/perf/perf-stats.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) 2026 Climate Interactive / New Venture Fund

import { describe, expect, it } from 'vitest'

import { PerfStats } from './perf-stats'

/**
* Add a sequence of run times to a fresh `PerfStats` instance.
*/
function makeStats(times: number[]): PerfStats {
const stats = new PerfStats()
for (const t of times) {
stats.addRun(t)
}
return stats
}

describe('PerfStats', () => {
it('should produce zeroed report when no runs were added', () => {
const report = new PerfStats().toReport()
expect(report.minTime).toBe(0)
expect(report.maxTime).toBe(0)
expect(report.avgTime).toBe(0)
expect(report.medianTime).toBe(0)
expect(report.p95Time).toBe(0)
expect(report.stdDev).toBe(0)
expect(report.allTimes).toEqual([])
})

it('should report raw min and max from all samples', () => {
const report = makeStats([20, 10, 30, 15, 25]).toReport()
expect(report.minTime).toBe(10)
expect(report.maxTime).toBe(30)
})

it('should sort allTimes ascending in the report', () => {
const report = makeStats([20, 10, 30, 15, 25]).toReport()
expect(report.allTimes).toEqual([10, 15, 20, 25, 30])
})

it('should compute the trimmed mean (interquartile mean) for avgTime', () => {
// For 8 samples, the middle 50% is the 4 middle values [3,4,5,6] -> avg 4.5
const report = makeStats([1, 2, 3, 4, 5, 6, 7, 100]).toReport()
expect(report.avgTime).toBeCloseTo((3 + 4 + 5 + 6) / 4, 6)
})

it('should ignore extreme outliers when computing avgTime', () => {
// With heavy outliers on both ends, trimmed mean should sit near the bulk
const samples = [20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 1, 200]
const report = makeStats(samples).toReport()
expect(report.avgTime).toBeCloseTo(20, 6)
})

it('should compute median as 50th percentile (linear interpolation)', () => {
// Odd count: median is the middle value
expect(makeStats([1, 2, 3, 4, 5]).toReport().medianTime).toBeCloseTo(3, 6)
// Even count: median is interpolated midpoint
expect(makeStats([1, 2, 3, 4]).toReport().medianTime).toBeCloseTo(2.5, 6)
})

it('should compute p95 as the 95th percentile (linear interpolation)', () => {
// For samples 1..100 sorted ascending, p95 ≈ 95.05 with linear interpolation
const samples: number[] = []
for (let i = 1; i <= 100; i++) {
samples.push(i)
}
const report = makeStats(samples).toReport()
expect(report.p95Time).toBeCloseTo(95.05, 2)
})

it('should compute the population stddev across all samples', () => {
// Mean = 30, variance = ((10-30)^2 + (20-30)^2 + (30-30)^2 + (40-30)^2 + (50-30)^2) / 5 = 200
// stddev = sqrt(200) ≈ 14.142
const report = makeStats([10, 20, 30, 40, 50]).toReport()
expect(report.stdDev).toBeCloseTo(Math.sqrt(200), 4)
})

it('should produce stable percentiles for a single sample', () => {
const report = makeStats([42]).toReport()
expect(report.minTime).toBe(42)
expect(report.maxTime).toBe(42)
expect(report.avgTime).toBe(42)
expect(report.medianTime).toBe(42)
expect(report.p95Time).toBe(42)
expect(report.stdDev).toBe(0)
})
})
99 changes: 87 additions & 12 deletions packages/check-core/src/perf/perf-stats.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,122 @@
// Copyright (c) 2021-2022 Climate Interactive / New Venture Fund
// Copyright (c) 2021-2026 Climate Interactive / New Venture Fund

/**
* A summary of timing samples collected during a performance run.
*/
export interface PerfReport {
/** Minimum sample time, in milliseconds. */
readonly minTime: number
/** Maximum sample time, in milliseconds. */
readonly maxTime: number
/**
* Trimmed mean (interquartile mean) computed from the middle 50% of samples,
* in milliseconds. This is more robust against outliers than a simple mean.
*/
readonly avgTime: number
/** Median (50th percentile) sample time, in milliseconds. */
readonly medianTime: number
/** 95th percentile sample time, in milliseconds. */
readonly p95Time: number
/** Population standard deviation across all samples, in milliseconds. */
readonly stdDev: number
/** All recorded sample times, sorted ascending, in milliseconds. */
readonly allTimes: number[]
}

/**
* Return the linearly-interpolated percentile of the given sorted array.
*
* @param sorted The samples sorted in ascending order. Must be non-empty.
* @param p The percentile to compute, in the range [0, 1].
* @returns The interpolated percentile value.
*/
function percentile(sorted: number[], p: number): number {
if (sorted.length === 1) {
return sorted[0]
}
const rank = p * (sorted.length - 1)
const lo = Math.floor(rank)
const hi = Math.ceil(rank)
if (lo === hi) {
return sorted[lo]
}
const frac = rank - lo
return sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}

/**
* Collect performance timing samples and produce a robust statistical summary.
*/
export class PerfStats {
private readonly times: number[] = []

/**
* Record a single run time sample.
*
* @param timeInMillis The run time in milliseconds.
*/
addRun(timeInMillis: number): void {
this.times.push(timeInMillis)
}

/**
* Get the raw run time samples that have been recorded.
*
* @returns A copy of the recorded run times, in insertion order.
*/
getTimes(): number[] {
return this.times.slice()
}

/**
* Produce a `PerfReport` summarizing the recorded samples.
*
* @returns The summary report.
*/
toReport(): PerfReport {
if (this.times.length === 0) {
return {
minTime: 0,
maxTime: 0,
avgTime: 0,
medianTime: 0,
p95Time: 0,
stdDev: 0,
allTimes: []
}
}

// Get the absolute min and max times, just for informational
// purposes (these will be thrown out before computing the average)
const minTime = Math.min(...this.times)
const maxTime = Math.max(...this.times)
// Sort the samples ascending for percentile and trimmed-mean calculations
const sortedTimes = this.times.slice().sort((a, b) => a - b)
const n = sortedTimes.length

// Raw min/max
const minTime = sortedTimes[0]
const maxTime = sortedTimes[n - 1]

// Sort the run times, then keep only the middle 50% so that we
// ignore outliers for computing the average time
const sortedTimes = this.times.sort()
const minIndex = Math.floor(sortedTimes.length / 4)
const maxIndex = minIndex + Math.ceil(sortedTimes.length / 2)
// Trimmed mean across the middle 50% of samples (interquartile mean).
// This matches the historical behavior of `avgTime`.
const minIndex = Math.floor(n / 4)
const maxIndex = minIndex + Math.max(1, Math.ceil(n / 2))
const middleTimes = sortedTimes.slice(minIndex, maxIndex)
const totalTime = middleTimes.reduce((a, b) => a + b, 0)
const avgTime = totalTime / middleTimes.length
const avgTime = middleTimes.reduce((a, b) => a + b, 0) / middleTimes.length

// Robust quantiles
const medianTime = percentile(sortedTimes, 0.5)
const p95Time = percentile(sortedTimes, 0.95)

// Population standard deviation across all samples
const mean = sortedTimes.reduce((a, b) => a + b, 0) / n
const variance = sortedTimes.reduce((acc, t) => acc + (t - mean) * (t - mean), 0) / n
const stdDev = Math.sqrt(variance)

return {
minTime,
maxTime,
avgTime,
medianTime,
p95Time,
stdDev,
allTimes: sortedTimes
}
}
Expand Down
59 changes: 49 additions & 10 deletions packages/check-ui-shell/src/components/perf/dot-plot-vm.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,69 @@
// Copyright (c) 2021-2022 Climate Interactive / New Venture Fund
// Copyright (c) 2021-2026 Climate Interactive / New Venture Fund

/**
* View model for a single horizontal dot plot.
*/
export interface DotPlotViewModel {
/** Raw values of the dots. */
/** Raw values of the dots (full set, including any that overflow the visible domain). */
values: number[]
/** Raw average value. */
avg: number
/** Positions of the dots, in the range [0, 100]. */
/** Lower bound of the visible domain (the value at the left tick). */
min: number
/** Upper bound of the visible domain (the value at the right tick). */
max: number
/** Positions of the in-range dots, in the range [0, 100]. */
points: number[]
/** Position of the average line, in the range [0, 100]. */
/** Position of the average line, in the range [0, 100] (clamped). */
avgPoint: number
/** Number of samples that exceed the upper bound (rendered as an overflow indicator). */
overflowCount: number
}

/**
* Build a `DotPlotViewModel` for the given samples. Values that exceed `max`
* are excluded from the rendered dots and counted in `overflowCount` so the
* caller can display a "tail beyond the visible range" indicator.
*
* @param values The raw sample values.
* @param min The lower bound of the visible domain.
* @param max The upper bound of the visible domain.
* @param avg The average value to highlight (clamped to the visible range).
* @returns A populated dot plot view model.
*/
export function createDotPlotViewModel(values: number[], min: number, max: number, avg: number): DotPlotViewModel {
// Convert raw values to percentages
const spread = max - min
function pct(x: number): number {
if (spread !== 0) {
return ((x - min) / (max - min)) * 100
} else {
if (spread === 0) {
return 0
}
const p = ((x - min) / spread) * 100
if (p < 0) {
return 0
}
if (p > 100) {
return 100
}
return p
}

const points: number[] = []
let overflowCount = 0
for (const v of values) {
if (v > max) {
overflowCount++
} else {
points.push(pct(v))
}
}

return {
values,
avg,
points: values.map(p => pct(p)),
avgPoint: pct(avg)
min,
max,
points,
avgPoint: pct(avg),
overflowCount
}
}
73 changes: 72 additions & 1 deletion packages/check-ui-shell/src/components/perf/dot-plot.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<!-- Copyright (c) 2021-2022 Climate Interactive / New Venture Fund -->
<!-- Copyright (c) 2021-2026 Climate Interactive / New Venture Fund -->

<!-- SCRIPT -->
<script lang="ts">
import type { DotPlotViewModel } from './dot-plot-vm'

export let viewModel: DotPlotViewModel
export let colorClass: string
export let showAxisLabels = false
export let avgLabelPosition: 'above' | 'below' | undefined = undefined
export let avgLabelTextClass = ''
</script>

<!-- TEMPLATE -->
Expand All @@ -17,6 +20,22 @@ export let colorClass: string
<div class={`dot ${colorClass}`} style="left: {point}%;"></div>
{/each}
<div class={`vline avg-line ${colorClass}`} style="left: {viewModel.avgPoint}%;"></div>
{#if viewModel.overflowCount > 0}
<div class="overflow" title={`${viewModel.overflowCount} sample(s) beyond p95`}>+{viewModel.overflowCount}</div>
{/if}
{#if showAxisLabels}
<div class="axis-label axis-label-left">{viewModel.min.toFixed(1)}</div>
<div class="axis-label axis-label-right">{viewModel.max.toFixed(1)}</div>
{/if}
{#if avgLabelPosition === 'below'}
<div class={`avg-label avg-label-below ${avgLabelTextClass}`} style="left: {viewModel.avgPoint}%;">
{viewModel.avg.toFixed(1)}
</div>
{:else if avgLabelPosition === 'above'}
<div class={`avg-label avg-label-above ${avgLabelTextClass}`} style="left: {viewModel.avgPoint}%;">
{viewModel.avg.toFixed(1)}
</div>
{/if}
</div>

<!-- STYLE -->
Expand Down Expand Up @@ -65,4 +84,56 @@ $line-color: #555;
border-radius: $dot-size * 0.5;
opacity: 0.2;
}

.overflow {
position: absolute;
left: 100%;
top: 0;
height: $height;
display: flex;
align-items: center;
margin-left: 0.4rem;
color: #888;
font-family: monospace;
font-size: 0.75rem;
white-space: nowrap;
}

.axis-label {
position: absolute;
top: $height;
margin-top: 0.1rem;
color: #888;
font-family: monospace;
font-size: 0.75rem;
white-space: nowrap;
transform: translateX(-50%);

&.axis-label-left {
left: 0;
}

&.axis-label-right {
left: 100%;
}
}

.avg-label {
position: absolute;
font-family: monospace;
font-size: 0.75rem;
white-space: nowrap;

&.avg-label-below {
top: $height;
margin-top: 0.1rem;
transform: translateX(-50%);
}

&.avg-label-above {
top: 0;
margin-top: -0.1rem;
transform: translate(-50%, -100%);
}
}
</style>
Loading
Loading