diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9f9cf06..e1f35ed9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,7 @@ jobs: # machines, and that host delta dominates the 8% regression threshold. # Per-case time is longer than the local default so tinybench gets enough samples; # compare treats the median change as the verdict, not the per-case count. + # Per-case lists also compare median ops/s (tinybench p50), not mean throughput. benchmark-pr: name: Benchmark (PR) needs: changes diff --git a/README.ja.md b/README.ja.md index 68fa6787..a2746e3c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -428,4 +428,4 @@ pnpm run bench:compare -- --head tmp/bench/head.json --base tmp/bench/base.json - GitHub Actions では、`devel` / `main` 向け PR で base/head 比較を PR comment として投稿します - GitHub Actions では、`devel` / `main` への push でも直前 revision 比較を実行し、対象 commit へ commit comment を投稿します - CI は比較前に base と head を同一 runner で計測し、ホスト間ノイズが delta を支配しないようにします -- CI はローカル default より長い per-case time を使い、比較シグナルは median change です。ケース単位の一覧は点検用です +- CI はローカル default より長い per-case time を使い、比較シグナルは median change です。ケース単位の一覧も mean ではなく median ops/s で比較します diff --git a/README.md b/README.md index 61bcbad8..6ce9b4bc 100644 --- a/README.md +++ b/README.md @@ -428,4 +428,4 @@ pnpm run bench:compare -- --head tmp/bench/head.json --base tmp/bench/base.json - In GitHub Actions, post base/head comparison as PR comment in PR for `devel` / `main` - GitHub Actions also performs the previous revision comparison when pushing to `devel` / `main` and posts a commit comment to the target commit. - CI measures base and head on the same runner before comparing, so host-to-host noise does not dominate the delta. -- CI uses a longer per-case time than the local default, and treats the median change as the comparison signal. Per-case lists are for inspection. +- CI uses a longer per-case time than the local default, and treats the median change as the comparison signal. Per-case lists compare median ops/s, not mean. diff --git a/scripts/bench/aggregate-results.test.ts b/scripts/bench/aggregate-results.test.ts index 2125be24..403114f2 100644 --- a/scripts/bench/aggregate-results.test.ts +++ b/scripts/bench/aggregate-results.test.ts @@ -38,7 +38,9 @@ function createSnapshot( results: { 'utils.clamp': { hz, + medianHz: hz, meanMs, + p50Ms: meanMs, p75Ms: meanMs + 1, p99Ms: meanMs + 2, minMs: meanMs - 1, @@ -65,10 +67,72 @@ describe('aggregateBenchmarkSnapshots', () => { runCount: 3, }); expect(aggregated.results['utils.clamp'].hz).toBe(120); + expect(aggregated.results['utils.clamp'].medianHz).toBe(120); expect(aggregated.results['utils.clamp'].meanMs).toBe(9); + expect(aggregated.results['utils.clamp'].p50Ms).toBe(9); expect(aggregated.results['utils.clamp'].p99Ms).toBe(11); }); + it('aggregates medianHz independently from mean hz', () => { + const aggregated = aggregateBenchmarkSnapshots([ + createSnapshot(80, 12, { + results: { + 'utils.clamp': { + hz: 80, + medianHz: 100, + meanMs: 12, + p50Ms: 10, + p75Ms: 13, + p99Ms: 14, + minMs: 9, + maxMs: 15, + rmePercent: 1, + sampleCount: 100, + totalTimeMs: 200, + }, + }, + }), + createSnapshot(200, 5, { + results: { + 'utils.clamp': { + hz: 200, + medianHz: 140, + meanMs: 5, + p50Ms: 8, + p75Ms: 6, + p99Ms: 7, + minMs: 4, + maxMs: 8, + rmePercent: 1, + sampleCount: 100, + totalTimeMs: 200, + }, + }, + }), + createSnapshot(90, 11, { + results: { + 'utils.clamp': { + hz: 90, + medianHz: 120, + meanMs: 11, + p50Ms: 9, + p75Ms: 12, + p99Ms: 13, + minMs: 8, + maxMs: 14, + rmePercent: 1, + sampleCount: 100, + totalTimeMs: 200, + }, + }, + }), + ]); + + expect(aggregated.results['utils.clamp'].hz).toBe(90); + expect(aggregated.results['utils.clamp'].medianHz).toBe(120); + expect(aggregated.results['utils.clamp'].p50Ms).toBe(9); + }); + it('rejects incompatible snapshots', () => { expect(() => aggregateBenchmarkSnapshots([ diff --git a/scripts/bench/aggregate-results.ts b/scripts/bench/aggregate-results.ts index 87c94860..4b76e5e4 100644 --- a/scripts/bench/aggregate-results.ts +++ b/scripts/bench/aggregate-results.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadExportsBenchmarkSnapshot, resolveCliValue, runCliMain } from './cli-utils.ts'; import type { BenchmarkTaskStats, ExportsBenchmarkSnapshot } from './exports.types.ts'; +import { median } from './task-stats.ts'; interface CliDefaults { outputPath: string; @@ -22,7 +23,9 @@ const DEFAULTS: CliDefaults = { const BENCHMARK_RESULT_FIELDS = [ 'hz', + 'medianHz', 'meanMs', + 'p50Ms', 'p75Ms', 'p99Ms', 'minMs', @@ -120,19 +123,6 @@ function aggregateTaskStats(values: readonly BenchmarkTaskStats[]): BenchmarkTas return aggregated; } -function median(values: readonly number[]): number { - if (values.length === 0) { - return 0; - } - - const sorted = [...values].sort((left, right) => left - right); - const middleIndex = Math.floor(sorted.length / 2); - if (sorted.length % 2 === 1) { - return sorted[middleIndex]; - } - return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2; -} - function parseArgs(args: string[], defaults: CliDefaults): CliOptions { const options: CliOptions = { inputPaths: [], diff --git a/scripts/bench/cli-utils.test.ts b/scripts/bench/cli-utils.test.ts index bce1322f..1a9a4de0 100644 --- a/scripts/bench/cli-utils.test.ts +++ b/scripts/bench/cli-utils.test.ts @@ -57,7 +57,9 @@ function createSnapshot(overrides: Partial = {}): Expo results: { 'utils.clamp': { hz: 100, + medianHz: 100, meanMs: 10, + p50Ms: 10, p75Ms: 11, p99Ms: 12, minMs: 9, diff --git a/scripts/bench/compare-results.test.ts b/scripts/bench/compare-results.test.ts index ca0cfa01..8edf93f3 100644 --- a/scripts/bench/compare-results.test.ts +++ b/scripts/bench/compare-results.test.ts @@ -49,7 +49,9 @@ function createSnapshot( key, { hz, + medianHz: hz, meanMs: hz === 0 ? 0 : 1000 / hz, + p50Ms: hz === 0 ? 0 : 1000 / hz, p75Ms: 11, p99Ms: 12, minMs: 9, @@ -93,6 +95,37 @@ describe('compareSnapshots', () => { expect(rows[0]?.headHz).toBe(110); expect(rows[0]?.deltaPercent).toBeCloseTo(10); }); + + it('compares median ops/s when mean hz disagrees', () => { + const rows = compareSnapshots( + createSnapshot({ 'utils.case': 100 }), + createSnapshot( + { 'utils.case': 100 }, + { + results: { + 'utils.case': { + hz: 50, + medianHz: 110, + meanMs: 20, + p50Ms: 9.091, + p75Ms: 11, + p99Ms: 12, + minMs: 9, + maxMs: 40, + rmePercent: 1, + sampleCount: 100, + totalTimeMs: 200, + }, + }, + }, + ), + ); + + expect(rows).toHaveLength(1); + expect(rows[0]?.baseHz).toBe(100); + expect(rows[0]?.headHz).toBe(110); + expect(rows[0]?.deltaPercent).toBeCloseTo(10); + }); }); describe('summarizeRows', () => { @@ -165,6 +198,73 @@ describe('buildDiffMarkdown', () => { expect(markdown).toContain('| Median change | +1.00% |'); expect(markdown).toContain('| Cases regressed (<= -threshold) | 1 |'); expect(markdown).toContain('Overall verdict uses the median change'); + expect(markdown).toContain('Per-case lists compare median ops/s, not mean.'); expect(markdown).toContain('`utils.noisy`'); }); + + it('ranks top lists by median ops/s, not mean hz', () => { + const markdown = buildDiffMarkdown( + createSnapshot({ + 'utils.meanOnly': 100, + 'utils.medianReal': 100, + 'utils.stable': 100, + }), + createSnapshot( + { + 'utils.meanOnly': 100, + 'utils.medianReal': 100, + 'utils.stable': 100, + }, + { + results: { + 'utils.meanOnly': { + hz: 50, + medianHz: 99, + meanMs: 20, + p50Ms: 10.101, + p75Ms: 11, + p99Ms: 12, + minMs: 9, + maxMs: 40, + rmePercent: 1, + sampleCount: 100, + totalTimeMs: 200, + }, + 'utils.medianReal': { + hz: 99, + medianHz: 50, + meanMs: 10.101, + p50Ms: 20, + p75Ms: 21, + p99Ms: 22, + minMs: 9, + maxMs: 40, + rmePercent: 1, + sampleCount: 100, + totalTimeMs: 200, + }, + 'utils.stable': { + hz: 101, + medianHz: 101, + meanMs: 9.901, + p50Ms: 9.901, + p75Ms: 11, + p99Ms: 12, + minMs: 9, + maxMs: 13, + rmePercent: 1, + sampleCount: 100, + totalTimeMs: 200, + }, + }, + }, + ), + 8, + 12, + ); + + expect(markdown).toContain('`utils.medianReal`'); + expect(markdown).toContain('| `utils.medianReal` | 100.00 | 50.00 | -50.00% |'); + expect(markdown).not.toContain('`utils.meanOnly`'); + }); }); diff --git a/scripts/bench/compare-results.ts b/scripts/bench/compare-results.ts index bf784e3f..00afa627 100644 --- a/scripts/bench/compare-results.ts +++ b/scripts/bench/compare-results.ts @@ -10,6 +10,7 @@ import { runCliMain, } from './cli-utils.ts'; import type { ExportsBenchmarkSnapshot } from './exports.types.ts'; +import { resolveComparisonHz } from './task-stats.ts'; interface CliDefaults { outputPath: string; @@ -130,7 +131,8 @@ export function buildDiffMarkdown( lines.push(`- Head SHA: \`${formatSha(headSnapshot.gitSha)}\``); lines.push(`- Comparable cases: \`${summary.comparableCaseCount}\``); lines.push(`- Regression threshold: \`${thresholdPercent.toFixed(2)}%\``); - lines.push(`- Overall verdict uses the median change. Per-case lists are for inspection.`); + lines.push(`- Overall verdict uses the median change across cases.`); + lines.push(`- Per-case lists compare median ops/s, not mean.`); lines.push(`- Base runs: \`${formatRunCount(baseSnapshot)}\``); lines.push(`- Head runs: \`${formatRunCount(headSnapshot)}\``); lines.push(''); @@ -151,7 +153,7 @@ export function buildDiffMarkdown( if (regressions.length === 0) { lines.push('No regression over threshold.'); } else { - lines.push('| API | Base ops/s | Head ops/s | Change |'); + lines.push('| API | Base median ops/s | Head median ops/s | Change |'); lines.push('| --- | ---: | ---: | ---: |'); for (const row of regressions) { lines.push( @@ -165,7 +167,7 @@ export function buildDiffMarkdown( if (improvements.length === 0) { lines.push('No improvement over threshold.'); } else { - lines.push('| API | Base ops/s | Head ops/s | Change |'); + lines.push('| API | Base median ops/s | Head median ops/s | Change |'); lines.push('| --- | ---: | ---: | ---: |'); for (const row of improvements) { lines.push( @@ -188,7 +190,11 @@ export function buildDiffMarkdown( function buildHeadOnlyMarkdown(headSnapshot: ExportsBenchmarkSnapshot, topCount: number, basePath?: string): string { const slowest = Object.entries(headSnapshot.results) - .map(([key, value]) => ({ key, hz: value.hz, meanMs: value.meanMs })) + .map(([key, value]) => ({ + key, + hz: resolveComparisonHz(value), + meanMs: value.p50Ms ?? value.meanMs, + })) .sort((left, right) => left.hz - right.hz) .slice(0, topCount); @@ -209,7 +215,7 @@ function buildHeadOnlyMarkdown(headSnapshot: ExportsBenchmarkSnapshot, topCount: if (slowest.length === 0) { lines.push('No benchmark result found.'); } else { - lines.push('| API | ops/s | mean (ms) |'); + lines.push('| API | median ops/s | median (ms) |'); lines.push('| --- | ---: | ---: |'); for (const row of slowest) { lines.push(`| \`${row.key}\` | ${formatOps(row.hz)} | ${row.meanMs.toFixed(4)} |`); @@ -225,14 +231,19 @@ export function compareSnapshots( const rows: ComparedRow[] = []; for (const [key, headResult] of Object.entries(headSnapshot.results)) { const baseResult = baseSnapshot.results[key]; - if (!baseResult || !Number.isFinite(baseResult.hz) || baseResult.hz <= 0) { + if (!baseResult) { + continue; + } + const baseHz = resolveComparisonHz(baseResult); + const headHz = resolveComparisonHz(headResult); + if (baseHz <= 0 || headHz <= 0) { continue; } - const deltaPercent = (headResult.hz / baseResult.hz - 1) * 100; + const deltaPercent = (headHz / baseHz - 1) * 100; rows.push({ key, - baseHz: baseResult.hz, - headHz: headResult.hz, + baseHz, + headHz, deltaPercent, }); } @@ -240,10 +251,7 @@ export function compareSnapshots( return rows; } -export function resolveOverallVerdict( - medianDeltaPercent: number, - thresholdPercent: number, -): BenchmarkOverallVerdict { +export function resolveOverallVerdict(medianDeltaPercent: number, thresholdPercent: number): BenchmarkOverallVerdict { if (medianDeltaPercent >= thresholdPercent) { return 'improved'; } diff --git a/scripts/bench/exports.ts b/scripts/bench/exports.ts index ccc793f8..74e3e127 100644 --- a/scripts/bench/exports.ts +++ b/scripts/bench/exports.ts @@ -3,11 +3,12 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; -import { Bench, type TaskResult } from 'tinybench'; +import { Bench } from 'tinybench'; import * as jsonApi from '@be-music/json'; import * as parserApi from '@be-music/parser'; import type { RenderResult } from '@be-music/audio-renderer'; import { parseNonNegativeCliNumber, parsePositiveCliNumber, resolveCliValue, runCliMain } from './cli-utils.ts'; +import { convertTaskResult, createSingleIterationStats, resolveComparisonHz } from './task-stats.ts'; import { PACKAGE_NAMES, type PackageName, @@ -257,100 +258,19 @@ async function runBenchmarkCase( throw new Error(`Benchmark result is unavailable: ${key}`); } const converted = convertTaskResult(result); - if (converted.hz > 0 && converted.sampleCount > 0 && Number.isFinite(converted.meanMs)) { + if (resolveComparisonHz(converted) > 0 && converted.sampleCount > 0 && Number.isFinite(converted.meanMs)) { return converted; } return runSingleIterationFallback(benchmarkCase, fixtures); } -function convertTaskResult(result: TaskResult): BenchmarkTaskStats { - const typedResult = result as TaskResult & { - mean?: number; - p75?: number; - p99?: number; - min?: number; - max?: number; - hz?: number; - rme?: number; - samples?: number[]; - totalTime?: number; - period?: number; - latency?: { - min?: number; - max?: number; - p75?: number; - p99?: number; - samplesCount?: number; - }; - throughput?: { - mean?: number; - rme?: number; - samplesCount?: number; - }; - }; - const isV6 = - typeof typedResult.period === 'number' || - typeof typedResult.throughput?.mean === 'number' || - typeof typedResult.latency?.samplesCount === 'number'; - if (isV6) { - const periodSeconds = typedResult.period; - const latency = typedResult.latency; - const throughput = typedResult.throughput; - // tinybench v6 exposes period/latency in milliseconds. - const meanMs = Number.isFinite(periodSeconds) ? periodSeconds : Number.NaN; - const p75Ms = Number.isFinite(latency?.p75) ? (latency?.p75 ?? meanMs) : meanMs; - const p99Ms = Number.isFinite(latency?.p99) ? (latency?.p99 ?? meanMs) : meanMs; - const minMs = Number.isFinite(latency?.min) ? (latency?.min ?? meanMs) : meanMs; - const maxMs = Number.isFinite(latency?.max) ? (latency?.max ?? meanMs) : meanMs; - return { - hz: Number.isFinite(throughput?.mean) ? (throughput?.mean ?? 0) : 0, - meanMs, - p75Ms, - p99Ms, - minMs, - maxMs, - rmePercent: Number.isFinite(throughput?.rme) ? (throughput?.rme ?? 0) : 0, - sampleCount: - Number.isFinite(latency?.samplesCount) && (latency?.samplesCount ?? 0) > 0 - ? Math.floor(latency?.samplesCount ?? 0) - : 0, - totalTimeMs: Number.isFinite(typedResult.totalTime) ? (typedResult.totalTime ?? 0) : 0, - }; - } - - const samples = Array.isArray(typedResult.samples) ? typedResult.samples : []; - const meanMs = Number.isFinite(typedResult.mean) ? (typedResult.mean ?? Number.NaN) : Number.NaN; - return { - hz: Number.isFinite(typedResult.hz) ? (typedResult.hz ?? 0) : 0, - meanMs, - p75Ms: Number.isFinite(typedResult.p75) ? (typedResult.p75 ?? meanMs) : meanMs, - p99Ms: Number.isFinite(typedResult.p99) ? (typedResult.p99 ?? meanMs) : meanMs, - minMs: Number.isFinite(typedResult.min) ? (typedResult.min ?? meanMs) : meanMs, - maxMs: Number.isFinite(typedResult.max) ? (typedResult.max ?? meanMs) : meanMs, - rmePercent: Number.isFinite(typedResult.rme) ? (typedResult.rme ?? 0) : 0, - sampleCount: samples.length, - totalTimeMs: Number.isFinite(typedResult.totalTime) ? (typedResult.totalTime ?? 0) : 0, - }; -} - async function runSingleIterationFallback( benchmarkCase: BenchmarkCaseDefinition, fixtures: BenchFixtures, ): Promise { const startedAt = performance.now(); await benchmarkCase.run(fixtures); - const durationMs = Math.max(0.000001, performance.now() - startedAt); - return { - hz: 1000 / durationMs, - meanMs: durationMs, - p75Ms: durationMs, - p99Ms: durationMs, - minMs: durationMs, - maxMs: durationMs, - rmePercent: 0, - sampleCount: 1, - totalTimeMs: durationMs, - }; + return createSingleIterationStats(performance.now() - startedAt); } async function createBenchFixtures(): Promise { @@ -619,11 +539,11 @@ function printSummary(snapshot: ExportsBenchmarkSnapshot, outputPath: string): v if (benchmarkedKeys.length > 0) { const topSlow = benchmarkedKeys - .map((key) => ({ key, hz: snapshot.results[key].hz })) + .map((key) => ({ key, hz: resolveComparisonHz(snapshot.results[key]) })) .sort((left, right) => left.hz - right.hz) .slice(0, 5); - process.stdout.write('\nSlowest 5 (ops/s)\n'); + process.stdout.write('\nSlowest 5 (median ops/s)\n'); for (const row of topSlow) { process.stdout.write(` ${row.key.padEnd(48)} ${row.hz.toFixed(2)}\n`); } diff --git a/scripts/bench/exports.types.ts b/scripts/bench/exports.types.ts index e37514e0..4ed6bd32 100644 --- a/scripts/bench/exports.types.ts +++ b/scripts/bench/exports.types.ts @@ -25,7 +25,9 @@ export interface ExportsBenchmarkCliOverrides { export interface BenchmarkTaskStats { hz: number; + medianHz: number; meanMs: number; + p50Ms: number; p75Ms: number; p99Ms: number; minMs: number; diff --git a/scripts/bench/task-stats.test.ts b/scripts/bench/task-stats.test.ts new file mode 100644 index 00000000..d695e2f6 --- /dev/null +++ b/scripts/bench/task-stats.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import type { TaskResult } from 'tinybench'; +import { convertTaskResult, createSingleIterationStats, median, resolveComparisonHz } from './task-stats.ts'; + +describe('median', () => { + it('returns 0 for an empty list', () => { + expect(median([])).toBe(0); + }); + + it('returns the middle value for an odd-length list', () => { + expect(median([3, 1, 2])).toBe(2); + }); + + it('averages the two middle values for an even-length list', () => { + expect(median([1, 2, 3, 4])).toBe(2.5); + }); +}); + +describe('resolveComparisonHz', () => { + it('prefers median ops/s when it is a positive finite value', () => { + expect(resolveComparisonHz({ hz: 50, medianHz: 99 })).toBe(99); + }); + + it('falls back to mean hz when median ops/s is missing or non-positive', () => { + expect(resolveComparisonHz({ hz: 50 })).toBe(50); + expect(resolveComparisonHz({ hz: 50, medianHz: 0 })).toBe(50); + expect(resolveComparisonHz({ hz: 50, medianHz: Number.NaN })).toBe(50); + }); + + it('returns 0 when neither value is usable', () => { + expect(resolveComparisonHz({ hz: 0, medianHz: 0 })).toBe(0); + }); +}); + +describe('convertTaskResult', () => { + it('records tinybench v6 median throughput separately from the mean', () => { + const stats = convertTaskResult({ + period: 10, + totalTime: 200, + latency: { + min: 8, + max: 20, + p50: 9, + p75: 11, + p99: 18, + samplesCount: 40, + }, + throughput: { + mean: 100, + p50: 111, + rme: 2, + }, + } as TaskResult); + + expect(stats.hz).toBe(100); + expect(stats.medianHz).toBe(111); + expect(stats.meanMs).toBe(10); + expect(stats.p50Ms).toBe(9); + expect(stats.sampleCount).toBe(40); + }); + + it('falls back to mean throughput when v6 p50 is missing', () => { + const stats = convertTaskResult({ + period: 10, + totalTime: 200, + latency: { + samplesCount: 10, + }, + throughput: { + mean: 80, + }, + } as TaskResult); + + expect(stats.hz).toBe(80); + expect(stats.medianHz).toBe(80); + expect(stats.p50Ms).toBe(10); + }); + + it('derives median ops/s from legacy sample periods', () => { + const stats = convertTaskResult({ + hz: 100, + mean: 10, + samples: [8, 10, 20], + totalTime: 200, + } as TaskResult); + + expect(stats.hz).toBe(100); + expect(stats.p50Ms).toBe(10); + expect(stats.medianHz).toBe(100); + expect(stats.sampleCount).toBe(3); + }); +}); + +describe('createSingleIterationStats', () => { + it('uses the same value for mean and median when only one sample exists', () => { + const stats = createSingleIterationStats(4); + expect(stats.hz).toBe(250); + expect(stats.medianHz).toBe(250); + expect(stats.meanMs).toBe(4); + expect(stats.p50Ms).toBe(4); + expect(stats.sampleCount).toBe(1); + }); +}); diff --git a/scripts/bench/task-stats.ts b/scripts/bench/task-stats.ts new file mode 100644 index 00000000..23877894 --- /dev/null +++ b/scripts/bench/task-stats.ts @@ -0,0 +1,128 @@ +import type { TaskResult } from 'tinybench'; +import type { BenchmarkTaskStats } from './exports.types.ts'; + +type LegacyTaskResult = TaskResult & { + mean?: number; + p50?: number; + p75?: number; + p99?: number; + min?: number; + max?: number; + hz?: number; + rme?: number; + samples?: number[]; + totalTime?: number; + period?: number; + latency?: { + min?: number; + max?: number; + p50?: number; + p75?: number; + p99?: number; + samplesCount?: number; + }; + throughput?: { + mean?: number; + p50?: number; + rme?: number; + samplesCount?: number; + }; +}; + +export function resolveComparisonHz(stats: Pick & { medianHz?: number }): number { + if (Number.isFinite(stats.medianHz) && (stats.medianHz ?? 0) > 0) { + return stats.medianHz ?? 0; + } + return Number.isFinite(stats.hz) && stats.hz > 0 ? stats.hz : 0; +} + +export function median(values: readonly number[]): number { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((left, right) => left - right); + const middleIndex = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) { + return sorted[middleIndex] ?? 0; + } + return ((sorted[middleIndex - 1] ?? 0) + (sorted[middleIndex] ?? 0)) / 2; +} + +export function createSingleIterationStats(durationMs: number): BenchmarkTaskStats { + const safeDurationMs = Math.max(0.000001, durationMs); + const hz = 1000 / safeDurationMs; + return { + hz, + medianHz: hz, + meanMs: safeDurationMs, + p50Ms: safeDurationMs, + p75Ms: safeDurationMs, + p99Ms: safeDurationMs, + minMs: safeDurationMs, + maxMs: safeDurationMs, + rmePercent: 0, + sampleCount: 1, + totalTimeMs: safeDurationMs, + }; +} + +export function convertTaskResult(result: TaskResult): BenchmarkTaskStats { + const typedResult = result as LegacyTaskResult; + const isV6 = + typeof typedResult.period === 'number' || + typeof typedResult.throughput?.mean === 'number' || + typeof typedResult.latency?.samplesCount === 'number'; + if (isV6) { + const periodMs = typedResult.period; + const latency = typedResult.latency; + const throughput = typedResult.throughput; + // tinybench v6 exposes period/latency in milliseconds. + const meanMs = finiteNumber(periodMs); + const meanHz = finitePositive(throughput?.mean); + const medianHz = finitePositive(throughput?.p50, meanHz); + return { + hz: meanHz, + medianHz, + meanMs, + p50Ms: finitePositive(latency?.p50, meanMs), + p75Ms: finiteNumber(latency?.p75, meanMs), + p99Ms: finiteNumber(latency?.p99, meanMs), + minMs: finiteNumber(latency?.min, meanMs), + maxMs: finiteNumber(latency?.max, meanMs), + rmePercent: finiteNumber(throughput?.rme), + sampleCount: + Number.isFinite(latency?.samplesCount) && (latency?.samplesCount ?? 0) > 0 + ? Math.floor(latency?.samplesCount ?? 0) + : 0, + totalTimeMs: finiteNumber(typedResult.totalTime), + }; + } + + const samples = Array.isArray(typedResult.samples) ? typedResult.samples : []; + const meanMs = finiteNumber(typedResult.mean); + const meanHz = finitePositive(typedResult.hz); + const p50Ms = finitePositive(typedResult.p50, samples.length > 0 ? median(samples) : meanMs); + const medianHz = p50Ms > 0 ? 1000 / p50Ms : meanHz; + return { + hz: meanHz, + medianHz, + meanMs, + p50Ms, + p75Ms: finiteNumber(typedResult.p75, meanMs), + p99Ms: finiteNumber(typedResult.p99, meanMs), + minMs: finiteNumber(typedResult.min, meanMs), + maxMs: finiteNumber(typedResult.max, meanMs), + rmePercent: finiteNumber(typedResult.rme), + sampleCount: samples.length, + totalTimeMs: finiteNumber(typedResult.totalTime), + }; +} + +function finiteNumber(value: number | undefined, fallback = Number.NaN): number { + return Number.isFinite(value) ? (value ?? fallback) : fallback; +} + +function finitePositive(value: number | undefined, fallback = 0): number { + return Number.isFinite(value) && (value ?? 0) > 0 ? (value ?? fallback) : fallback; +}