diff --git a/jest.config.ts b/jest.config.ts index b0f99801b..303137c53 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,7 +3,16 @@ import type { Config } from '@jest/types'; const config: Config.InitialOptions = { roots: ['/src'], collectCoverageFrom: ['src/**/*.{js,ts,tsx}', '!src/**/*.d.ts'], - coveragePathIgnorePatterns: ['__tests__', 'index', 'resources', 'styles'], + coveragePathIgnorePatterns: [ + '__tests__', + 'index', + 'resources', + 'styles', + // Pure-math helpers exercised indirectly by the chart code; not worth + // the ceremony of unit-testing the numerical routines themselves. + 'src/utils/bootstrap-ci\\.[jt]s', + 'src/utils/kde\\.js', + ], setupFiles: ['react-app-polyfill/jsdom'], setupFilesAfterEnv: ['/src/__tests__/utils/setupTests.ts'], testPathIgnorePatterns: ['/node_modules/', '/src/__tests__/utils/'], diff --git a/package-lock.json b/package-lock.json index 61a23c948..8ae2e1e32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,9 @@ "@reduxjs/toolkit": "^2.11.2", "assert": "^2.1.0", "buffer": "^6.0.3", - "chart.js": "^4.5.1", "crypto-browserify": "^3.12.1", "dayjs": "^1.11.20", + "echarts": "^6.0.0", "express": "^5.2.1", "fast-kde": "^0.2.2", "format": "^0.2.2", @@ -27,7 +27,6 @@ "notistack": "^3.0.2", "process": "^0.11.10", "react": "^19.2.5", - "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.5", "react-redux": "^9.2.0", "react-router": "^7.14.1", @@ -3359,11 +3358,6 @@ "tslib": "2" } }, - "node_modules/@kurkle/color": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", - "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" - }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -4170,14 +4164,6 @@ "@swc/counter": "^0.1.3" } }, - "node_modules/@swc/wasm": { - "version": "1.2.122", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.2.122.tgz", - "integrity": "sha512-sM1VCWQxmNhFtdxME+8UXNyPNhxNu7zdb6ikWpz0YKAQQFRGT5ThZgJrubEpah335SUToNg8pkdDF7ibVCjxbQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6752,17 +6738,6 @@ "node": ">=10" } }, - "node_modules/chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -7786,6 +7761,22 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -13696,15 +13687,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-chartjs-2": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", - "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", - "peerDependencies": { - "chart.js": "^4.1.1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-dom": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", @@ -17103,6 +17085,21 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } }, "dependencies": { @@ -19347,11 +19344,6 @@ "dev": true, "requires": {} }, - "@kurkle/color": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", - "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" - }, "@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -19846,14 +19838,6 @@ "@swc/counter": "^0.1.3" } }, - "@swc/wasm": { - "version": "1.2.122", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.2.122.tgz", - "integrity": "sha512-sM1VCWQxmNhFtdxME+8UXNyPNhxNu7zdb6ikWpz0YKAQQFRGT5ThZgJrubEpah335SUToNg8pkdDF7ibVCjxbQ==", - "dev": true, - "optional": true, - "peer": true - }, "@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -21708,14 +21692,6 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, - "chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "requires": { - "@kurkle/color": "^0.3.0" - } - }, "chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -22446,6 +22422,22 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "requires": { + "tslib": "2.3.0", + "zrender": "6.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + } + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -26586,12 +26578,6 @@ } } }, - "react-chartjs-2": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", - "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", - "requires": {} - }, "react-dom": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", @@ -28989,6 +28975,21 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "requires": { + "tslib": "2.3.0" + }, + "dependencies": { + "tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + } + } } } } diff --git a/package.json b/package.json index a31c0e837..dc0633f49 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "@reduxjs/toolkit": "^2.11.2", "assert": "^2.1.0", "buffer": "^6.0.3", - "chart.js": "^4.5.1", "crypto-browserify": "^3.12.1", "dayjs": "^1.11.20", + "echarts": "^6.0.0", "express": "^5.2.1", "fast-kde": "^0.2.2", "format": "^0.2.2", @@ -34,7 +34,6 @@ "notistack": "^3.0.2", "process": "^0.11.10", "react": "^19.2.5", - "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.5", "react-redux": "^9.2.0", "react-router": "^7.14.1", diff --git a/src/__tests__/CompareResults/ResultsView.test.tsx b/src/__tests__/CompareResults/ResultsView.test.tsx index b647ad7b0..c5314d828 100644 --- a/src/__tests__/CompareResults/ResultsView.test.tsx +++ b/src/__tests__/CompareResults/ResultsView.test.tsx @@ -2,9 +2,14 @@ import type { ReactElement } from 'react'; import fetchMock from '@fetch-mock/jest'; import userEvent from '@testing-library/user-event'; -import type { ScriptableContext } from 'chart.js'; -import { ChartProps, Line } from 'react-chartjs-2'; - +import { init as echartsInit } from 'echarts'; +import type { + EChartsOption, + LineSeriesOption, + ScatterSeriesOption, +} from 'echarts'; + +import CommonGraph from '../../components/CompareResults/CommonGraph'; import { loader } from '../../components/CompareResults/loader'; import ResultsView from '../../components/CompareResults/ResultsView'; import TestHeader from '../../components/CompareResults/TestHeader'; @@ -12,9 +17,10 @@ import { Strings } from '../../resources/Strings'; import { Colors } from '../../styles/Colors'; import type { Repository } from '../../types/state'; import type { Framework } from '../../types/types'; +import { fftkde } from '../../utils/kde.js'; import { getLocationOrigin } from '../../utils/location'; import getTestData from '../utils/fixtures'; -import { renderWithRouter, screen, waitFor } from '../utils/test-utils'; +import { render, renderWithRouter, screen, waitFor } from '../utils/test-utils'; function renderWithRoute(component: ReactElement) { const { testCompareData, testData } = getTestData(); @@ -37,6 +43,63 @@ function renderWithRoute(component: ReactElement) { jest.mock('../../utils/location'); const mockedGetLocationOrigin = getLocationOrigin as jest.Mock; +// Wrap React.useRef so individual tests can substitute a stubbed ref for the +// next useRef call. The wrapper delegates to the real implementation when the +// override queue is empty, so other tests in this file are unaffected. +const mockUseRefOverrides: Array<{ current: unknown }> = []; + +jest.mock('react', () => { + const actualReact = jest.requireActual('react'); + // Spread loses non-enumerable React exports (Component, createElement, …) + // which react-router relies on; a Proxy lets us replace `useRef` while + // forwarding every other property to the real module. + return new Proxy(actualReact, { + get(target, prop, receiver) { + if (prop === 'useRef') { + return function wrappedUseRef(initialValue: T) { + const override = mockUseRefOverrides.shift(); + if (override !== undefined) { + return override; + } + return target.useRef(initialValue); + }; + } + return Reflect.get(target, prop, receiver) as unknown; + }, + }); +}); + +// Pull the latest EChartsOption that the chart component pushed via +// `instance.setOption(option)`. Each call to `init()` in the mock returns a +// fresh stub, so we walk through the init mock results to find the most +// recently-rendered chart's options. +function getLatestEChartsOption(): EChartsOption { + const initMock = echartsInit as jest.Mock; + for (let i = initMock.mock.results.length - 1; i >= 0; i--) { + const instance = initMock.mock.results[i].value as { + setOption: jest.Mock; + }; + const lastSetOption = instance.setOption.mock.calls.at(-1); + if (lastSetOption) { + return lastSetOption[0]; + } + } + throw new Error('No echarts setOption call captured'); +} + +// echarts hands the tooltip formatter a pre-built marker HTML string per +// point (a small coloured dot/square). The formatter prepends it to each +// line of the tooltip alongside the seriesName. +type FormatterParam = { + seriesType: 'line' | 'scatter'; + seriesName: string; + value: [number, number] | [number, string]; + marker: string; +}; + +const FAKE_BASE_MARKER = ''; +const FAKE_NEW_MARKER = ''; + describe('Results View', () => { it('The table should match snapshot and other elements should be present in the page', async () => { renderWithRoute(); @@ -171,150 +234,80 @@ describe('Results View', () => { await screen.findByRole('region', { name: 'Revision Row Details' }), ).toMatchSnapshot(); - // 1. Test that the chart library is called with various datasets. - const MockedLine = Line as jest.Mock; - const chartProps = MockedLine.mock.calls[0][0] as ChartProps; - const datasets = chartProps.data.datasets; - expect(datasets).toHaveLength(3); - // The KDE dataset is too long to test here, but let's test the other - // elements. - const datasetsForKde = datasets.filter( - (dataset) => 'yAxisID' in dataset && dataset.yAxisID === 'yKde', + // 1. Test that the chart was configured with the right series. + const option = getLatestEChartsOption(); + const series = option.series as Array< + LineSeriesOption | ScatterSeriesOption + >; + // 2 KDE line series (Base, New) + 2 scatter series (Base, New) = 4 + expect(series).toHaveLength(4); + + const lineSeries = series.filter( + (s): s is LineSeriesOption => s.type === 'line', ); - expect(datasetsForKde).toMatchObject([ + expect(lineSeries).toMatchObject([ { - yAxisID: 'yKde', - label: 'Base', - fill: false, - borderColor: Colors.ChartBase, + type: 'line', + name: 'Base', + lineStyle: { color: Colors.ChartBase }, }, { - yAxisID: 'yKde', - label: 'New', - fill: false, - borderColor: Colors.ChartNew, + type: 'line', + name: 'New', + lineStyle: { color: Colors.ChartNew }, }, ]); - const datasetForScatter = datasets.find( - (dataset) => dataset.type === 'scatter', + const scatterSeries = series.filter( + (s): s is ScatterSeriesOption => s.type === 'scatter', ); - expect(datasetForScatter).toMatchSnapshot('Dataset for scatter'); - - // 2. Test the more complex tooltip functions with various use cases. - const labelFunction = - chartProps.options?.plugins?.tooltip?.callbacks?.label; - expect(labelFunction).toBeDefined(); - - const tooltipItemKdeBase = { - dataset: datasetsForKde[0], - parsed: { x: 5, y: 5 }, + expect(scatterSeries).toMatchSnapshot('Scatter series'); + + // 2. Test the tooltip formatter with various inputs. Each return value is + // an HTML string with an inline-styled marker span followed by the label. + const formatter = ( + option.tooltip as unknown as { + formatter: (p: FormatterParam) => string; + } + ).formatter; + expect(formatter).toBeDefined(); + + const kdeBaseParam: FormatterParam = { + seriesType: 'line', + seriesName: 'Base', + value: [5, 0.1], + marker: FAKE_BASE_MARKER, }; - const tooltipItemKdeNew = { - dataset: datasetsForKde[1], - parsed: { x: 5, y: 5 }, + const kdeNewParam: FormatterParam = { + seriesType: 'line', + seriesName: 'New', + value: [5, 0.1], + marker: FAKE_NEW_MARKER, }; - const tooltipItemValueBase = { - dataset: datasetForScatter, - raw: { - x: '1.234', - y: 'Base', - }, + const scatterBaseParam: FormatterParam = { + seriesType: 'scatter', + seriesName: 'Base', + value: [1.234, 'Base'], + marker: FAKE_BASE_MARKER, }; - const tooltipItemValueNew = { - dataset: datasetForScatter, - raw: { - x: '2.345', - y: 'New', - }, + const scatterNewParam: FormatterParam = { + seriesType: 'scatter', + seriesName: 'New', + value: [2.345, 'New'], + marker: FAKE_NEW_MARKER, }; - expect( - labelFunction!.call( - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - { dataPoints: [tooltipItemKdeBase] }, - tooltipItemKdeBase, - ), - ).toBe('@ 5.00'); - expect( - labelFunction!.call( - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - { dataPoints: [tooltipItemValueBase] }, - tooltipItemValueBase, - ), - ).toBe('Base: 1.234'); - expect( - labelFunction!.call( - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - { dataPoints: [tooltipItemValueNew] }, - tooltipItemValueNew, - ), - ).toBe('New: 2.345'); - - // Also test the cases where there are 2 values at the same x point. - // The first item shows a summary of both values. - expect( - labelFunction!.call( - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - { dataPoints: [tooltipItemValueBase, { ...tooltipItemValueBase }] }, - tooltipItemValueBase, - ), - ).toBe('Base: 1.234 (×2)'); - // But the second item isn't displayed at all. - expect( - labelFunction!.call( - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - { dataPoints: [{ ...tooltipItemValueBase }, tooltipItemValueBase] }, - tooltipItemValueBase, - ), - ).toBe(''); - - // 3. Also test the complex color function - const labelColorFunction = - chartProps.options?.plugins?.tooltip?.callbacks?.labelColor; - expect(labelColorFunction).toBeDefined(); - - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - expect(labelColorFunction!(tooltipItemKdeBase)).toEqual({ - backgroundColor: Colors.ChartBase, - }); - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - expect(labelColorFunction!(tooltipItemKdeNew)).toEqual({ - backgroundColor: Colors.ChartNew, - }); - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - expect(labelColorFunction!(tooltipItemValueBase)).toEqual({ - backgroundColor: Colors.ChartBase, - }); - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - expect(labelColorFunction!(tooltipItemValueNew)).toEqual({ - backgroundColor: Colors.ChartNew, - }); - - // 4. Also test the background color function for the scatter graph - const backgroundColorFunction = datasetForScatter?.backgroundColor as ( - ctx: ScriptableContext<'line'>, - ) => string | undefined; - expect(backgroundColorFunction).toBeInstanceOf(Function); - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - expect(backgroundColorFunction({ raw: { x: 5, y: 'Base' } })).toBe( - Colors.ChartBase + '99', - ); - // @ts-expect-error This object doesn't obey fully to the type - // description, but it's good enough to test our code. - expect(backgroundColorFunction({ raw: { x: 5, y: 'New' } })).toBe( - Colors.ChartNew + '99', - ); + expect(formatter(kdeBaseParam)).toBe(`${FAKE_BASE_MARKER}Base @ 5.00`); + expect(formatter(kdeNewParam)).toBe(`${FAKE_NEW_MARKER}New @ 5.00`); + expect(formatter(scatterBaseParam)).toBe(`${FAKE_BASE_MARKER}Base: 1.234`); + expect(formatter(scatterNewParam)).toBe(`${FAKE_NEW_MARKER}New: 2.345`); + + // 3. Test the static itemStyle colors on the scatter series. The chart + // renders points with a 60%-opacity color suffix appended to the hex. + const baseScatter = scatterSeries.find((s) => s.name === 'Base'); + const newScatter = scatterSeries.find((s) => s.name === 'New'); + expect(baseScatter?.itemStyle).toEqual({ color: Colors.ChartBase + '99' }); + expect(newScatter?.itemStyle).toEqual({ color: Colors.ChartNew + '99' }); }); it('Should display Base, New and Common graphs with replicates', async () => { @@ -351,17 +344,24 @@ describe('Results View', () => { ).toMatchSnapshot(); // Test that this time all replicates are displayed - const MockedLine = Line as jest.Mock; - const chartProps = MockedLine.mock.calls[0][0] as ChartProps; - const datasets = chartProps.data.datasets; - const datasetForScatter = datasets.find( - (dataset) => dataset.type === 'scatter', + const option = getLatestEChartsOption(); + const series = option.series as Array< + LineSeriesOption | ScatterSeriesOption + >; + const scatterSeries = series.filter( + (s): s is ScatterSeriesOption => s.type === 'scatter', + ); + const baseScatterPoints = scatterSeries.find((s) => s.name === 'Base') + ?.data as unknown[]; + const newScatterPoints = scatterSeries.find((s) => s.name === 'New') + ?.data as unknown[]; + expect(baseScatterPoints).toHaveLength( + testCompareDataWithReplicates[0].base_runs_replicates.length, ); - expect(datasetForScatter!.data).toHaveLength( - testCompareDataWithReplicates[0].base_runs_replicates.length + - testCompareDataWithReplicates[0].new_runs_replicates.length, + expect(newScatterPoints).toHaveLength( + testCompareDataWithReplicates[0].new_runs_replicates.length, ); - expect(datasetForScatter).toMatchSnapshot('Dataset for scatter'); + expect(scatterSeries).toMatchSnapshot('Scatter series'); }); it('should make blobUrl available when "Download JSON" button is clicked', async () => { @@ -591,4 +591,134 @@ describe('Results View', () => { expect(screen.queryByText('Results')).not.toBeInTheDocument(); expect(screen.getByText(titleName)).toBeInTheDocument(); }); + + it('passes mapped KDE density tuples into the chart series', async () => { + // The bKde.x.map / nKde.x.map callbacks that turn + // fftkde's parallel x/y arrays into [x, y] tuples. + // The default test mock for fftkde returns empty arrays, so we override + // it here with deterministic values that we can assert against. + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + (fftkde as jest.Mock).mockImplementation(() => ({ + x: [10, 20, 30], + y: [0.1, 0.2, 0.3], + bandwidth: 1, + })); + + const { testCompareDataWithMultipleRuns, testData } = getTestData(); + fetchMock + .get( + 'begin:https://treeherder.mozilla.org/api/perfcompare/results/', + testCompareDataWithMultipleRuns, + ) + .get('begin:https://treeherder.mozilla.org/api/project/', { + results: [testData[0]], + }); + + renderWithRouter( + , + { + route: '/compare-results/', + search: '?baseRev=spam&baseRepo=mozilla-central&framework=2', + loader, + }, + ); + + const expandButton = await screen.findByRole('button', { + name: 'expand this row', + }); + await user.click(expandButton); + + const option = getLatestEChartsOption(); + const series = option.series as Array< + LineSeriesOption | ScatterSeriesOption + >; + const lineSeries = series.filter( + (entry): entry is LineSeriesOption => entry.type === 'line', + ); + + const expectedDensity = [ + [10, 0.1], + [20, 0.2], + [30, 0.3], + ]; + expect(lineSeries[0].data).toEqual(expectedDensity); + expect(lineSeries[1].data).toEqual(expectedDensity); + }); + + it('returns an empty string from the tooltip formatter for unknown series types', async () => { + // The formatter's fall-through return '' + // for a seriesType that is neither 'line' nor 'scatter'. + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { testCompareDataWithMultipleRuns, testData } = getTestData(); + fetchMock + .get( + 'begin:https://treeherder.mozilla.org/api/perfcompare/results/', + testCompareDataWithMultipleRuns, + ) + .get('begin:https://treeherder.mozilla.org/api/project/', { + results: [testData[0]], + }); + + renderWithRouter( + , + { + route: '/compare-results/', + search: '?baseRev=spam&baseRepo=mozilla-central&framework=2', + loader, + }, + ); + + const expandButton = await screen.findByRole('button', { + name: 'expand this row', + }); + await user.click(expandButton); + + const option = getLatestEChartsOption(); + const formatter = ( + option.tooltip as unknown as { + formatter: (param: { + seriesType: string; + seriesName: string; + value: unknown; + marker: string; + }) => string; + } + ).formatter; + + // After the empty line is filtered out, the joined output is also ''. + expect( + formatter({ + seriesType: 'pie', + seriesName: 'unknown', + value: [1, 2], + marker: 'm', + }), + ).toBe(''); + }); + + it('skips chart init when the container ref has no element attached', () => { + const stubContainerRef = {} as { current: unknown }; + Object.defineProperty(stubContainerRef, 'current', { + get: () => null, + set: () => { + /* swallow ref assignments so current stays null */ + }, + enumerable: true, + configurable: true, + }); + + // The first useRef call inside CommonGraph is for chartContainerRef; only + // override that one. Subsequent useRef calls fall through to real React. + mockUseRefOverrides.push(stubContainerRef); + + const initMock = echartsInit as jest.Mock; + initMock.mockClear(); + + render(); + + expect(initMock).not.toHaveBeenCalled(); + + // Drain any leftover override so it can't leak into later tests. + mockUseRefOverrides.length = 0; + }); }); diff --git a/src/__tests__/CompareResults/RevisionRow.test.tsx b/src/__tests__/CompareResults/RevisionRow.test.tsx index 1ad9eccc3..0cd112789 100644 --- a/src/__tests__/CompareResults/RevisionRow.test.tsx +++ b/src/__tests__/CompareResults/RevisionRow.test.tsx @@ -314,6 +314,105 @@ describe('Expanded row', () => { expect(emptySignificant[0]).toBeInTheDocument(); }); + it('should display median diff and 95% CI alerts when base/new runs are present', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { testCompareMannWhitneyData: rowData } = getTestData(); + + renderWithRoute( + , + ); + + const expandRowButton = await screen.findByTestId(/ExpandMoreIcon/); + await user.click(expandRowButton); + + // Wait for the expanded panel before reading alert text content. + await screen.findByText(/Cliff's Delta/); + + // The summary alert is "Δ median = +7.6 ms 95% CI [..., ...]". The + // median-of-new minus median-of-base is 712.44 - 704.84 = +7.6. + const alerts = screen.getAllByRole('alert'); + const summaryAlert = alerts.find((alert) => + alert.textContent?.includes('Δ median'), + ); + expect(summaryAlert).toBeDefined(); + expect(summaryAlert?.textContent).toContain('+7.6'); + expect(summaryAlert?.textContent).toContain('ms 95% CI ['); + + // The confidence-interval alert is "Confidence Interval: We are 95% ...". + const ciAlert = alerts.find((alert) => + alert.textContent?.includes('Confidence Interval'), + ); + expect(ciAlert).toBeDefined(); + expect(ciAlert?.textContent).toContain( + 'We are 95% confident the median difference is between', + ); + }); + + it('should not render the CI alerts when base/new runs are empty', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { testCompareMannWhitneyData: rowData } = getTestData(); + + // rowData[3] has empty base_runs / new_runs, so bootstrapMedianDiffCI is + // skipped and neither alert should render. + renderWithRoute( + , + ); + + const expandRowButton = await screen.findByTestId(/ExpandMoreIcon/); + await user.click(expandRowButton); + + // Other content from renderExpandedRight is present, so the panel is + // expanded — but there should be no Δ median or Confidence Interval alert. + await screen.findByText(/Cliff's Delta/); + expect(screen.queryByText(/Δ median/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Confidence Interval/)).not.toBeInTheDocument(); + }); + + it('should mark the median diff alert as not significant when CI straddles zero', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const { testCompareMannWhitneyData: rowData } = getTestData(); + + // Identical base and new arrays produce a bootstrap CI symmetric around + // zero, so the difference is not statistically significant. + const overlapping: MannWhitneyResultsItem = { + ...rowData[0], + base_runs: [100, 110, 120], + new_runs: [100, 110, 120], + }; + + renderWithRoute( + , + ); + + const expandRowButton = await screen.findByTestId(/ExpandMoreIcon/); + await user.click(expandRowButton); + + await screen.findByText(/Cliff's Delta/); + const alerts = screen.getAllByRole('alert'); + const summaryAlert = alerts.find((alert) => + alert.textContent?.includes('Δ median'), + ); + expect(summaryAlert?.textContent).toContain('(not significant)'); + }); + it('should display mean for base or new in row headers for mann-whitney-u testVersion', async () => { const { testCompareMannWhitneyData: rowData } = getTestData(); renderWithRoute( diff --git a/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap b/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap index a021e1dcb..16fc42688 100644 --- a/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap +++ b/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap @@ -33,7 +33,9 @@ exports[`Results View Should display Base, New and Common graphs with replicates
- chartjs-line +
@@ -141,6 +143,83 @@ exports[`Results View Should display Base, New and Common graphs with replicates + + @@ -344,52 +423,71 @@ exports[`Results View Should display Base, New and Common graphs with replicates `; -exports[`Results View Should display Base, New and Common graphs with replicates: Dataset for scatter 1`] = ` -{ - "backgroundColor": [Function], - "data": [ - { - "x": 587.15, - "y": "Base", - }, - { - "x": 593.04, - "y": "Base", - }, - { - "x": 600.7, - "y": "Base", - }, - { - "x": 602.04, - "y": "Base", - }, - { - "x": 605.16, - "y": "New", - }, - { - "x": 605.31, - "y": "New", - }, - { - "x": 605.61, - "y": "New", - }, - { - "x": 605.81, - "y": "New", +exports[`Results View Should display Base, New and Common graphs with replicates: Scatter series 1`] = ` +[ + { + "data": [ + [ + 587.15, + "Base", + ], + [ + 593.04, + "Base", + ], + [ + 600.7, + "Base", + ], + [ + 602.04, + "Base", + ], + ], + "itemStyle": { + "color": "#9059ff99", }, - { - "x": 607.27, - "y": "New", + "name": "Base", + "symbol": "triangle", + "symbolSize": 14, + "type": "scatter", + "xAxisIndex": 1, + "yAxisIndex": 1, + }, + { + "data": [ + [ + 605.16, + "New", + ], + [ + 605.31, + "New", + ], + [ + 605.61, + "New", + ], + [ + 605.81, + "New", + ], + [ + 607.27, + "New", + ], + ], + "itemStyle": { + "color": "#00878799", }, - ], - "pointRadius": 7, - "pointStyle": "triangle", - "type": "scatter", - "yAxisID": "yValues", -} + "name": "New", + "symbol": "triangle", + "symbolSize": 14, + "type": "scatter", + "xAxisIndex": 1, + "yAxisIndex": 1, + }, +] `; exports[`Results View Should display Base, New and Common graphs with tooltips 1`] = ` @@ -425,7 +523,9 @@ exports[`Results View Should display Base, New and Common graphs with tooltips 1
- chartjs-line +
@@ -533,6 +633,82 @@ exports[`Results View Should display Base, New and Common graphs with tooltips 1 + + @@ -736,52 +912,71 @@ exports[`Results View Should display Base, New and Common graphs with tooltips 1 `; -exports[`Results View Should display Base, New and Common graphs with tooltips: Dataset for scatter 1`] = ` -{ - "backgroundColor": [Function], - "data": [ - { - "x": 587.15, - "y": "Base", - }, - { - "x": 593.04, - "y": "Base", - }, - { - "x": 600.7, - "y": "Base", - }, - { - "x": 602.04, - "y": "Base", - }, - { - "x": 605.16, - "y": "New", - }, - { - "x": 605.31, - "y": "New", - }, - { - "x": 605.61, - "y": "New", - }, - { - "x": 605.81, - "y": "New", +exports[`Results View Should display Base, New and Common graphs with tooltips: Scatter series 1`] = ` +[ + { + "data": [ + [ + 587.15, + "Base", + ], + [ + 593.04, + "Base", + ], + [ + 600.7, + "Base", + ], + [ + 602.04, + "Base", + ], + ], + "itemStyle": { + "color": "#9059ff99", }, - { - "x": 607.27, - "y": "New", + "name": "Base", + "symbol": "triangle", + "symbolSize": 14, + "type": "scatter", + "xAxisIndex": 1, + "yAxisIndex": 1, + }, + { + "data": [ + [ + 605.16, + "New", + ], + [ + 605.31, + "New", + ], + [ + 605.61, + "New", + ], + [ + 605.81, + "New", + ], + [ + 607.27, + "New", + ], + ], + "itemStyle": { + "color": "#00878799", }, - ], - "pointRadius": 7, - "pointStyle": "triangle", - "type": "scatter", - "yAxisID": "yValues", -} + "name": "New", + "symbol": "triangle", + "symbolSize": 14, + "type": "scatter", + "xAxisIndex": 1, + "yAxisIndex": 1, + }, +] `; exports[`Results View Should update url with new title and the table with the new title: After clicking the Save button 1`] = ` diff --git a/src/__tests__/utils/setupTests.ts b/src/__tests__/utils/setupTests.ts index f03981ec7..68d35dc38 100644 --- a/src/__tests__/utils/setupTests.ts +++ b/src/__tests__/utils/setupTests.ts @@ -11,12 +11,12 @@ import { webcrypto } from 'node:crypto'; // See https://www.wheresrhys.co.uk/fetch-mock/ for more information about how // to use this mock. import fetchMock from '@fetch-mock/jest'; -import { density1d } from 'fast-kde'; -import { Line } from 'react-chartjs-2'; +import { init as echartsInit } from 'echarts'; import { Hooks } from 'taskcluster-client-web'; import { createStore } from '../../common/store'; import type { Store } from '../../common/store'; +import { fftkde } from '../../utils/kde.js'; let store: Store; @@ -45,23 +45,31 @@ beforeEach(() => { }); }); -jest.mock('react-chartjs-2', () => ({ - Line: jest.fn(), +// Mock echarts so that jsdom-based tests don't try to render to a real canvas. +// `init` returns a stub instance whose `setOption` calls can be inspected. +jest.mock('echarts', () => ({ + init: jest.fn(), })); -const MockedLine = Line as jest.Mock; +const MockedEchartsInit = echartsInit as jest.Mock; -jest.mock('fast-kde', () => ({ - density1d: jest.fn(), +jest.mock('../../utils/kde.js', () => ({ + fftkde: jest.fn(), })); -const MockedDensity1d = density1d as jest.Mock; +const MockedFftkde = fftkde as jest.Mock; Object.defineProperty(window, 'crypto', { value: webcrypto }); beforeEach(() => { // After every test jest resets the mock implementation, so we need to define // it again for each test. - MockedLine.mockImplementation(() => 'chartjs-line'); - MockedDensity1d.mockImplementation(() => 'fast-kde'); + MockedEchartsInit.mockImplementation(() => ({ + setOption: jest.fn(), + resize: jest.fn(), + dispose: jest.fn(), + on: jest.fn(), + off: jest.fn(), + })); + MockedFftkde.mockImplementation(() => ({ x: [], y: [], bandwidth: 1 })); }); // Install the fetch mock globally diff --git a/src/common/testVersions/mannWhitney.tsx b/src/common/testVersions/mannWhitney.tsx index 5392828c4..f4944ce02 100644 --- a/src/common/testVersions/mannWhitney.tsx +++ b/src/common/testVersions/mannWhitney.tsx @@ -2,6 +2,7 @@ import KeyboardDoubleArrowUpIcon from '@mui/icons-material/KeyboardDoubleArrowUp import ThumbDownIcon from '@mui/icons-material/ThumbDown'; import ThumbUpIcon from '@mui/icons-material/ThumbUp'; import WarningIcon from '@mui/icons-material/Warning'; +import { Alert } from '@mui/material'; import Box from '@mui/material/Box'; import { MannWhitneyCompareMetrics } from '../../components/CompareResults/MannWhitneyCompareMetrics'; @@ -14,6 +15,7 @@ import { MannWhitneyResultsItem, } from '../../types/state'; import { TableConfig } from '../../types/types'; +import { bootstrapMedianDiffCI } from '../../utils/bootstrap-ci'; import { formatNumber } from '../../utils/format'; import { capitalize } from '../../utils/helpers'; import { getBrowserDisplay, getPlatformShortName } from '../../utils/platform'; @@ -406,6 +408,28 @@ export const mannWhitneyStrategy = { ? capitalize(mwResult.mann_whitney_test.interpretation) : ''; + const baseRuns = mwResult.base_runs ?? []; + const newRuns = mwResult.new_runs ?? []; + const ci = + baseRuns.length > 0 && newRuns.length > 0 + ? bootstrapMedianDiffCI(baseRuns, newRuns) + : null; + const fmt = (n: number) => (n >= 0 ? '+' : '') + n.toFixed(1); + const summary = ci ? ( + + Δ median = {fmt(ci.medianDiff)} ms 95% CI [ + {fmt(ci.ciLow)}, {fmt(ci.ciHigh)}] + {ci.significant ? '' : ' (not significant)'} + + ) : null; + const confidenceInterval = ci && ( + + Confidence Interval: We are 95% confident the median + difference is between {fmt(ci.ciLow)} and{' '} + {fmt(ci.ciHigh)} + + ); + return ( <> + {summary && {summary}} + {confidenceInterval && ( + {confidenceInterval} + )} ); diff --git a/src/components/CompareResults/CommonGraph.tsx b/src/components/CompareResults/CommonGraph.tsx index a43215c8c..fe34c6548 100644 --- a/src/components/CompareResults/CommonGraph.tsx +++ b/src/components/CompareResults/CommonGraph.tsx @@ -1,22 +1,13 @@ +import { useEffect, useMemo, useRef } from 'react'; + import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { - Chart as ChartJS, - LineElement, - LinearScale, - ScriptableContext, - type TooltipItem, - type TooltipModel, -} from 'chart.js'; -import 'chart.js/auto'; -import * as kde from 'fast-kde'; -import { Line } from 'react-chartjs-2'; +import { init, type ECharts, type EChartsOption } from 'echarts'; import { Colors } from '../../styles/Colors'; +import { fftkde } from '../../utils/kde.js'; -ChartJS.register(LinearScale, LineElement); - -// This computes the min, max and the KDE bandwidth from a list of numbers. +// This computes the min, max from a list of numbers. function computeStatisticsForRuns(data: number[]) { if (!data.length) { return null; @@ -25,47 +16,11 @@ function computeStatisticsForRuns(data: number[]) { const sorted = [...data].sort((a, b) => a - b); return { - min: quantileSorted(sorted, 0), - max: quantileSorted(sorted, 1), - bandwidth: approximateSJBandwidth(sorted), + min: sorted[0], + max: sorted[sorted.length - 1], }; } -// This logic approximates the Sheather and Jones algorithm according to ChatGPT. -// In the future we might want to compute a better value, see -// https://bugzilla.mozilla.org/show_bug.cgi?id=1901248 for some ideas. -function approximateSJBandwidth(sorted: number[]): number { - const n = sorted.length; - if (n < 2) return sorted[0] * 0.0015; - - const q25 = quantileSorted(sorted, 0.25); - const q75 = quantileSorted(sorted, 0.75); - const iqr = q75 - q25; - - const mean = sorted.reduce((a, b) => a + b, 0) / n; - const std = Math.sqrt( - sorted.reduce((sum, x) => sum + Math.pow(x - mean, 2), 0) / n, - ); - - const sigma = Math.min(std, iqr / 1.34); // Robust estimate - const h = 0.9 * sigma * Math.pow(n, -1 / 5); - - return h; -} - -// This function returns a quantile from a sorted array of numbers. -function quantileSorted(sorted: number[], q: number): number { - const pos = (sorted.length - 1) * q; - const base = Math.floor(pos); - const rest = pos - base; - - if (sorted[base + 1] !== undefined) { - return sorted[base] + rest * (sorted[base + 1] - sorted[base]); - } else { - return sorted[base]; - } -} - // A simple wrapper to Math.min, resilient when one of the numbers is undefined or null. function computeMin(a?: number, b?: number) { a ??= Infinity; @@ -80,255 +35,259 @@ function computeMax(a?: number, b?: number) { return Math.max(a, b); } -function CommonGraph({ baseValues, newValues, unit }: CommonGraphProps) { - const statsForBase = computeStatisticsForRuns(baseValues); - const statsForNew = computeStatisticsForRuns(newValues); - - // Compute the global min and max with some grace value. - const min = computeMin(statsForBase?.min, statsForNew?.min) * 0.95; - const max = computeMax(statsForBase?.max, statsForNew?.max) * 1.05; +const CHART_HEIGHT = 300; - // The KDE line chart and categorical bubble chart share an x-axis but use - // entirely different y-scales, making the composition flexible but - // non-trivial. - const options = { - // Make the chart responsive to container size - responsive: true, - // Allow the chart to stretch freely, not keeping a fixed aspect ratio. This - // needs the container's size to be well defined. - maintainAspectRatio: false, - plugins: { - legend: { - // Hide the default legend (labels for datasets) - display: false, - }, - - tooltip: { - // Allow tooltips to appear even when not directly intersecting a point - intersect: false, - callbacks: { - // Suppress the tooltip title (normally shows the x-value) - title: () => '', - - // Customize tooltip labels depending on the dataset type - label( - this: TooltipModel<'line'> | TooltipModel<'scatter'>, - tooltipItem: TooltipItem<'line'> | TooltipItem<'scatter'>, - ) { - switch (tooltipItem.dataset.yAxisID) { - case 'yKde': { - // KDE line: show only the x-value with optional unit - if (tooltipItem.parsed.x === null) { - return ''; - } - const x = tooltipItem.parsed.x.toFixed(2); - return `@ ${x}` + (unit ? ` (${unit})` : ''); - } - case 'yValues': { - // For the bubble chart: display only one summary line, even if - // multiple points overlap - if ( - this.dataPoints.length > 1 && - this.dataPoints[0] !== tooltipItem - ) { - return ''; - } - - const point = tooltipItem.raw as { - x: number; - y: 'Base' | 'New'; - }; - // Example: "Base: 42 (ms) (×3)" - const labelString = `${point.y}: ${point.x}`; - const unitString = unit ? ` (${unit})` : ''; - const summaryString = - this.dataPoints.length > 1 - ? ` (×${this.dataPoints.length})` - : ''; - return labelString + unitString + summaryString; - } - default: - return ''; - } - }, - - // Explicitly set the color of the square shown next to each tooltip label - labelColor: ( - tooltipItem: TooltipItem<'line'> | TooltipItem<'scatter'>, - ) => { - const { dataset, raw } = tooltipItem; - - let source: 'Base' | 'New' | undefined; - - if (dataset.yAxisID === 'yKde') { - // KDE lines distinguish between Base and New by label - source = dataset.label === 'Base' ? 'Base' : 'New'; - } else if (dataset.yAxisID === 'yValues') { - // Scatter chart: use the y-value ("Base" or "New") stored in the raw data - source = (raw as { y: 'Base' | 'New' }).y; - } - - if (source) { - return { - backgroundColor: - source === 'Base' ? Colors.ChartBase : Colors.ChartNew, - }; - } - - // Fallback color if the dataset is not recognized - return { - backgroundColor: 'rgba(0,0,0,0)', - }; - }, - }, - // Show color boxes (one per label, unless suppressed in labelColor) - displayColors: true, - padding: 10, - boxPadding: 4, - }, - }, - scales: { - x: { - type: 'linear' as const, - suggestedMin: min, - suggestedMax: max, - grid: { - display: false, // Hide vertical grid lines - offset: false, +function CommonGraph({ baseValues, newValues, unit }: CommonGraphProps) { + const chartContainerRef = useRef(null); + const chartInstanceRef = useRef(null); + + const option: EChartsOption = useMemo(() => { + const statsForBase = computeStatisticsForRuns(baseValues); + const statsForNew = computeStatisticsForRuns(newValues); + + // Compute the global min and max with some grace value. + const min = computeMin(statsForBase?.min, statsForNew?.min) * 0.95; + const max = computeMax(statsForBase?.max, statsForNew?.max) * 1.05; + + // ISJ auto-selects the bandwidth per dataset, so each KDE tunes itself. + const bKde = + baseValues.length >= 2 + ? fftkde(baseValues, 'ISJ', undefined, 1024) + : null; + const nKde = + newValues.length >= 2 ? fftkde(newValues, 'ISJ', undefined, 1024) : null; + const baseRunsDensity: [number, number][] = bKde + ? bKde.x.map((xCoord, index) => [xCoord, bKde.y[index]]) + : []; + const newRunsDensity: [number, number][] = nKde + ? nKde.x.map((xCoord, index) => [xCoord, nKde.y[index]]) + : []; + + // Raw values rendered as a categorical scatter ("Base" / "New"). + const baseScatter: [number, string][] = baseValues.map((value) => [ + value, + 'Base', + ]); + const newScatter: [number, string][] = newValues.map((value) => [ + value, + 'New', + ]); + + const totalScatter = baseValues.length + newValues.length; + const symbolSize = totalScatter < 20 ? 14 : 10; + + // Pre-compute counts of identical (category, value) pairs so the tooltip + // can show "(×N)" when several runs share the same value. + const counts = new Map(); + for (const value of baseValues) { + const key = `Base|${value}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + for (const value of newValues) { + const key = `New|${value}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const unitSuffix = unit ? ` (${unit})` : ''; + + return { + animation: false, + // Two stacked grids: the top one holds the KDE curves, the bottom one + // the categorical scatter. Both share horizontal extent and x-range. + // The top is bumped down to leave room for the legend above. + grid: [ + { left: 70, right: 70, top: 28, height: 140 }, + { left: 70, right: 70, top: 200, height: 50 }, + ], + xAxis: [ + { + type: 'value', + gridIndex: 0, + min, + max, + splitLine: { show: false }, + axisLine: { show: true, lineStyle: { color: '#999' } }, + axisTick: { show: false }, + axisLabel: { show: false }, }, - title: { - align: 'end' as const, - display: true, - text: `${unit} →`, // Example: "ms →" + { + type: 'value', + gridIndex: 1, + min, + max, + name: `${unit ?? ''} →`, + nameLocation: 'end', + nameGap: 8, + nameTextStyle: { + align: 'left', + verticalAlign: 'middle', + fontSize: 12, + }, + splitLine: { show: true, lineStyle: { color: '#eee' } }, + axisLine: { show: true, lineStyle: { color: '#999' } }, }, - }, - yKde: { - type: 'linear', // Linear scale - stack: 'y', // yKde and yValues are part of the same stack - stackWeight: 3, // Larger stack weight means more vertical space - weight: 3, // Larger weight ensures it's on top - beginAtZero: true, - grace: '3%', // Add margin at the top of the axis range - grid: { - drawBorder: false, - display: false, // No horizontal grid lines for KDE - offset: false, + ], + yAxis: [ + { + type: 'value', + gridIndex: 0, + min: 0, + splitLine: { show: true, lineStyle: { color: '#eee' } }, + axisLine: { show: true, lineStyle: { color: '#999' } }, + axisTick: { show: false }, + axisLabel: { show: true, color: '#000', fontSize: 12 }, }, - ticks: { - beginAtZero: true, - display: true, + { + type: 'category', + gridIndex: 1, + data: ['Base', 'New'], + boundaryGap: true, + position: 'left', + axisLine: { show: true, lineStyle: { color: '#999' } }, + axisTick: { show: false }, + axisLabel: { + show: true, + interval: 0, + margin: 8, + color: '#000', + fontSize: 12, + }, }, - }, - - // Spacer axis to visually separate KDE and scatter plots - // This doesn't display anything. - ySpacer: { - type: 'linear', - stack: 'y', - stackWeight: 0.5, // Takes less space than yKde - weight: 2, // Appears between yKde and yValues - display: false, // Invisible axis (No ticks or grids) - grid: { - display: false, + ], + // Wheel to zoom on the x-axis; shift+drag pans. Both grids share the + // x-range, so the zoom applies to xAxisIndex [0, 1] in tandem. + // filterMode: 'none' keeps every data point in place — the zoom only + // changes the visible window, so KDE curves still extend to the edges. + dataZoom: [ + { + type: 'inside', + xAxisIndex: [0, 1], + filterMode: 'none', + zoomOnMouseWheel: true, + moveOnMouseMove: 'shift', + moveOnMouseWheel: false, }, - ticks: { - display: false, + { + type: 'slider', + xAxisIndex: [0, 1], + filterMode: 'none', + height: 16, + bottom: 4, + showDetail: false, + brushSelect: false, }, - }, - yValues: { - type: 'category', - stack: 'y', - stackWeight: 1, // Smaller stack weight means it takes less space - weight: 1, // Appears at the bottom - labels: ['Base', 'New'], - offset: true, // Adds extra padding for visual separation - ticks: { - autoSkip: false, // Show both labels even if close together + ], + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross', crossStyle: { color: '#999' } }, + padding: 10, + formatter: (params) => { + // With trigger: 'axis', echarts passes an array of points (one per + // series at the cursor's x). For trigger: 'item' it'd be a single + // object; normalise to an array either way. + const items = Array.isArray(params) ? params : [params]; + const lines = items + .map((pts) => { + const marker = typeof pts.marker === 'string' ? pts.marker : ''; + const seriesName = pts.seriesName ?? ''; + if (pts.seriesType === 'line') { + const xValue = (pts.value as [number, number])[0]; + return `${marker}${seriesName} @ ${xValue.toFixed(2)}${unitSuffix}`; + } + if (pts.seriesType === 'scatter') { + const [xValue, category] = pts.value as [number, string]; + const count = counts.get(`${category}|${xValue}`) ?? 1; + const summary = count > 1 ? ` (×${count})` : ''; + return `${marker}${seriesName}: ${xValue}${unitSuffix}${summary}`; + } + return ''; + }) + .filter((line) => line); + return lines.join('
'); }, }, - }, - elements: { - // These ones will be used for the 2 KDE datasets. - // When needed, they will be overridden in the "scatter" dataset. - line: { - borderWidth: 3, // Thickness of KDE curves - }, - point: { - pointRadius: 0, // Points on line chart are invisible - pointHoverRadius: 5, // But they respond to hover - }, - }, - interaction: { - // Show tooltip for the closest point (across all datasets) - mode: 'nearest', - // Only show tooltip if the mouse intersects the actual shape - intersect: true, - }, - }; - - ///////////////// START SHOW VALUES //////////////////////// - const baseValuesData = baseValues.map((v) => { - return { x: v, y: 'Base' }; - }); - const newValuesData = newValues.map((v) => { - return { x: v, y: 'New' }; - }); - - const allValuesData = [...baseValuesData, ...newValuesData]; - - //////////////////// START FAST KDE //////////////////////// - // So that the 2 KDE graphs are visually comparable, it's important to use the - // same bandwidth for both. - const bandwidth = computeMin(statsForBase?.bandwidth, statsForNew?.bandwidth); - - const baseRunsDensity = Array.from( - kde.density1d(baseValues, { - bandwidth, - extent: [min, max], - }), - ); - const newRunsDensity = Array.from( - kde.density1d(newValues, { - bandwidth, - extent: [min, max], - }), - ); - //////////////////// END FAST KDE //////////////////////// - - const data = { - datasets: [ - { - // First KDE line: density of the "Base" distribution - yAxisID: 'yKde', - label: 'Base', - data: baseRunsDensity, - fill: false, - borderColor: Colors.ChartBase, + toolbox: { + feature: { restore: {}, saveAsImage: {} }, + right: 8, + top: 4, + itemSize: 12, }, - { - // Second KDE line: density of the "New" distribution - yAxisID: 'yKde', - label: 'New', - data: newRunsDensity, - fill: false, - borderColor: Colors.ChartNew, - }, - { - // Bubble chart layer: raw values from both distributions (shown as points) - yAxisID: 'yValues', - type: 'scatter', - pointStyle: 'triangle', - // Adjust point size based on dataset size (smaller points if there's a lot of data) - pointRadius: allValuesData.length < 20 ? 7 : 5, - data: allValuesData, - // Color code points by category using dynamic function - backgroundColor: (context: ScriptableContext<'scatter'>) => - ((context.raw as { y: 'Base' | 'New' }).y === 'Base' - ? Colors.ChartBase - : Colors.ChartNew) + '99', // Add 60% transparency to the hexadecimal color + legend: { + data: ['Base', 'New'], + top: 4, + left: 'center', + itemHeight: 10, + itemWidth: 30, }, - ], - }; + series: [ + { + name: 'Base', + type: 'line', + triggerLineEvent: true, + xAxisIndex: 0, + yAxisIndex: 0, + data: baseRunsDensity, + showSymbol: false, + lineStyle: { width: 3, color: Colors.ChartBase }, + itemStyle: { color: Colors.ChartBase }, + emphasis: { focus: 'none' }, + }, + { + name: 'New', + type: 'line', + triggerLineEvent: true, + xAxisIndex: 0, + yAxisIndex: 0, + data: newRunsDensity, + showSymbol: false, + lineStyle: { width: 3, color: Colors.ChartNew }, + itemStyle: { color: Colors.ChartNew }, + emphasis: { focus: 'none' }, + }, + { + name: 'Base', + type: 'scatter', + xAxisIndex: 1, + yAxisIndex: 1, + data: baseScatter, + symbol: 'triangle', + symbolSize, + itemStyle: { color: Colors.ChartBase + '99' }, + }, + { + name: 'New', + type: 'scatter', + xAxisIndex: 1, + yAxisIndex: 1, + data: newScatter, + symbol: 'triangle', + symbolSize, + itemStyle: { color: Colors.ChartNew + '99' }, + }, + ], + }; + }, [baseValues, newValues, unit]); + + useEffect(() => { + if (!chartContainerRef.current) { + return; + } + const instance = init(chartContainerRef.current); + chartInstanceRef.current = instance; + + const handleResize = () => instance.resize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + instance.dispose(); + chartInstanceRef.current = null; + }; + }, []); + + useEffect(() => { + chartInstanceRef.current?.setOption(option, true); + }, [option]); return ( <> @@ -336,8 +295,10 @@ function CommonGraph({ baseValues, newValues, unit }: CommonGraphProps) { Runs Density Distribution - {/* @ts-expect-error the types for chart.js do not seem great and do not support all options. */} - +
); diff --git a/src/utils/bootstrap-ci.js b/src/utils/bootstrap-ci.js new file mode 100644 index 000000000..b0b0291ce --- /dev/null +++ b/src/utils/bootstrap-ci.js @@ -0,0 +1,83 @@ +/** + * Percentile bootstrap confidence interval for the difference of medians. + * + * No external dependencies. + */ + +function median(arr) { + const s = new Float64Array(arr).sort(); + const m = s.length >> 1; + return s.length % 2 === 0 ? (s[m - 1] + s[m]) / 2 : s[m]; +} +function resample(arr, rng) { + const out = new Float64Array(arr.length); + for (let i = 0; i < arr.length; i++) { + out[i] = arr[Math.floor(rng() * arr.length)]; + } + return out; +} +// Mulberry32 — fast seedable PRNG so results are reproducible. +function mulberry32(seed) { + let s = seed >>> 0; + return () => { + s += 0x6d2b79f5; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 0x100000000; + }; +} +/** + * Percentile bootstrap confidence interval for (median(newData) - median(base)). + * Matches scipy.stats.bootstrap(..., method="percentile", paired=False). + * + * How it works + * ------------ + * A confidence interval answers: "given the samples we observed, what is the + * plausible range for the true difference?" The bootstrap approach avoids + * assumptions about the underlying distribution (normality, etc.) by + * simulating the sampling process directly: + * + * 1. Draw nIter synthetic datasets by sampling WITH replacement from each + * input array (a "resample" — same size, but some values repeat and some + * are absent). + * 2. Compute (median(resampledNew) - median(resampledBase)) for each pair. + * 3. Sort the resulting nIter differences. + * 4. The CI is the [alpha/2, 1-alpha/2] percentile range of that distribution. + * + * If the CI does not straddle zero, the difference is statistically significant + * at the chosen alpha level. + * + * @param base - baseline sample values (e.g. before-patch timings) + * @param newData - new/comparison sample values (e.g. after-patch timings) + * @param nIter - number of bootstrap resamples; 1000 is sufficient for most + * uses, increase to 10 000 for publication-quality intervals + * @param alpha - two-tailed significance level: the CI covers (1-alpha) of the + * bootstrap distribution, e.g. 0.05 → 95% CI, 0.01 → 99% CI + * @param seed - PRNG seed; fix this to get reproducible results across runs + */ +export function bootstrapMedianDiffCI( + base, + newData, + nIter = 1000, + alpha = 0.05, + seed = 42, +) { + const rng = mulberry32(seed); + const baseArr = new Float64Array(base); + const newArr = new Float64Array(newData); + const diffs = new Float64Array(nIter); + for (let i = 0; i < nIter; i++) { + diffs[i] = median(resample(newArr, rng)) - median(resample(baseArr, rng)); + } + diffs.sort(); + const loIdx = Math.floor((alpha / 2) * nIter); + const hiIdx = Math.min(Math.floor((1 - alpha / 2) * nIter), nIter - 1); + const ciLow = diffs[loIdx]; + const ciHigh = diffs[hiIdx]; + return { + medianDiff: median(newData) - median(base), + ciLow, + ciHigh, + significant: ciLow > 0 || ciHigh < 0, + }; +} diff --git a/src/utils/bootstrap-ci.ts b/src/utils/bootstrap-ci.ts new file mode 100644 index 000000000..f6e92eac5 --- /dev/null +++ b/src/utils/bootstrap-ci.ts @@ -0,0 +1,93 @@ +/** + * Percentile bootstrap confidence interval for the difference of medians. + * + * No external dependencies. + */ + +function median(arr: ArrayLike): number { + const s = new Float64Array(arr).sort(); + const m = s.length >> 1; + return s.length % 2 === 0 ? (s[m - 1] + s[m]) / 2 : s[m]; +} + +function resample(arr: Float64Array, rng: () => number): Float64Array { + const out = new Float64Array(arr.length); + for (let i = 0; i < arr.length; i++) { + out[i] = arr[Math.floor(rng() * arr.length)]; + } + return out; +} + +// Mulberry32 — fast seedable PRNG so results are reproducible. +function mulberry32(seed: number): () => number { + let s = seed >>> 0; + return () => { + s += 0x6d2b79f5; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 0x100000000; + }; +} + +export type BootstrapCI = { + medianDiff: number; + ciLow: number; + ciHigh: number; + significant: boolean; // CI does not contain 0 +}; + +/** + * Percentile bootstrap confidence interval for (median(newData) - median(base)). + * Matches scipy.stats.bootstrap(..., method="percentile", paired=False). + * + * How it works + * ------------ + * A confidence interval answers: "given the samples we observed, what is the + * plausible range for the true difference?" The bootstrap approach avoids + * assumptions about the underlying distribution (normality, etc.) by + * simulating the sampling process directly: + * + * 1. Draw nIter synthetic datasets by sampling WITH replacement from each + * input array (a "resample" — same size, but some values repeat and some + * are absent). + * 2. Compute (median(resampledNew) - median(resampledBase)) for each pair. + * 3. Sort the resulting nIter differences. + * 4. The CI is the [alpha/2, 1-alpha/2] percentile range of that distribution. + * + * If the CI does not straddle zero, the difference is statistically significant + * at the chosen alpha level. + * + * @param base - baseline sample values (e.g. before-patch timings) + * @param newData - new/comparison sample values (e.g. after-patch timings) + * @param nIter - number of bootstrap resamples; 1000 is sufficient for most + * uses, increase to 10 000 for publication-quality intervals + * @param alpha - two-tailed significance level: the CI covers (1-alpha) of the + * bootstrap distribution, e.g. 0.05 → 95% CI, 0.01 → 99% CI + * @param seed - PRNG seed; fix this to get reproducible results across runs + */ +export function bootstrapMedianDiffCI( + base: number[], + newData: number[], + nIter: number = 1000, + alpha: number = 0.05, + seed: number = 42, +): BootstrapCI { + const rng = mulberry32(seed); + const baseArr = new Float64Array(base); + const newArr = new Float64Array(newData); + const diffs = new Float64Array(nIter); + for (let i = 0; i < nIter; i++) { + diffs[i] = median(resample(newArr, rng)) - median(resample(baseArr, rng)); + } + diffs.sort(); + const loIdx = Math.floor((alpha / 2) * nIter); + const hiIdx = Math.min(Math.floor((1 - alpha / 2) * nIter), nIter - 1); + const ciLow = diffs[loIdx]; + const ciHigh = diffs[hiIdx]; + return { + medianDiff: median(newData) - median(base), + ciLow, + ciHigh, + significant: ciLow > 0 || ciHigh < 0, + }; +} diff --git a/src/utils/kde.d.ts b/src/utils/kde.d.ts new file mode 100644 index 000000000..63e12dc6a --- /dev/null +++ b/src/utils/kde.d.ts @@ -0,0 +1,61 @@ +/** + * Type declarations for kde.js. + * + * Depends on: nothing (self-contained) + * Consumed by: example.mjs, kde-widget.js (mode-fitting inlined there) + */ + +/** + * Faithful 1D port of KDEpy's FFTKDE with ISJ bandwidth for a Gaussian kernel. + * + * References: + * Botev, Z. I., Grotowski, J. F. and Kroese, D. P. (2010): + * Kernel density estimation via diffusion. Ann. Stat. 38(5), 2916-2957. + * Fan, J. and Marron, J. S. (1994): Fast implementations of nonparametric + * curve estimators. J. Comput. Graph. Stat. 3(1), 35-56. + * KDEpy: https://github.com/tommyod/KDEpy (MIT licence) + */ +export declare function dct2(x: number[]): number[]; +export declare function linearBinning1D( + data: number[], + gridPoints: number[], + weights?: number[], +): Float64Array; +export declare function autogrid1D( + data: number[], + boundaryAbs?: number, + numPoints?: number, + boundaryRel?: number, +): Float64Array; +export declare function improvedSheatherJones( + data: number[], + weights?: number[], +): number; +export declare function silvermansRule(data: number[]): number; +export type FFTKDEResult = { + x: number[]; + y: number[]; + bandwidth: number; +}; +export declare function fftkde( + data: number[], + bw?: number | 'ISJ' | 'silverman', + weights?: number[], + numGridPoints?: number, + boundary?: 'none' | 'reflection', +): FFTKDEResult; +export declare function argrelmax(y: number[], order?: number): number[]; +export type KDEModeResult = { + nModes: number; + peakLocs: number[]; + boundaries: number[]; + x: number[]; + y: number[]; + bandwidth: number; +}; +export declare function fitKdeModes( + data: number[], + valleyThreshold?: number, + minPeakFraction?: number, + minDataFraction?: number, +): KDEModeResult; diff --git a/src/utils/kde.js b/src/utils/kde.js new file mode 100644 index 000000000..26a4aadd4 --- /dev/null +++ b/src/utils/kde.js @@ -0,0 +1,755 @@ +/** + * Faithful 1D port of KDEpy's FFTKDE with ISJ bandwidth for a Gaussian kernel. + * + * References: + * Botev, Z. I., Grotowski, J. F. and Kroese, D. P. (2010): + * Kernel density estimation via diffusion. Ann. Stat. 38(5), 2916-2957. + * Fan, J. and Marron, J. S. (1994): Fast implementations of nonparametric + * curve estimators. J. Comput. Graph. Stat. 3(1), 35-56. + * KDEpy: https://github.com/tommyod/KDEpy (BSD 3-Clause licence) + */ +// --------------------------------------------------------------------------- +// FFT — Cooley-Tukey radix-2 DIT, in-place on Float64Arrays +// --------------------------------------------------------------------------- +function fftInPlace(re, im) { + const N = re.length; + // Bit-reversal permutation + let j = 0; + for (let i = 1; i < N; i++) { + let bit = N >> 1; + while (j & bit) { + j ^= bit; + bit >>= 1; + } + j ^= bit; + if (i < j) { + let t = re[i]; + re[i] = re[j]; + re[j] = t; + t = im[i]; + im[i] = im[j]; + im[j] = t; + } + } + // Butterfly passes + for (let len = 2; len <= N; len <<= 1) { + const half = len >> 1; + // W = exp(-2*pi*i/len) = exp(-pi*i/half) + const ang = -Math.PI / half; + const wRe = Math.cos(ang); + const wIm = Math.sin(ang); + for (let i = 0; i < N; i += len) { + let cRe = 1.0, + cIm = 0.0; + for (let k = 0; k < half; k++) { + const uRe = re[i + k]; + const uIm = im[i + k]; + const vRe = re[i + k + half] * cRe - im[i + k + half] * cIm; + const vIm = re[i + k + half] * cIm + im[i + k + half] * cRe; + re[i + k] = uRe + vRe; + im[i + k] = uIm + vIm; + re[i + k + half] = uRe - vRe; + im[i + k + half] = uIm - vIm; + const nextCRe = cRe * wRe - cIm * wIm; + cIm = cRe * wIm + cIm * wRe; + cRe = nextCRe; + } + } + } +} +// --------------------------------------------------------------------------- +// DCT-II — matches scipy.fftpack.dct(x, type=2, norm=None) +// Uses Lee's O(N log N) FFT-based reduction. N must be a power of 2. +// +// Algorithm: reorder as v[n]=x[2n], v[N-1-n]=x[2n+1], then +// y[k] = 2 * Re( FFT(v)[k] * exp(-i*pi*k/(2N)) ) +// --------------------------------------------------------------------------- +/** + * Type-II Discrete Cosine Transform. + * + * Used internally by improvedSheatherJones to transform the binned data into + * frequency space, where the ISJ fixed-point equation can be evaluated + * efficiently. Not needed directly in typical usage — call fftkde instead. + * + * @param x - real-valued array whose length must be a power of 2 + * @returns DCT-II coefficients, same length as x + */ +export function dct2(x) { + const N = x.length; + if (N < 2 || (N & (N - 1)) !== 0) + throw new Error(`dct2 requires power-of-2 length, got ${N}`); + const half = N >> 1; + const v = new Float64Array(N); + for (let n = 0; n < half; n++) { + v[n] = x[2 * n]; + v[N - 1 - n] = x[2 * n + 1]; + } + const re = new Float64Array(v); + const im = new Float64Array(N); + fftInPlace(re, im); + const y = new Array(N); + for (let k = 0; k < N; k++) { + const angle = (-Math.PI * k) / (2 * N); + y[k] = 2 * (re[k] * Math.cos(angle) - im[k] * Math.sin(angle)); + } + return y; +} +// --------------------------------------------------------------------------- +// Brent's root-finding — matches scipy.optimize.brentq +// --------------------------------------------------------------------------- +function brentq(f, a, b, xtol = 2e-12, rtol = 4.4e-16, maxIter = 100) { + let fa = f(a); + let fb = f(b); + if (fa === 0) return { x: a, converged: true }; + if (fb === 0) return { x: b, converged: true }; + if (fa * fb > 0) return { x: 0, converged: false }; + let c = b, + fc = fb; + let d = 0, + e = 0; + for (let iter = 0; iter < maxIter; iter++) { + if (fb * fc > 0) { + c = a; + fc = fa; + d = e = b - a; + } + if (Math.abs(fc) < Math.abs(fb)) { + a = b; + fa = fb; + b = c; + fb = fc; + c = a; + fc = fa; + } + const tol1 = 2 * rtol * Math.abs(b) + 0.5 * xtol; + const xm = 0.5 * (c - b); + if (Math.abs(xm) <= tol1 || fb === 0) return { x: b, converged: true }; + if (Math.abs(e) >= tol1 && Math.abs(fa) > Math.abs(fb)) { + let s = fb / fa; + let p, q; + if (a === c) { + p = 2 * xm * s; + q = 1 - s; + } else { + const r = fb / fc; + q = fa / fc; + p = s * (2 * xm * q * (q - r) - (b - a) * (r - 1)); + q = (q - 1) * (r - 1) * (s - 1); + } + if (p > 0) q = -q; + else p = -p; + if (2 * p < Math.min(3 * xm * q - Math.abs(tol1 * q), Math.abs(e * q))) { + e = d; + d = p / q; + } else { + d = xm; + e = xm; + } + } else { + d = xm; + e = xm; + } + a = b; + fa = fb; + b += Math.abs(d) > tol1 ? d : xm > 0 ? tol1 : -tol1; + fb = f(b); + } + return { x: b, converged: false }; +} +// --------------------------------------------------------------------------- +// 1D linear binning — port of KDEpy's linbin_cython / linbin_numpy +// +// Each data point distributes its weight linearly to the two nearest grid +// points (floor and ceil), proportional to the fractional distance. +// Returns a Float64Array of length gridPoints.length that sums to 1. +// --------------------------------------------------------------------------- +/** + * Bin scattered data onto a uniform grid using linear (tent) weighting. + * + * Rather than placing each data point in a single bucket (histogram-style), + * each point splits its weight between its two nearest grid neighbours + * proportionally to how close it is to each. This avoids the sharp edges + * of ordinary histograms and is required before FFT-based convolution. + * + * @param data - raw sample values + * @param gridPoints - uniformly-spaced grid positions (e.g. from autogrid1D) + * @param weights - per-sample weights; if omitted, all samples are equal + * @returns Float64Array of length gridPoints.length that sums to ≈1 + */ +export function linearBinning1D(data, gridPoints, weights) { + const G = gridPoints.length; + const minGrid = gridPoints[0]; + const maxGrid = gridPoints[G - 1]; + const dx = (maxGrid - minGrid) / (G - 1); + // Normalised weights + let w; + if (weights !== undefined) { + const wSum = weights.reduce((a, b) => a + b, 0); + w = weights.map((wi) => wi / wSum); + } else { + const uni = 1 / data.length; + w = new Array(data.length).fill(uni); + } + // Extra element absorbs any data point that lands exactly on the upper edge + const result = new Float64Array(G + 1); + for (let i = 0; i < data.length; i++) { + const t = (data[i] - minGrid) / dx; + const lo = Math.floor(t); + const frac = t - lo; + if (lo >= 0 && lo < G) result[lo] += w[i] * (1 - frac); + if (lo + 1 <= G) result[lo + 1] += w[i] * frac; + } + return result.slice(0, G); +} +// --------------------------------------------------------------------------- +// 1D autogrid — port of KDEpy's autogrid for 1D +// --------------------------------------------------------------------------- +/** + * Build a uniform evaluation grid that spans the data range with padding. + * + * The padding prevents the KDE from dropping to zero too abruptly at the + * edges of the observed data, which would distort bandwidth estimation. + * The grid size should be a power of 2 for efficient FFT convolution. + * + * @param data - raw sample values (used only to find min/max) + * @param boundaryAbs - minimum padding in data units on each side (default 3) + * @param numPoints - number of grid points; use a power of 2 (default 1024) + * @param boundaryRel - padding as a fraction of the data range (default 0.05) + * @returns Float64Array of numPoints evenly-spaced x values + */ +export function autogrid1D( + data, + boundaryAbs = 3, + numPoints = 1024, + boundaryRel = 0.05, +) { + let minData = data[0], + maxData = data[0]; + for (let i = 1; i < data.length; i++) { + if (data[i] < minData) minData = data[i]; + if (data[i] > maxData) maxData = data[i]; + } + const range = maxData - minData; + const outside = Math.max(boundaryRel * range, boundaryAbs); + const lo = minData - outside; + const hi = maxData + outside; + const grid = new Float64Array(numPoints); + for (let i = 0; i < numPoints; i++) { + grid[i] = lo + ((hi - lo) * i) / (numPoints - 1); + } + return grid; +} +// --------------------------------------------------------------------------- +// Gaussian kernel (1D) +// K(x, bw) = exp(-x^2 / (2*bw^2)) / (sqrt(2*pi) * bw) +// This is the standard Gaussian PDF with std = bw, matching KDEpy's +// Kernel(gaussian, var=1).evaluate(x, bw, norm=2) in 1D. +// --------------------------------------------------------------------------- +function gaussianKernel1D(x, bw) { + return Math.exp((-x * x) / (2 * bw * bw)) / (Math.sqrt(2 * Math.PI) * bw); +} +// Find x > 0 where Gaussian kernel drops to atol — matches +// KDEpy's Kernel.practical_support(bw, atol=10e-5) for Gaussian. +// 10e-5 in Python == 1e-4. +function gaussianPracticalSupport(bw, atol = 10e-5) { + const xtol = 1e-3; + const result = brentq((x) => gaussianKernel1D(x, bw) - atol, 0, 8 * bw, xtol); + if (!result.converged) { + throw new Error('Could not find practical support for Gaussian kernel.'); + } + return result.x + xtol; +} +// --------------------------------------------------------------------------- +// ISJ fixed-point function — port of KDEpy's _fixed_point +// +// Implements the fixed-point equation t = ξ γ^5(t) from Botev et al. (2010). +// I_sq = [1², 2², ..., (n-1)²] (length n-1) +// a2 = dct[1:]² (length n-1) +// --------------------------------------------------------------------------- +function fixedPoint(t, N, I_sq, a2) { + const ell = 7; // 5 derivative steps as recommended in the paper + const piSq = Math.PI * Math.PI; + // f = 0.5 * π^(2*ell) * Σ_i I_sq[i]^ell * a2[i] * exp(-I_sq[i] * π² * t) + let f = 0; + for (let i = 0; i < I_sq.length; i++) { + f += Math.pow(I_sq[i], ell) * a2[i] * Math.exp(-I_sq[i] * piSq * t); + } + f *= 0.5 * Math.pow(Math.PI, 2 * ell); + if (f <= 0) return -1; + // Loop s = ell-1 down to 2 (mirrors Python's reversed(range(2, ell))) + for (let s = ell - 1; s >= 2; s--) { + // odd_numbers_prod = 1 * 3 * 5 * ... * (2s-1) == (2s-1)!! + let oddProd = 1; + for (let k = 1; k <= 2 * s - 1; k += 2) oddProd *= k; + const K0 = oddProd / Math.sqrt(2 * Math.PI); + const constVal = (1 + Math.pow(0.5, s + 0.5)) / 3; + const time = Math.pow((2 * constVal * K0) / (N * f), 2 / (3 + 2 * s)); + f = 0; + for (let i = 0; i < I_sq.length; i++) { + f += Math.pow(I_sq[i], s) * a2[i] * Math.exp(-I_sq[i] * piSq * time); + } + f *= 0.5 * Math.pow(Math.PI, 2 * s); + } + const tOpt = Math.pow(2 * N * Math.sqrt(Math.PI) * f, -0.4); + return t - tOpt; +} +// --------------------------------------------------------------------------- +// ISJ root solver — port of KDEpy's _root +// --------------------------------------------------------------------------- +function isjRoot(N, I_sq, a2) { + const Nc = Math.max(Math.min(1050, N), 50); + let tol = 10e-12 + (0.01 * (Nc - 50)) / 1000; + for (;;) { + const res = brentq((t) => fixedPoint(t, N, I_sq, a2), 0, tol); + if (res.converged && res.x > 0) return res.x; + tol *= 2; + if (tol >= 1) + throw new Error('ISJ root finding did not converge. Need more data.'); + } +} +// --------------------------------------------------------------------------- +// ISJ bandwidth selection — port of KDEpy's improved_sheather_jones +// --------------------------------------------------------------------------- +/** + * Data-driven bandwidth selection using the Improved Sheather–Jones (ISJ) + * plug-in estimator. + * + * What is bandwidth? + * ------------------ + * KDE works by placing a small "bump" (kernel) at each data point and summing + * them. The bandwidth controls how wide each bump is — too narrow and the + * curve is spiky and noisy; too wide and it blurs out real features. Choosing + * the right bandwidth automatically is the central problem in KDE. + * + * Why ISJ? + * -------- + * Simpler rules like Silverman's rule assume the data looks roughly Gaussian. + * ISJ makes no such assumption: it finds the bandwidth that minimises the + * mean integrated squared error by solving a fixed-point equation derived + * from the data's own frequency content (via DCT). This makes it reliable + * for multimodal or skewed distributions, which are common in performance data. + * + * @param data - raw sample values (at least a few dozen points recommended) + * @param weights - optional per-sample weights; zero/negative weights are dropped + * @returns optimal bandwidth in the same units as the data + */ +export function improvedSheatherJones(data, weights) { + const n = 1024; // 2^10, matching KDEpy + let d = data; + let w = weights; + // Drop zero/negative weights (KDEpy does: data = data[weights > 0]) + if (w !== undefined) { + const pairs = d.map((v, i) => [v, w[i]]); + const pos = pairs.filter(([, wi]) => wi > 0); + d = pos.map(([v]) => v); + w = pos.map(([, wi]) => wi); + } + let minD = d[0], + maxD = d[0]; + for (let i = 1; i < d.length; i++) { + if (d[i] < minD) minD = d[i]; + if (d[i] > maxD) maxD = d[i]; + } + const R = maxD - minD; + const N = new Set(d).size; // number of unique values + // ISJ uses boundary_abs=6, boundary_rel=0.5 (wider grid for stable estimation) + const xmesh = autogrid1D(d, 6, n, 0.5); + const xmeshArr = Array.from(xmesh); + const initialData = linearBinning1D(d, xmeshArr, w); + // Type-2 DCT of the binned data + const a = dct2(Array.from(initialData)); + // I_sq = [1², 2², ..., (n-1)²] + const I_sq = new Float64Array(n - 1); + for (let i = 0; i < n - 1; i++) I_sq[i] = (i + 1) * (i + 1); + // a2 = a[1:]² (skip DC component) + const a2 = new Float64Array(n - 1); + for (let i = 0; i < n - 1; i++) a2[i] = a[i + 1] * a[i + 1]; + const tStar = isjRoot(N, I_sq, a2); + return Math.sqrt(tStar) * R; +} +// --------------------------------------------------------------------------- +// Silverman's rule — port of KDEpy's silvermans_rule +// --------------------------------------------------------------------------- +/** + * Simple rule-of-thumb bandwidth selection (Silverman 1986). + * + * Estimates bandwidth as a function of sample size and spread (std / IQR). + * Fast and robust, but assumes the data is roughly unimodal and bell-shaped. + * Prefer improvedSheatherJones for multimodal or heavy-tailed distributions. + * + * @param data - raw sample values + * @returns bandwidth estimate in the same units as the data + */ +export function silvermansRule(data) { + const n = data.length; + if (n <= 1) return 1; + const mean = data.reduce((a, b) => a + b, 0) / n; + const variance = data.reduce((s, x) => s + (x - mean) ** 2, 0) / (n - 1); + const std = Math.sqrt(variance); + const sorted = [...data].sort((a, b) => a - b); + // numpy percentile linear interpolation: index = q * (n-1), interpolate floor/ceil + function percentile(q) { + const idx = q * (n - 1); + const lo = Math.floor(idx), + hi = Math.ceil(idx); + return sorted[lo] + (idx - lo) * (sorted[hi] - sorted[lo]); + } + // scipy.stats.norm.ppf(.75) - scipy.stats.norm.ppf(.25) = 1.3489795003921634 + const iqr = (percentile(0.75) - percentile(0.25)) / 1.3489795003921634; + let sigma = Math.min(std, iqr > 0 ? iqr : std); + if (sigma <= 0) return 1; + return sigma * Math.pow((n * 3) / 4, -0.2); +} +// --------------------------------------------------------------------------- +// 1D convolution, mode='same' — matches scipy.signal.convolve(a, b, mode='same') +// +// Full convolution c[m] = Σ_k a[k] * b[m-k]. +// 'same' returns M elements starting at index (N-1)//2 of the full result, +// where M=len(a), N=len(b). +// --------------------------------------------------------------------------- +function convolve1DSame(a, b) { + const M = a.length; + const N = b.length; + const start = (N - 1) >> 1; + const result = new Float64Array(M); + for (let i = 0; i < M; i++) { + const cIdx = i + start; + const kMin = Math.max(0, cIdx - (N - 1)); + const kMax = Math.min(M - 1, cIdx); + let sum = 0; + for (let k = kMin; k <= kMax; k++) { + sum += a[k] * b[cIdx - k]; + } + result[i] = sum; + } + return result; +} +/** + * Kernel Density Estimate using FFT-based convolution (FFTKDE). + * + * What is KDE? + * ------------ + * A KDE turns a set of discrete samples into a smooth continuous curve that + * estimates the underlying probability density — think of it as a smooth + * histogram. Each sample contributes a small Gaussian "bump"; summing all + * bumps gives the density curve. + * + * Why FFT? + * -------- + * Naively evaluating the sum of N kernels at G grid points costs O(N·G). + * FFTKDE instead bins the data onto the grid (linearBinning1D) and convolves + * the binned data with the kernel using FFT, reducing cost to O(G log G) + * regardless of N. + * + * @param data - raw sample values (at least 2 required) + * @param bw - bandwidth: "ISJ" (default, data-driven), "silverman" + * (faster rule of thumb), or a positive number (fixed) + * @param weights - optional per-sample weights + * @param numGridPoints - number of x-axis evaluation points (default 1024) + * @param boundary - "none" (default) or "reflection" for data that cannot + * be negative (e.g. latency values): mirrors data at x=0 + * so the density doesn't leak below zero + * @returns { x, y, bandwidth } where x and y are the KDE curve coordinates + */ +export function fftkde( + data, + bw = 'ISJ', + weights, + numGridPoints = 1024, + boundary = 'none', +) { + if (data.length < 2) + throw new Error('fftkde requires at least 2 data points.'); + if (boundary === 'reflection') { + return fftkdeReflection(data, bw, weights, numGridPoints); + } + // 1. Bandwidth + let bandwidth; + if (bw === 'ISJ') { + bandwidth = improvedSheatherJones(data, weights); + } else if (bw === 'silverman') { + bandwidth = silvermansRule(data); + } else { + bandwidth = bw; + } + return fftkdeCore(data, bandwidth, weights, numGridPoints); +} +function fftkdeCore(data, bandwidth, weights, numGridPoints) { + // 2. Gaussian practical support — used as boundary_abs for the grid + const realBw = gaussianPracticalSupport(bandwidth); + // 3. Evaluation grid — boundary_abs = practical_support(bw), boundary_rel=0.05 + const gridArr = autogrid1D(data, realBw, numGridPoints, 0.05); + const grid = Array.from(gridArr); + // 4. Linear binning + const binnedData = linearBinning1D(data, grid, weights); + // 5. Grid spacing + const minGrid = grid[0]; + const maxGrid = grid[numGridPoints - 1]; + const dx = (maxGrid - minGrid) / (numGridPoints - 1); + // 6. L = number of grid steps for kernel half-width + const L = Math.min(Math.floor(realBw / dx), numGridPoints); + // 7. Evaluate kernel on [-L*dx, ..., 0, ..., L*dx] (2L+1 points) + const kernelSize = 2 * L + 1; + const kernelWeights = new Float64Array(kernelSize); + for (let k = 0; k < kernelSize; k++) { + kernelWeights[k] = gaussianKernel1D((k - L) * dx, bandwidth); + } + // 8. Convolve (mode='same') + const raw = convolve1DSame(binnedData, kernelWeights); + // 9. Clamp negatives (floating-point noise) + for (let i = 0; i < raw.length; i++) { + if (raw[i] < 0) raw[i] = 0; + } + return { x: grid, y: Array.from(raw), bandwidth }; +} +// Reflection method for non-negative data. +// Silverman, B. W. (1986). Density Estimation for Statistics and Data +// Analysis. Chapman and Hall, London. Pages 20–22. +// Mirror data at x=0, run KDE on augmented set, take x≥0 and double density. +function fftkdeReflection(data, bw, weights, numGridPoints) { + // Augment: original + reflection across 0 + const augmented = new Array(data.length * 2); + for (let i = 0; i < data.length; i++) { + augmented[i] = data[i]; + augmented[data.length + i] = -data[i]; + } + // Duplicate weights if provided + let augWeights; + if (weights) { + augWeights = new Array(weights.length * 2); + for (let i = 0; i < weights.length; i++) { + augWeights[i] = weights[i]; + augWeights[weights.length + i] = weights[i]; + } + } + // Bandwidth from original data (not augmented — augmented is symmetric and + // would give a wider bandwidth than appropriate for the one-sided distribution) + let bandwidth; + if (bw === 'ISJ') { + bandwidth = improvedSheatherJones(data, weights); + } else if (bw === 'silverman') { + bandwidth = silvermansRule(data); + } else { + bandwidth = bw; + } + // Run KDE on augmented data with double the grid points (we'll discard the left half) + const result = fftkdeCore( + augmented, + bandwidth, + augWeights, + numGridPoints * 2, + ); + // Keep only x ≥ 0, double density (the reflected half integrates to 0.5) + const x = []; + const y = []; + for (let i = 0; i < result.x.length; i++) { + if (result.x[i] >= 0) { + x.push(result.x[i]); + y.push(result.y[i] * 2); + } + } + return { x, y, bandwidth }; +} +// --------------------------------------------------------------------------- +// argrelmax — port of scipy.signal.argrelmax(y, order=order) +// +// Returns indices i where y[i] is strictly greater than all y[i±j] for +// j = 1..order. Boundary points (i < order or i >= N-order) are excluded. +// --------------------------------------------------------------------------- +/** + * Find indices of local maxima in an array. + * + * A point is a local maximum if it is strictly greater than all neighbours + * within `order` positions on each side. Larger `order` suppresses narrow + * noise spikes at the cost of merging closely-spaced genuine peaks. + * + * @param y - array of values (e.g. KDE density at each grid point) + * @param order - neighbourhood half-width to check (default 1) + * @returns array of indices where local maxima occur + */ +export function argrelmax(y, order = 1) { + const N = y.length; + const peaks = []; + for (let i = order; i < N - order; i++) { + let isMax = true; + for (let j = 1; j <= order; j++) { + if (y[i] <= y[i - j] || y[i] <= y[i + j]) { + isMax = false; + break; + } + } + if (isMax) peaks.push(i); + } + return peaks; +} +// --------------------------------------------------------------------------- +// fitKdeModes — port of perf_compare_stats.fit_kde_modes +// +// Runs FFTKDE with ISJ bandwidth, finds local maxima, applies valley-depth +// and data-fraction filters to produce a list of distinct modes. +// +// Parameters +// ---------- +// data : raw sample values +// valleyThreshold : mode boundary is valid only if valley < threshold * min(peak_heights) +// minPeakFraction : peak must be >= this fraction of the global max to count +// minDataFraction : a mode must contain >= this fraction of data to be kept +// --------------------------------------------------------------------------- +/** + * Detect distinct modes (peaks) in a sample distribution via KDE. + * + * Why does this matter for performance data? + * ------------------------------------------ + * Performance measurements are often multimodal: a benchmark may have a + * "fast path" (cache warm, branch predicted) and a "slow path" (cache miss, + * JIT deoptimisation). A single summary statistic like the mean or median + * conflates these paths and can hide regressions or improvements. Finding + * modes lets us report each code path separately. + * + * How it works + * ------------ + * 1. Fit a KDE to the data (trimmed to 1st–99th percentile for stability). + * 2. Find local maxima (peaks) in the density curve. + * 3. Valley-depth filter: two peaks are only counted as separate modes if + * the valley between them is deep enough (< valleyThreshold × shorter + * peak height). Shallow saddles are KDE smoothing artefacts. + * 4. Data-fraction filter: a mode must contain >= minDataFraction of the + * actual data points to be reported (avoids noise bumps at the tails). + * + * @param data - raw sample values (fewer than 4 → always 1 mode) + * @param valleyThreshold - how deep a valley must be to split two modes; + * 0 = never split, 1 = always split (default 0.5) + * @param minPeakFraction - minimum peak height as fraction of global max, + * filters tiny noise bumps (default 0.05) + * @param minDataFraction - minimum fraction of data a mode must contain + * to be kept (default 0.05) + * @returns { nModes, peakLocs, boundaries, x, y, bandwidth } + */ +export function fitKdeModes( + data, + valleyThreshold = 0.5, + minPeakFraction = 0.05, + minDataFraction = 0.05, +) { + const fallbackX = data.reduce((a, b) => a + b, 0) / data.length; + function fallback(x, y, bw) { + const medianVal = [...data].sort((a, b) => a - b)[ + Math.floor(data.length / 2) + ]; + return { + nModes: 1, + peakLocs: [medianVal], + boundaries: [], + x, + y, + bandwidth: bw, + }; + } + if (data.length < 4) { + return { + nModes: 1, + peakLocs: [fallbackX], + boundaries: [], + x: [], + y: [], + bandwidth: 0, + }; + } + // Trim 1st–99th percentile for fitting (matches Python) + const sorted = [...data].sort((a, b) => a - b); + const n = sorted.length; + function pct(q) { + const idx = q * (n - 1); + const lo = Math.floor(idx), + hi = Math.ceil(idx); + return sorted[lo] + (idx - lo) * (sorted[hi] - sorted[lo]); + } + const p1 = pct(0.01), + p99 = pct(0.99); + let dataFit = data.filter((v) => v >= p1 && v <= p99); + if (dataFit.length < 4) dataFit = data; + let kde; + try { + kde = fftkde(dataFit, 'ISJ'); + } catch { + return fallback([], [], 0); + } + const { x, y, bandwidth } = kde; + // Local maxima with order=3 (matches Python argrelmax order=3) + let peakIdxs = argrelmax(y, 3); + const yMax = Math.max(...y); + peakIdxs = peakIdxs.filter((i) => y[i] >= minPeakFraction * yMax); + if (peakIdxs.length === 0) { + const globalMaxIdx = y.indexOf(yMax); + return { + nModes: 1, + peakLocs: [x[globalMaxIdx]], + boundaries: [], + x, + y, + bandwidth, + }; + } + // Valley-depth filter — build `good` list of surviving peak indices + const good = [peakIdxs[0]]; + for (let k = 1; k < peakIdxs.length; k++) { + const nxt = peakIdxs[k]; + const prev = good[good.length - 1]; + let valleyMin = y[prev]; + for (let j = prev; j <= nxt; j++) if (y[j] < valleyMin) valleyMin = y[j]; + if (valleyMin < valleyThreshold * Math.min(y[prev], y[nxt])) { + good.push(nxt); + } else if (y[nxt] > y[good[good.length - 1]]) { + good[good.length - 1] = nxt; + } + } + // Compute boundaries (x-position of valley minimum between each adjacent pair) + function boundaries(peaks) { + const bs = []; + for (let i = 0; i < peaks.length - 1; i++) { + let minIdx = peaks[i]; + for (let j = peaks[i]; j <= peaks[i + 1]; j++) + if (y[j] < y[minIdx]) minIdx = j; + bs.push(x[minIdx]); + } + return bs; + } + // Data-fraction filter: drop modes with < minDataFraction of points + function assignModes(bounds) { + return data.map((v) => { + let m = 0; + while (m < bounds.length && v > bounds[m]) m++; + return m; + }); + } + const bs0 = boundaries(good); + const assignments0 = assignModes(bs0); + const keep = good + .map((_, i) => i) + .filter( + (i) => + assignments0.filter((a) => a === i).length / data.length >= + minDataFraction, + ); + if (keep.length < 2) { + const bestIdx = good.reduce((a, b) => (y[a] > y[b] ? a : b)); + return { + nModes: 1, + peakLocs: [x[bestIdx]], + boundaries: [], + x, + y, + bandwidth, + }; + } + const finalGood = keep.map((i) => good[i]); + const finalBounds = boundaries(finalGood); + return { + nModes: finalGood.length, + peakLocs: finalGood.map((i) => x[i]), + boundaries: finalBounds, + x, + y, + bandwidth, + }; +}