Files
node/benchmark/_node-bench-analysis.js
T

764 lines
26 KiB
JavaScript

'use strict';
const { createHistogram } = require('node:perf_hooks');
const { inspect } = require('node:util');
function createRateHistogram(rates, scale, figures) {
const histogram = createHistogram({ figures });
for (const rate of rates) {
const value = Math.max(1, Math.round(rate * scale));
if (!Number.isSafeInteger(value)) {
throw new RangeError('Benchmark rate is too large for the histogram scale');
}
histogram.record(value);
}
return histogram;
}
function holmAdjust(pValues) {
const order = pValues
.map((p, index) => ({ index, p }))
.sort((a, b) => a.p - b.p);
const adjusted = new Array(order.length);
let running = 0;
for (let index = 0; index < order.length; index++) {
running = Math.max(
running,
Math.min(1, (order.length - index) * order[index].p),
);
adjusted[order[index].index] = running;
}
return adjusted;
}
function thresholdPValue(oldRates, newHistogram, scale, maxRegression) {
const factor = 1 - maxRegression / 100;
if (factor <= 0) return 1;
const thresholdHistogram = createRateHistogram(
oldRates.map((rate) => rate * factor), scale, 3);
const result = thresholdHistogram.welchTest(newHistogram);
if (Number.isNaN(result.pValue)) return 1;
return result.tStatistic > 0 ?
result.pValue / 2 : 1 - result.pValue / 2;
}
function isRegressionFailure(row, maxRegression) {
return row.pThresholdAdjusted < 0.05 &&
row.improvement + row.ci95 < -maxRegression;
}
function analyzeCompare(samples, scale, maxRegression) {
const groups = new Map();
for (const sample of samples) {
let group = groups.get(sample.identity);
if (group === undefined) {
const suffix = sample.configuration === '' ?
'' : ` ${sample.configuration}`;
group = {
name: `${sample.name}${suffix}`,
new: [],
old: [],
};
groups.set(sample.identity, group);
}
group[sample.binary].push(sample.rate);
}
const rows = [];
let skipped = 0;
for (const { name, old: oldRates, new: newRates } of groups.values()) {
if (oldRates.length < 2 || newRates.length < 2) {
skipped++;
continue;
}
const oldHistogram = createRateHistogram(oldRates, scale, 3);
const newHistogram = createRateHistogram(newRates, scale, 3);
const oldMean = oldRates.reduce((sum, rate) => sum + rate, 0) /
oldRates.length;
const newMean = newRates.reduce((sum, rate) => sum + rate, 0) /
newRates.length;
const improvement = ((newMean - oldMean) / oldMean) * 100;
const w95 = oldHistogram.welchTest(newHistogram, { confidence: 0.95 });
const w99 = oldHistogram.welchTest(newHistogram, { confidence: 0.99 });
const w999 = oldHistogram.welchTest(newHistogram, { confidence: 0.999 });
let stars = '';
if (w95.pValue < 0.001) stars = '***';
else if (w95.pValue < 0.01) stars = ' **';
else if (w95.pValue < 0.05) stars = ' *';
const ciPercent = (result) => {
const half = (result.confidenceInterval.upper -
result.confidenceInterval.lower) / 2;
return (half / (oldMean * scale)) * 100;
};
const row = {
ci95: ciPercent(w95),
ci99: ciPercent(w99),
ci999: ciPercent(w999),
improvement,
name,
pValue: Number.isNaN(w95.pValue) ? 1 : w95.pValue,
stars,
};
if (maxRegression !== undefined) {
row.pThreshold = thresholdPValue(
oldRates, newHistogram, scale, maxRegression);
}
rows.push(row);
}
const adjusted = holmAdjust(rows.map(({ pValue }) => pValue));
const thresholdAdjusted = maxRegression === undefined ? null :
holmAdjust(rows.map(({ pThreshold }) => pThreshold));
let underpowered = 0;
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
row.pAdjusted = adjusted[index];
if (thresholdAdjusted !== null) {
row.pThresholdAdjusted = thresholdAdjusted[index];
}
row.inconclusive = maxRegression > 0 &&
row.stars.trim() === '' &&
row.ci95 > maxRegression;
if (row.inconclusive) underpowered++;
}
const output = [];
const maxNameLength = rows.reduce(
(maximum, { name }) => Math.max(maximum, name.length), 0);
const pad = (value, length) =>
value + ' '.repeat(Math.max(0, length - value.length));
const padStart = (value, length) =>
' '.repeat(Math.max(0, length - value.length)) + value;
output.push(`${pad('', maxNameLength)} confidence` +
' improvement accuracy (*) (**) (***)');
for (const row of rows) {
const improvement =
`${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
output.push(
`${pad(row.name, maxNameLength)} ${pad(row.stars, 10)}` +
` ${padStart(improvement, 11)}` +
` ±${row.ci95.toFixed(2)}%` +
` ±${row.ci99.toFixed(2)}%` +
` ±${row.ci999.toFixed(2)}%` +
`${row.inconclusive ? ' (inconclusive)' : ''}`,
);
}
if (skipped > 0) {
output.push('');
output.push(
`Note: ${skipped} configuration${skipped === 1 ? ' was' : 's were'}` +
' skipped because Welch\'s t-test requires at least 2 samples per' +
' binary. Use --runs 2 or higher.',
);
}
printCompareChart(output, rows, maxNameLength);
output.push('');
output.push(
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).`,
'Use --scale to adjust precision if needed.',
'',
);
const significant = rows.filter(({ pAdjusted }) => pAdjusted < 0.05).length;
output.push(
'The confidence markers above are per-benchmark and uncorrected. ' +
`After Holm-Bonferroni correction across ${rows.length} comparison` +
`${rows.length === 1 ? '' : 's'}, ${significant} remain` +
`${significant === 1 ? 's' : ''} significant at 5%.`,
);
if (maxRegression !== undefined) {
output.push(
`For --max-regression, one-sided p-values against the ` +
`${maxRegression}% threshold were corrected separately.`,
);
}
if (maxRegression > 0 && underpowered > 0) {
output.push('');
output.push(
`Note: ${underpowered} of ${rows.length} comparison` +
`${rows.length === 1 ? '' : 's'} could not resolve an effect as small ` +
`as ${maxRegression}% and are marked (inconclusive). Raise --runs to ` +
'narrow their confidence intervals.',
);
}
const failures = maxRegression !== undefined ?
rows.filter((row) => isRegressionFailure(row, maxRegression)) : [];
if (failures.length > 0) {
output.push('');
output.push(
`FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` +
` regressed by more than ${maxRegression}% (the 95% interval excludes ` +
`the threshold and its one-sided test is family-wise corrected across ` +
`${rows.length} comparisons):`,
);
for (const failure of failures) {
output.push(
` ${failure.name} ${failure.improvement.toFixed(2)}% ` +
`(95% CI up to ${(failure.improvement + failure.ci95).toFixed(2)}%, ` +
`adjusted threshold p=` +
`${failure.pThresholdAdjusted.toExponential(2)})`,
);
}
}
return {
failed: failures.length > 0,
output: `${output.join('\n')}\n`,
rows,
};
}
function printCompareChart(output, rows, maxNameLength) {
if (rows.length === 0) return;
const width = 40;
const halfWidth = width / 2;
let maximum = 0;
for (const row of rows) {
maximum = Math.max(maximum, Math.abs(row.improvement) + row.ci95);
}
if (maximum === 0) maximum = 1;
const left = `-${maximum.toFixed(1)}%`;
const right = `+${maximum.toFixed(1)}%`;
const centerLabel = '0%';
const labelPadding = maxNameLength + 5;
output.push('');
output.push(
' '.repeat(labelPadding) + left +
' '.repeat(Math.max(
0, halfWidth - left.length - Math.floor(centerLabel.length / 2))) +
centerLabel +
' '.repeat(Math.max(
0, halfWidth - Math.ceil(centerLabel.length / 2) - right.length)) +
right,
);
for (const row of rows) {
const center = halfWidth;
const result = center + (row.improvement / maximum) * halfWidth;
const lower = center +
((row.improvement - row.ci95) / maximum) * halfWidth;
const upper = center +
((row.improvement + row.ci95) / maximum) * halfWidth;
let bar = '';
for (let index = 0; index < width; index++) {
const position = index + 0.5;
if (index === Math.floor(center)) {
bar += '|';
} else if ((row.improvement >= 0 &&
position > center && position <= result) ||
(row.improvement < 0 &&
position < center && position >= result)) {
bar += row.stars === '' ? '▓' : '█';
} else if (position >= lower && position <= upper) {
bar += '░';
} else {
bar += ' ';
}
}
const label = `${row.improvement >= 0 ? '+' : ''}` +
`${row.improvement.toFixed(2)}%`;
output.push(
`${row.name.padEnd(maxNameLength)} ${bar} ${label} ${row.stars.trim()}`,
);
}
}
function histogramScale(rates) {
let minimum = Infinity;
let maximum = 0;
for (const rate of rates) {
if (rate > 0 && rate < minimum) minimum = rate;
if (rate > maximum) maximum = rate;
}
if (!Number.isFinite(minimum) || maximum === 0) return 1;
let scale = 1;
while (minimum * scale < 1e6 && maximum * scale < 1e15) scale *= 10;
return scale;
}
function validateScatterParameters(samples, xAxis, category) {
if (category !== undefined && category === xAxis) {
throw new Error('--xaxis and --category must name different parameters');
}
for (const key of [xAxis, category]) {
if (key === undefined) continue;
if (samples.some(({ params }) =>
!Object.hasOwn(params, key))) {
const available = [...new Set(samples.flatMap(
({ params }) => Object.keys(params)))].sort();
throw new Error(
`The variable '${key}' is not present in every configuration. ` +
`Available variables: ${available.join(', ')}`,
);
}
}
}
function analyzeScatter(samples, xAxis, category, showChart) {
validateScatterParameters(samples, xAxis, category);
const parameterNames = [...new Set(samples.flatMap(
({ params }) => Object.keys(params)))];
const aggregated = parameterNames.filter((name) => {
if (name === xAxis || name === category) return false;
const first = samples[0].params[name];
return samples.some(({ params }) => params[name] !== first);
});
const groups = new Map();
for (const sample of samples) {
const xValue = sample.params[xAxis];
const categoryValue = category === undefined ?
undefined : sample.params[category];
const key = valueKey([xValue, categoryValue]);
let group = groups.get(key);
if (group === undefined) {
group = {
categoryValue,
members: [],
observations: new Map(),
xValue,
};
groups.set(key, group);
}
group.members.push(sample);
let rates = group.observations.get(sample.observation);
if (rates === undefined) {
rates = [];
group.observations.set(sample.observation, rates);
}
rates.push(sample.rate);
}
for (const group of groups.values()) {
group.processRates = [...group.observations].map(([observation, rates]) => ({
observation,
rate: rates.reduce((sum, rate) => sum + rate, 0) / rates.length,
}));
group.rates = group.processRates.map(({ rate }) => rate);
}
const scale = histogramScale(samples.map(({ rate }) => rate));
const compareValues = (a, b) => {
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b));
};
const rows = [...groups.values()]
.sort((a, b) => compareValues(a.xValue, b.xValue) ||
compareValues(a.categoryValue, b.categoryValue))
.map((group) => {
const histogram = createRateHistogram(group.rates, scale, 5);
const count = group.rates.length;
const mean = group.rates.reduce((sum, rate) => sum + rate, 0) / count;
const meanInterval = histogram.meanCI();
const confidenceInterval = count > 1 ?
(meanInterval.upper - meanInterval.lower) / (2 * scale) : NaN;
const medianInterval = histogram.percentileCI(50);
const median = rawMedian(group.rates);
const skewed = count > 1 &&
(Math.abs(histogram.skewness) > 1 ||
Math.abs(median - mean) > confidenceInterval);
return {
...group,
confidenceInterval,
count,
histogram,
mean,
median,
medianLower: medianInterval.lower / scale,
medianUpper: medianInterval.upper / scale,
skewed,
};
});
const legend = assignLabels(rows, 'xValue', 'xLabel');
if (category !== undefined) {
legend.push(...assignLabels(rows, 'categoryValue', 'categoryLabel'));
}
const output = [];
const contamination = new Map();
for (const variable of aggregated) {
const share = varianceShare([...groups.values()], variable);
contamination.set(variable, share);
const percent = share === 1 ?
'100' : (share >= 0.995 ? '>99' : (share * 100).toFixed(0));
const suffix = Number.isNaN(share) ?
'' : ` (explains ${percent}% of within-group variance)`;
output.push(`aggregating variable: ${variable}${suffix}`);
}
const dominant = aggregated.filter(
(variable) => contamination.get(variable) > 0.5);
if (dominant.length > 0) {
output.push('');
wrapOutput(
output,
`${dominant.join(', ')} ${dominant.length === 1 ? 'explains' : 'explain'} ` +
'most of the spread within each group. Pin the parameter or use it as ' +
'--category; increasing --runs will not remove this source of variance.',
);
}
if (aggregated.length > 0) output.push('');
printScatterTable(output, rows, xAxis, category);
if (showChart) printScatterChart(output, rows, xAxis, category);
printScatterComparisons(output, rows, xAxis, category);
if (legend.length > 0) {
output.push('', 'Abbreviated values:');
for (const { full, label } of legend) {
output.push(` ${label}`, ` = ${full}`);
}
}
const singleSample = rows.filter(({ count }) => count < 2).length;
if (singleSample > 0) {
output.push('');
wrapOutput(
output,
`Note: ${singleSample} group${singleSample === 1 ? ' has' : 's have'} ` +
'only one sample, so no confidence interval could be estimated. Use ' +
'--runs 2 or higher.',
);
}
if (rows.some(({ skewed }) => skewed)) {
output.push('');
wrapOutput(
output,
'(!) marks groups where the median falls outside the mean confidence ' +
'interval or the sample is strongly skewed. The median and its interval ' +
'describe the typical run better for those groups.',
);
}
return `${output.join('\n')}\n`;
}
function rawMedian(rates) {
const sorted = [...rates].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ?
(sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
}
function valueKey(value) {
return JSON.stringify(value, (_, item) => {
if (typeof item === 'bigint') return { bigint: String(item) };
return item;
});
}
function varianceShare(groups, variable) {
let between = 0;
let total = 0;
for (const group of groups) {
if (group.members.length < 2) continue;
const mean = group.members.reduce(
(sum, sample) => sum + sample.rate, 0) / group.members.length;
const levels = new Map();
for (const sample of group.members) {
const key = valueKey(sample.params[variable]);
let level = levels.get(key);
if (level === undefined) {
level = { count: 0, sum: 0 };
levels.set(key, level);
}
level.count++;
level.sum += sample.rate;
}
for (const level of levels.values()) {
between += level.count * ((level.sum / level.count) - mean) ** 2;
}
for (const sample of group.members) total += (sample.rate - mean) ** 2;
}
return total === 0 ? NaN : Math.min(1, between / total);
}
function effectSizeLabel(delta) {
const absolute = Math.abs(delta);
if (absolute < 0.147) return 'negligible';
if (absolute < 0.33) return 'small';
if (absolute < 0.474) return 'medium';
return 'large';
}
const mannWhitneyFloors = new Map();
function mannWhitneyFloor(firstCount, secondCount) {
const key = `${firstCount},${secondCount}`;
let floor = mannWhitneyFloors.get(key);
if (floor !== undefined) return floor;
const low = createHistogram({ figures: 5 });
const high = createHistogram({ figures: 5 });
for (let index = 0; index < firstCount; index++) low.record(10000 + index);
for (let index = 0; index < secondCount; index++) {
high.record(10000 + firstCount + index);
}
floor = high.mannWhitneyTest(low).pValue;
mannWhitneyFloors.set(key, floor);
return floor;
}
function printScatterComparisons(output, rows, xAxis, category) {
const usable = rows.filter(({ count }) => count > 1);
if (usable.length < 2) return;
const series = new Map();
for (const row of usable) {
const key = valueKey(row.categoryValue);
if (!series.has(key)) series.set(key, []);
series.get(key).push(row);
}
const sections = [];
let floor = 0;
for (const group of series.values()) {
if (group.length < 2) continue;
const entries = [];
for (let index = 1; index < group.length; index++) {
const previous = group[index - 1];
const current = group[index];
// Configurations in one file share a process. Split consecutive groups
// across disjoint outer-process sets so the unpaired test does not treat
// correlated observations as independent.
const parity = index % 2;
const previousRates = previous.processRates
.filter(({ observation }) => observation % 2 === parity)
.map(({ rate }) => rate);
const currentRates = current.processRates
.filter(({ observation }) => observation % 2 !== parity)
.map(({ rate }) => rate);
if (previousRates.length === 0 || currentRates.length === 0) continue;
const comparisonScale = histogramScale([
...previousRates,
...currentRates,
]);
const previousHistogram =
createRateHistogram(previousRates, comparisonScale, 5);
const currentHistogram =
createRateHistogram(currentRates, comparisonScale, 5);
const { pValue } = currentHistogram.mannWhitneyTest(previousHistogram);
const delta = currentHistogram.cliffsD(previousHistogram);
const previousMean = previousRates.reduce(
(sum, rate) => sum + rate, 0) / previousRates.length;
const currentMean = currentRates.reduce(
(sum, rate) => sum + rate, 0) / currentRates.length;
const change = ((currentMean - previousMean) / previousMean) * 100;
floor = Math.max(
floor, mannWhitneyFloor(previousRates.length, currentRates.length));
let ratio = '';
let exponent = '';
if (typeof previous.xValue === 'number' &&
typeof current.xValue === 'number' &&
previous.xValue > 0 && current.xValue > 0 &&
previous.xValue !== current.xValue &&
previousMean > 0 && currentMean > 0) {
const xRatio = current.xValue / previous.xValue;
const value = Math.log(currentMean / previousMean) / Math.log(xRatio);
ratio = `${xRatio.toFixed(1)}x`;
exponent = `${value >= 0 ? '+' : ''}${value.toFixed(2)}`;
}
entries.push(
` ${previous.xLabel} -> ${current.xLabel}` +
` ${change >= 0 ? '+' : ''}${change.toFixed(2)}%` +
(exponent === '' ? '' : ` ${ratio} exponent=${exponent}`) +
` p=${pValue < 1e-4 ? pValue.toExponential(1) : pValue.toFixed(4)}` +
` delta=${delta >= 0 ? '+' : ''}${delta.toFixed(3)}` +
` (${effectSizeLabel(delta)})`,
);
}
if (entries.length > 0) {
sections.push({
entries,
heading: category === undefined ?
undefined : `${category}=${group[0].categoryLabel}`,
});
}
}
if (sections.length === 0) return;
output.push('', `Change between consecutive ${xAxis} values ` +
`(Mann-Whitney U on disjoint process sets, Cliff's delta):`);
for (const section of sections) {
output.push('');
if (section.heading !== undefined) output.push(` ${section.heading}`);
output.push(...section.entries);
}
if (floor >= 0.05) {
output.push('');
wrapOutput(
output,
`Warning: at this sample size the smallest p-value this test can ` +
`produce is ${floor.toFixed(4)}, so no comparison above can reach ` +
'significance. Raise --runs.',
);
} else if (floor >= 0.005) {
output.push('');
wrapOutput(
output,
`Note: at this sample size the smallest p-value this test can produce ` +
`is ${floor.toFixed(4)}. Raise --runs to strengthen non-significant ` +
'results.',
);
}
}
function formatRate(rate) {
return rate.toLocaleString('en-US', {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
}
function displayWidth(value) {
return [...value].length;
}
function pad(value, width, right) {
const padding = ' '.repeat(Math.max(0, width - displayWidth(value)));
return right ? padding + value : value + padding;
}
function truncateMiddle(value, maximum = 24) {
const characters = [...value];
if (characters.length <= maximum) return value;
const retained = maximum - 3;
const head = Math.ceil(retained / 2);
const tail = Math.floor(retained / 2);
return `${characters.slice(0, head).join('')}...` +
characters.slice(-tail).join('');
}
function assignLabels(rows, valueName, labelName) {
const assigned = new Map();
const used = new Map();
const legend = [];
for (const row of rows) {
const key = valueKey(row[valueName]);
let label = assigned.get(key);
if (label === undefined) {
const full = typeof row[valueName] === 'string' ?
inspect(row[valueName]) : String(row[valueName]);
const abbreviated = truncateMiddle(full);
const collisions = used.get(abbreviated) ?? 0;
used.set(abbreviated, collisions + 1);
label = collisions === 0 ?
abbreviated : `${abbreviated}~${collisions + 1}`;
assigned.set(key, label);
if (label !== full) legend.push({ full, label });
}
row[labelName] = label;
}
return legend;
}
function printScatterTable(output, rows, xAxis, category) {
const header = [xAxis];
if (category !== undefined) header.push(category);
header.push(
'samples', 'rate', 'confidence.interval', 'median', 'median.interval', '');
const body = rows.map((row) => {
const values = [row.xLabel];
if (category !== undefined) values.push(row.categoryLabel);
const medianInterval = row.count > 1 ?
`[${(((row.medianLower - row.median) / row.median) * 100).toFixed(2)}%, ` +
`+${(((row.medianUpper - row.median) / row.median) * 100).toFixed(2)}%]` :
'NA';
values.push(
String(row.count),
formatRate(row.mean),
Number.isNaN(row.confidenceInterval) ?
'NA' :
`${formatRate(row.confidenceInterval)} ` +
`(±${((row.confidenceInterval / row.mean) * 100).toFixed(2)}%)`,
formatRate(row.median),
medianInterval,
row.skewed ? '(!)' : '',
);
return values;
});
const widths = header.map((value, index) => Math.max(
displayWidth(value),
...body.map((values) => displayWidth(values[index])),
));
const right = [typeof rows[0].xValue === 'number'];
if (category !== undefined) {
right.push(typeof rows[0].categoryValue === 'number');
}
right.push(true, true, true, true, true, false);
const format = (values) => values.map(
(value, index) => pad(value, widths[index], right[index])).join(' ').trimEnd();
output.push(format(header));
for (const values of body) output.push(format(values));
}
function printScatterChart(output, rows, xAxis, category) {
if (rows.length === 0) return;
const width = 40;
let maximum = 0;
for (const row of rows) {
maximum = Math.max(
maximum,
row.mean + (Number.isNaN(row.confidenceInterval) ?
0 : row.confidenceInterval),
);
}
if (maximum === 0) return;
const labels = rows.map((row) => {
let label = `${xAxis}=${row.xLabel}`;
if (category !== undefined) label += ` ${category}=${row.categoryLabel}`;
return truncateMiddle(label, 44);
});
const labelWidth = Math.max(...labels.map(displayWidth));
const rateWidth = Math.max(...rows.map(({ mean }) =>
displayWidth(formatRate(mean))));
const axis = formatRate(maximum);
const indent = ' '.repeat(labelWidth + 2);
output.push(
'',
'Rate in operations/second; longer is faster. │ marks the mean and the',
'shaded band (░) is its 95% confidence interval.',
'',
`${indent}0${' '.repeat(Math.max(1, width - 1 - displayWidth(axis)))}${axis}`,
`${indent}+${'-'.repeat(width - 2)}+`,
);
let previous;
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
if (previous !== undefined && previous !== row.xValue) output.push('');
previous = row.xValue;
const interval = Number.isNaN(row.confidenceInterval) ?
0 : row.confidenceInterval;
const end = (row.mean / maximum) * width;
const lower = ((row.mean - interval) / maximum) * width;
const upper = ((row.mean + interval) / maximum) * width;
const meanCell = Math.min(width - 1, Math.floor(end));
let bar = '';
for (let cell = 0; cell < width; cell++) {
const position = cell + 0.5;
if (cell === meanCell) bar += '│';
else if (position >= lower && position <= upper) bar += '░';
else if (position <= end) bar += '█';
else bar += ' ';
}
output.push(
`${pad(labels[index], labelWidth, false)} ${bar} ` +
pad(formatRate(row.mean), rateWidth, true),
);
}
}
function wrapOutput(output, text, width = 76) {
let line = '';
for (const word of text.split(/\s+/)) {
if (line === '') line = word;
else if (displayWidth(line) + displayWidth(word) + 1 <= width) {
line += ` ${word}`;
} else {
output.push(line);
line = word;
}
}
if (line !== '') output.push(line);
}
module.exports = {
analyzeCompare,
analyzeScatter,
holmAdjust,
isRegressionFailure,
validateScatterParameters,
};