@@ -413,16 +413,16 @@ exports[`Results View The table should match snapshot and other elements should
- Not sorted by MD (%)
+ Not sorted by Mode Δ (%)
- MD (%)
+ Mode Δ (%)
+
@@ -458,6 +469,17 @@ exports[`Results View The table should match snapshot and other elements should
type="button"
>
Status
+
CD
+
@@ -540,6 +573,17 @@ exports[`Results View The table should match snapshot and other elements should
CLES (%)
+
@@ -581,6 +625,17 @@ exports[`Results View The table should match snapshot and other elements should
type="button"
>
Sig
+
-
- 0 %
-
+ ~1.85 %
-
- 0 %
-
+ ~1.08 %
-
- 0 %
-
+ ~-24.00 %
-
- 0 %
-
+ ~-2.40 %
@@ -1386,16 +1386,16 @@ exports[`Results Table Should match snapshot 1`] = `
- Not sorted by MD (%)
+ Not sorted by Mode Δ (%)
- MD (%)
+ Mode Δ (%)
+
@@ -1431,6 +1442,17 @@ exports[`Results Table Should match snapshot 1`] = `
type="button"
>
Status
+
CD
+
@@ -1513,6 +1546,17 @@ exports[`Results Table Should match snapshot 1`] = `
CLES (%)
+
@@ -1554,6 +1598,17 @@ exports[`Results Table Should match snapshot 1`] = `
type="button"
>
Sig
+
-
- 0 %
-
+ ~1.85 %
-
- 0 %
-
+ ~1.08 %
-
- 0 %
-
+ ~-2.40 %
-
- 0 %
-
+ ~-24.00 %
@@ -4269,16 +4308,16 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
- Not sorted by MD (%)
+ Not sorted by Mode Δ (%)
- MD (%)
+ Mode Δ (%)
+
@@ -4314,6 +4364,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
type="button"
>
Status
+
CD
+
@@ -4396,6 +4468,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
CLES (%)
+
@@ -4437,6 +4520,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
type="button"
>
Sig
+
-
- 1.849 %
-
+ ~1.85 %
-
- 1.078 %
-
+ ~1.08 %
-
- -2.401 %
-
+ ~-2.40 %
- -
+ ~0.00 %
CD
+
`;
@@ -5589,6 +5682,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
CD
+
`;
@@ -5614,6 +5718,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
CD
+
@@ -5755,6 +5870,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
CLES (%)
+
@@ -5800,6 +5926,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
CLES (%)
+
@@ -5835,32 +5972,32 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
[
"a11yr dhtml.html opt e10s fission stylo webrender",
" rev: spam",
- " - Linux 18.04, 1.849 %, Regression, -, -, 45.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 0.1, -, 25.00 %",
- " - Windows 10, -, , -, , 100.00 %",
- " - Windows 10, -2.401 %, , -, , 50.00 %",
+ " - macOS 10.15, No modes, Improvement, 0.1, -, 25.00 %",
+ " - Linux 18.04, No modes, Regression, -, -, 45.00 %",
+ " - Windows 10, No modes, , -, , 50.00 %",
+ " - Windows 10, No modes, , -, , 100.00 %",
" rev: tictactoe",
- " - Linux 18.04, 1.849 %, Regression, 0.8, -, 44.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 0.9, -, 24.00 %",
- " - Windows 10, -, , 0.8, , 99.00 %",
- " - Windows 10, -2.401 %, , 0.8, , 49.00 %",
+ " - macOS 10.15, No modes, Improvement, 0.9, -, 24.00 %",
+ " - Linux 18.04, No modes, Regression, 0.8, -, 44.00 %",
+ " - Windows 10, No modes, , 0.8, , 49.00 %",
+ " - Windows 10, No modes, , 0.8, , 99.00 %",
"a11yr aria.html opt e10s fission stylo webrender",
" rev: spam",
- " - Linux 18.04, 1.849 %, Regression, 1.2, -, 44.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 1.3, -, 24.00 %",
- " - Windows 10, -, , 1.2, , 99.00 %",
- " - Windows 10, -2.401 %, , 1.2, , 49.00 %",
+ " - macOS 10.15, No modes, Improvement, 1.3, -, 24.00 %",
+ " - Linux 18.04, No modes, Regression, 1.2, -, 44.00 %",
+ " - Windows 10, No modes, , 1.2, , 49.00 %",
+ " - Windows 10, No modes, , 1.2, , 99.00 %",
" rev: tictactoe",
- " - Linux 18.04, 1.849 %, Regression, 2, -, 43.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 2.1, -, 23.00 %",
- " - Windows 10, -, , 2, , 98.00 %",
- " - Windows 10, -2.401 %, , 2, , 48.00 %",
+ " - macOS 10.15, No modes, Improvement, 2.1, -, 23.00 %",
+ " - Linux 18.04, No modes, Regression, 2, -, 43.00 %",
+ " - Windows 10, No modes, , 2, , 48.00 %",
+ " - Windows 10, No modes, , 2, , 98.00 %",
]
`;
exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion can sort params from the URL on mann-whitney-u test_version 9`] = `
- Sorted by MD (%) in descending order
+ Sorted by Mode Δ (%) in descending order
- MD (%)
+ Mode Δ (%)
+
@@ -5907,32 +6055,32 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
[
"a11yr dhtml.html opt e10s fission stylo webrender",
" rev: spam",
- " - Windows 10, -2.401 %, , -, , 50.00 %",
- " - Windows 10, -, , -, , 100.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 0.1, -, 25.00 %",
- " - Linux 18.04, 1.849 %, Regression, -, -, 45.00 %",
+ " - macOS 10.15, No modes, Improvement, 0.1, -, 25.00 %",
+ " - Linux 18.04, No modes, Regression, -, -, 45.00 %",
+ " - Windows 10, No modes, , -, , 50.00 %",
+ " - Windows 10, No modes, , -, , 100.00 %",
" rev: tictactoe",
- " - Windows 10, -2.401 %, , 0.8, , 49.00 %",
- " - Windows 10, -, , 0.8, , 99.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 0.9, -, 24.00 %",
- " - Linux 18.04, 1.849 %, Regression, 0.8, -, 44.00 %",
+ " - macOS 10.15, No modes, Improvement, 0.9, -, 24.00 %",
+ " - Linux 18.04, No modes, Regression, 0.8, -, 44.00 %",
+ " - Windows 10, No modes, , 0.8, , 49.00 %",
+ " - Windows 10, No modes, , 0.8, , 99.00 %",
"a11yr aria.html opt e10s fission stylo webrender",
" rev: spam",
- " - Windows 10, -2.401 %, , 1.2, , 49.00 %",
- " - Windows 10, -, , 1.2, , 99.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 1.3, -, 24.00 %",
- " - Linux 18.04, 1.849 %, Regression, 1.2, -, 44.00 %",
+ " - macOS 10.15, No modes, Improvement, 1.3, -, 24.00 %",
+ " - Linux 18.04, No modes, Regression, 1.2, -, 44.00 %",
+ " - Windows 10, No modes, , 1.2, , 49.00 %",
+ " - Windows 10, No modes, , 1.2, , 99.00 %",
" rev: tictactoe",
- " - Windows 10, -2.401 %, , 2, , 48.00 %",
- " - Windows 10, -, , 2, , 98.00 %",
- " - macOS 10.15, 1.078 %, Improvement, 2.1, -, 23.00 %",
- " - Linux 18.04, 1.849 %, Regression, 2, -, 43.00 %",
+ " - macOS 10.15, No modes, Improvement, 2.1, -, 23.00 %",
+ " - Linux 18.04, No modes, Regression, 2, -, 43.00 %",
+ " - Windows 10, No modes, , 2, , 48.00 %",
+ " - Windows 10, No modes, , 2, , 98.00 %",
]
`;
exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion can sort params from the URL on mann-whitney-u test_version 11`] = `
- Sorted by MD (%) in ascending order
+ Sorted by Mode Δ (%) in ascending order
- MD (%)
+ Mode Δ (%)
+
@@ -5994,6 +6153,17 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion
type="button"
>
Sig
+
-
- 1.078 %
-
+ ~1.08 %
-
- 1.078 %
-
+ ~1.08 %
Estimated Modes
-
-
+
+ 0
+
+
+ 0
+
No modes or data for Base and New, possible oversmoothing, KDE evaluation failed
@@ -900,7 +904,7 @@ exports[`Results View Should display Base, New and Common graphs with tooltips 1
style="display: flex;"
>
Estimated Modes
-
-
+
+ 0
+
+
+ 0
+
No modes or data for Base and New, possible oversmoothing, KDE evaluation failed
@@ -1501,7 +1509,7 @@ exports[`Results View The table should match snapshot and other elements should
@@ -1568,16 +1576,16 @@ exports[`Results View The table should match snapshot and other elements should
- Not sorted by MD (%)
+ Not sorted by Mode Δ (%)
- MD (%)
+ Mode Δ (%)
+
@@ -1613,6 +1632,17 @@ exports[`Results View The table should match snapshot and other elements should
type="button"
>
Status
+
CD
+
@@ -1695,6 +1736,17 @@ exports[`Results View The table should match snapshot and other elements should
CLES (%)
+
@@ -1736,6 +1788,17 @@ exports[`Results View The table should match snapshot and other elements should
type="button"
>
Sig
+
-
- 0 %
-
+ ~1.85 %
-
- 0 %
-
+ ~1.08 %
-
- 0 %
-
+ ~-24.00 %
-
- 0 %
-
+ ~-2.40 %
CD
+
`;
@@ -47,6 +58,17 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
CD
+
@@ -92,6 +114,17 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
CD
+
@@ -145,6 +178,17 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
CD
+
@@ -242,6 +286,17 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
CLES (%)
+
@@ -287,6 +342,17 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
CD
+
`;
@@ -312,6 +378,17 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
CD
+
`;
@@ -337,6 +414,17 @@ exports[`SubtestsResultsView Component Tests for mann-whitney-u testVersion tabl
CD
+
`;
@@ -840,7 +928,7 @@ exports[`SubtestsResultsView Component Tests should render the subtests results
role="table"
>
@@ -900,16 +988,16 @@ exports[`SubtestsResultsView Component Tests should render the subtests results
- Not sorted by MD (%)
+ Not sorted by Mode Δ (%)
- MD (%)
+ Mode Δ (%)
+
@@ -945,6 +1044,17 @@ exports[`SubtestsResultsView Component Tests should render the subtests results
type="button"
>
Status
+
CD
+
@@ -1027,6 +1148,17 @@ exports[`SubtestsResultsView Component Tests should render the subtests results
CLES (%)
+
@@ -1068,6 +1200,17 @@ exports[`SubtestsResultsView Component Tests should render the subtests results
type="button"
>
Sig
+
-
- 0 %
-
+ ~-1.43 %
-
- 0 %
-
+ ~1.14 %
-
- 0 %
-
+ ~-1.44 %
-
- 0 %
-
+ ~1.04 %
-
- 0 %
-
+ ~0.98 %
@@ -2811,16 +2934,16 @@ exports[`SubtestsViewCompareOverTime Component Tests in mann-whitney-u testVersi
- Not sorted by MD (%)
+ Not sorted by Mode Δ (%)
- MD (%)
+ Mode Δ (%)
+
@@ -2856,6 +2990,17 @@ exports[`SubtestsViewCompareOverTime Component Tests in mann-whitney-u testVersi
type="button"
>
Status
+
+ );
+}
+
type FilterableColumnHeaderProps = {
name: string;
columnId: string;
@@ -78,6 +90,7 @@ type FilterableColumnHeaderProps = {
onToggleFilter: (checkedValues: Set) => unknown;
onClearFilter: () => unknown;
tooltip?: ReactNode;
+ showInfoIcon?: boolean;
};
function FilterableColumnHeader({
@@ -87,6 +100,7 @@ function FilterableColumnHeader({
checkedValues,
onToggleFilter,
onClearFilter,
+ showInfoIcon,
tooltip,
}: FilterableColumnHeaderProps) {
const popupState = usePopupState({ variant: 'popover', popupId: columnId });
@@ -127,6 +141,7 @@ function FilterableColumnHeader({
sx={{ paddingInline: 1.5, fontSize: '16px' }}
>
{name}
+ {showInfoIcon && }
void;
+ showInfoIcon?: boolean;
};
function SortableColumnHeader({
@@ -218,6 +234,7 @@ function SortableColumnHeader({
displayLabel,
sortDirection,
onToggle,
+ showInfoIcon,
}: SortableColumnHeaderProps) {
const buttonAriaLabel = sortDirection
? `${name} (Currently sorted by this column. Click to change)`
@@ -263,6 +280,7 @@ function SortableColumnHeader({
sx={inlineIconStyle}
/>
{displayLabel ? name : null}
+ {displayLabel && showInfoIcon ? : null}
);
}
@@ -365,6 +383,7 @@ function TableHeader({
onToggleFilter(header.key, checkedValues)
}
tooltip={header.tooltip}
+ showInfoIcon={header.tooltipIcon}
/>
);
@@ -377,6 +396,7 @@ function TableHeader({
onToggle={(newSortDirection) =>
onToggleSort(header.key, newSortDirection)
}
+ showInfoIcon={header.tooltipIcon}
/>
);
} else if ('filter' in header) {
@@ -391,6 +411,7 @@ function TableHeader({
onToggleFilter(header.key, checkedValues)
}
tooltip={header.tooltip}
+ showInfoIcon={header.tooltipIcon}
/>
);
}
diff --git a/src/types/state.ts b/src/types/state.ts
index 2091e09ef..da54f1226 100644
--- a/src/types/state.ts
+++ b/src/types/state.ts
@@ -242,6 +242,15 @@ export type MannWhitneyResultsItem = {
is_meaningful: boolean | null;
more_runs_are_needed: boolean | null;
warning_c_delta?: string | null;
+ // Client-side modality analysis precomputed by the Mann-Whitney loader.
+ // Single source of truth for any UI that needs to talk about modes (the
+ // Mode Δ column, the Distribution Interpretation row, KdeModesPanel) so
+ // those views can't drift onto different mode-detection logic. `null`
+ // means we never ran the pipeline (legacy/stale data). See
+ // computeModalityAnalysis in kdeAnalysis.ts.
+ modeDeltaPct?: number | null;
+ baseModeCount?: number | null;
+ newModeCount?: number | null;
/*
Each test has a signature and each signature may or may not have a parent_signature.
If a signature has a parent_signature then we are looking at a subtest. For regular tests this field will be null.
diff --git a/src/types/types.ts b/src/types/types.ts
index 962134a0e..a43556aba 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -17,6 +17,9 @@ export interface BasicColumn {
// Used in the grid CSS property to configure the width of the column.
gridWidth: string;
tooltip?: React.ReactNode;
+ // When true, render a small info icon next to the column title so users
+ // know a hover-tooltip is available. Opt-in per column.
+ tooltipIcon?: boolean;
}
// This interface is used for a column that can be filtered.
diff --git a/src/utils/kdeAnalysis.ts b/src/utils/kdeAnalysis.ts
index 2a3ff0795..3c20507bc 100644
--- a/src/utils/kdeAnalysis.ts
+++ b/src/utils/kdeAnalysis.ts
@@ -9,6 +9,7 @@ import {
fftkde,
fitModesFromKde,
improvedSheatherJones,
+ matchModes,
silvermansRule,
} from './kde.js';
@@ -137,3 +138,104 @@ export function computeModeInfo(
letters: assignLetters(peakLocs),
};
}
+
+/**
+ * Combined output of the client-side modality pipeline. Used as the single
+ * source of truth for any UI that needs to talk about modes (counts in the
+ * Distribution Interpretation row, the Mode Δ column, KdeModesPanel, etc.)
+ * so the page never contradicts itself.
+ */
+export type ModalityAnalysis = {
+ baseModes: ModeInfo;
+ newModes: ModeInfo;
+ // How far the biggest group of runs moved from Base to New, as a % of the
+ // base peak's position. null when there's no trustworthy group to measure
+ // (see computeModalityAnalysis for the exact cases).
+ dominantModeShiftPct: number | null;
+};
+
+export const EMPTY_MODALITY_ANALYSIS: ModalityAnalysis = {
+ baseModes: EMPTY_MODE_INFO,
+ newModes: EMPTY_MODE_INFO,
+ dominantModeShiftPct: null,
+};
+
+// Each peak in the KDE curve covers some share of the runs (its "area
+// fraction"); all peaks on one side add up to ~1, i.e. 100% of the runs.
+// A peak must cover at least this share — 10% — to count as a real group of
+// runs. Smaller peaks are treated as noise (a few stray runs) and ignored, so
+// a tiny bump that happens to move a lot can't hijack the reported shift.
+const MODE_AREA_FLOOR = 0.1;
+
+/**
+ * Run the full client-side modality pipeline for a base/new pair.
+ *
+ * Pipeline (same as `KdeModesPanel`): shared bandwidth (max of per-side
+ * `bandwidthFor`), `safeKde` both sides, `computeModeInfo` to get peaks +
+ * area fractions, `matchModes` to align base/new peaks. Then derives the
+ * shift of the dominant matched mode — the pair holding the largest share
+ * of the runs — as a signed percentage of the base peak location (positive =
+ * new peak shifted higher). Picking the biggest group rather than the biggest
+ * mover keeps a small, noisy peak from driving the reported number when the
+ * main peak barely moved.
+ *
+ * `dominantModeShiftPct` is `null` when:
+ * - either side has < 2 samples
+ * - either KDE fails (e.g. degenerate inputs)
+ * - mode detection finds no peaks on either side
+ * - no matched pairs (e.g. only unmatched modes — paths appeared/disappeared)
+ * - no matched pair clears MODE_AREA_FLOOR (only noise-level modes)
+ * - the dominant matched base peak is at exactly zero (can't divide)
+ *
+ * Mode counts (`baseModes.peakLocs.length` / `newModes.peakLocs.length`)
+ * are 0 in the same conditions that drive `dominantModeShiftPct` to null
+ * (except the divide-by-zero / below-floor cases, which still yield counts).
+ *
+ * @param valleyThreshold Passed to `fitModesFromKde`. Defaults to 0.5 to
+ * match `RevisionRowExpandable`'s slider default; the precompute path
+ * has no slider to read from.
+ */
+export function computeModalityAnalysis(
+ baseValues: number[],
+ newValues: number[],
+ isSubtest: boolean,
+ valleyThreshold: number = 0.5,
+): ModalityAnalysis {
+ if (baseValues.length < 2 || newValues.length < 2) {
+ return EMPTY_MODALITY_ANALYSIS;
+ }
+ const baseBw = bandwidthFor(baseValues, isSubtest) ?? 0;
+ const newBw = bandwidthFor(newValues, isSubtest) ?? 0;
+ const rawSharedBw = Math.max(baseBw, newBw);
+ const sharedBw = rawSharedBw > 0 ? rawSharedBw : undefined;
+ const bKde = safeKde(baseValues, sharedBw);
+ const nKde = safeKde(newValues, sharedBw);
+ if (!bKde || !nKde) return EMPTY_MODALITY_ANALYSIS;
+ const baseModes = computeModeInfo(bKde.x, bKde.y, valleyThreshold);
+ const newModes = computeModeInfo(nKde.x, nKde.y, valleyThreshold);
+ if (!baseModes.peakLocs.length || !newModes.peakLocs.length) {
+ return { baseModes, newModes, dominantModeShiftPct: null };
+ }
+ const { pairs } = matchModes(
+ baseModes.peakLocs,
+ baseModes.fracs,
+ newModes.peakLocs,
+ newModes.fracs,
+ );
+ // Report how far the biggest group of runs moved — not whichever peak moved
+ // the most. Each matched pair's "size" is the average share of runs under
+ // its two peaks; pairs smaller than MODE_AREA_FLOOR are noise and skipped.
+ // Of the rest we keep the largest (the dominant group) and use its shift.
+ let dominantModeShiftPct: number | null = null;
+ let bestMag = -Infinity;
+ for (const [bi, ni] of pairs) {
+ const baseLoc = baseModes.peakLocs[bi];
+ const newLoc = newModes.peakLocs[ni];
+ if (baseLoc === 0) continue; // can't express the shift as a percentage
+ const mag = (baseModes.fracs[bi] + newModes.fracs[ni]) / 2;
+ if (mag < MODE_AREA_FLOOR || mag <= bestMag) continue;
+ bestMag = mag;
+ dominantModeShiftPct = ((newLoc - baseLoc) / baseLoc) * 100;
+ }
+ return { baseModes, newModes, dominantModeShiftPct };
+}