From a5fef6a86c8ea92240faef816fc50c104af84d3f Mon Sep 17 00:00:00 2001 From: Devin Nusbaum Date: Tue, 20 Aug 2024 13:36:09 -0400 Subject: [PATCH 1/4] Implement simple linear regression directly and drop commons-math3 dependency --- pom.xml | 5 -- src/main/java/hudson/tasks/junit/History.java | 42 +++++++++-- .../junit/SimpleLinearRegressionTest.java | 73 +++++++++++++++++++ 3 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 src/test/java/hudson/tasks/junit/SimpleLinearRegressionTest.java diff --git a/pom.xml b/pom.xml index 0c9cbd8c..01bc3915 100644 --- a/pom.xml +++ b/pom.xml @@ -96,11 +96,6 @@ io.jenkins.plugins plugin-util-api - - org.apache.commons - commons-math3 - 3.6.1 - org.jenkins-ci.plugins display-url-api diff --git a/src/main/java/hudson/tasks/junit/History.java b/src/main/java/hudson/tasks/junit/History.java index 2f8d4a78..05bffc50 100644 --- a/src/main/java/hudson/tasks/junit/History.java +++ b/src/main/java/hudson/tasks/junit/History.java @@ -46,7 +46,6 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import jenkins.util.SystemProperties; -import org.apache.commons.math3.stat.regression.SimpleRegression; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.bind.JavaScriptMethod; @@ -249,12 +248,9 @@ private void createLinearTrend( return; } - SimpleRegression sr = new SimpleRegression(true); - for (int i = 0; i < lrX.length; i++) { - sr.addData(lrX[i], lrY[i]); - } - double intercept = sr.getIntercept(); - double slope = sr.getSlope(); + double[] cs = SimpleLinearRegression.coefficients(lrX, lrY); + double intercept = cs[0]; + double slope = cs[1]; ObjectNode lrSeries = MAPPER.createObjectNode(); series.add(lrSeries); @@ -813,4 +809,36 @@ public static int asInt(String s, int defaultValue) { return defaultValue; } } + + // https://en.wikipedia.org/wiki/Simple_linear_regression + static class SimpleLinearRegression { + static double[] coefficients(double[] xs, double[] ys) { + int n = xs.length; + if (n < 2) { + throw new IllegalArgumentException("At least two data points are required, but got: " + xs.length); + } + if (xs.length != ys.length) { + throw new IllegalArgumentException("Array lengths do not match:" + xs.length + " vs " + ys.length); + } + double sumX = 0; + double sumY = 0; + double sumXX = 0; + double sumXY = 0; + for (int i = 0; i < n; i++) { + double x = xs[i]; + double y = ys[i]; + sumX += x; + sumY += y; + sumXX += x * x; + sumXY += x * y; + } + if (Math.abs(sumXX) < 10 * Double.MIN_VALUE) { + // Avoid returning +/- infinity in case the x values are too close together. + return new double[] {Double.NaN, Double.NaN}; + } + double slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); + double intercept = sumY / n - slope / n * sumX; + return new double[] {intercept, slope}; + } + } } diff --git a/src/test/java/hudson/tasks/junit/SimpleLinearRegressionTest.java b/src/test/java/hudson/tasks/junit/SimpleLinearRegressionTest.java new file mode 100644 index 00000000..3ce5a236 --- /dev/null +++ b/src/test/java/hudson/tasks/junit/SimpleLinearRegressionTest.java @@ -0,0 +1,73 @@ +package hudson.tasks.junit; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.notANumber; +import static org.junit.Assert.assertThrows; + +import hudson.tasks.junit.History.SimpleLinearRegression; +import org.junit.Test; + +public class SimpleLinearRegressionTest { + + @Test + public void smokes() { + // Results checked in Excel. + double[] xs = {2, 3, 4, 5, 6, 8, 10, 11}; + double[] ys = {21.05, 23.51, 24.23, 27.71, 30.86, 45.85, 52.12, 55.98}; + double[] cs = SimpleLinearRegression.coefficients(xs, ys); + assertThat(cs[0], closeTo(9.4763, 0.0001)); + assertThat(cs[1], closeTo(4.1939, 0.0001)); + + xs = new double[] {1.47, 1.5, 1.52, 1.55, 1.57, 1.6, 1.63, 1.65, 1.68, 1.7, 1.73, 1.75, 1.78, 1.8, 1.83}; + ys = new double[] { + 52.21, 53.12, 54.48, 55.84, 57.2, 58.57, 59.93, 61.29, 63.11, 64.47, 66.28, 68.1, 69.92, 72.19, 74.46 + }; + cs = SimpleLinearRegression.coefficients(xs, ys); + assertThat(cs[0], closeTo(-39.0620, 0.0001)); + assertThat(cs[1], closeTo(61.2722, 0.0001)); + } + + @Test + public void requires2DataPoints() { + var t = assertThrows( + IllegalArgumentException.class, + () -> SimpleLinearRegression.coefficients(new double[0], new double[0])); + assertThat(t.getMessage(), containsString("At least two data points are required")); + t = assertThrows( + IllegalArgumentException.class, + () -> SimpleLinearRegression.coefficients(new double[1], new double[1])); + assertThat(t.getMessage(), containsString("At least two data points are required")); + double[] cs = SimpleLinearRegression.coefficients(new double[] {0.0, 1.0}, new double[] {1.0, 1.0}); + assertThat(cs[0], closeTo(1.0, 0.001)); + assertThat(cs[1], closeTo(0, 0.001)); + } + + @Test + public void requiresArraysWithSameLength() { + var t = assertThrows( + IllegalArgumentException.class, + () -> SimpleLinearRegression.coefficients(new double[3], new double[4])); + assertThat(t.getMessage(), containsString("Array lengths do not match")); + t = assertThrows( + IllegalArgumentException.class, + () -> SimpleLinearRegression.coefficients(new double[4], new double[3])); + assertThat(t.getMessage(), containsString("Array lengths do not match")); + } + + @Test + public void returnsNanIfXValuesDoNotVaryEnough() { + double[] xs = {Double.MIN_VALUE, 1e162 * Double.MIN_VALUE}; + double[] ys = {0.0, 1.0}; + double[] cs = SimpleLinearRegression.coefficients(xs, ys); + assertThat(cs[0], notANumber()); + assertThat(cs[1], notANumber()); + + xs = new double[] {Double.MIN_VALUE, 1e163 * Double.MIN_VALUE}; + ys = new double[] {0.0, 1.0}; + cs = SimpleLinearRegression.coefficients(xs, ys); + assertThat(cs[0], closeTo(0.0, 0.001)); + assertThat(cs[1], closeTo(2.0e160, 1e159)); + } +} From 23c682efc18d98d72c678c4cc9e62cdb5e35e73b Mon Sep 17 00:00:00 2001 From: Devin Nusbaum Date: Tue, 20 Aug 2024 13:36:52 -0400 Subject: [PATCH 2/4] Turn build duration distribution chart into a histogram --- src/main/java/hudson/tasks/junit/History.java | 55 ++++++++----------- .../hudson/tasks/junit/History/history.js | 13 +++-- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/main/java/hudson/tasks/junit/History.java b/src/main/java/hudson/tasks/junit/History.java index 05bffc50..f988f6d5 100644 --- a/src/main/java/hudson/tasks/junit/History.java +++ b/src/main/java/hudson/tasks/junit/History.java @@ -479,24 +479,16 @@ private ObjectNode computeResultTrendJson(List history private ObjectNode computeDistributionJson(List history) { ObjectNode root = MAPPER.createObjectNode(); ArrayNode series = MAPPER.createArrayNode(); - ArrayNode domainAxisLabels = MAPPER.createArrayNode(); ObjectNode durationSeries = MAPPER.createObjectNode(); durationSeries.put("name", "Build Count"); - durationSeries.put("type", "line"); - durationSeries.put("symbol", "circle"); - durationSeries.put("symbolSize", "0"); - durationSeries.put("sampling", "lttb"); + durationSeries.put("type", "bar"); + durationSeries.put("barWidth", "99%"); ArrayNode durationData = MAPPER.createArrayNode(); durationSeries.set("data", durationData); ObjectNode durationStyle = MAPPER.createObjectNode(); durationSeries.set("itemStyle", durationStyle); durationStyle.put("color", "--success-color"); // "rgba(50, 200, 50, 0.8)"); - durationSeries.put("stack", "stacked"); - ObjectNode durAreaStyle = MAPPER.createObjectNode(); - durationSeries.set("areaStyle", durAreaStyle); - durAreaStyle.put("color", "rgba(0,0,0,0)"); - durationSeries.put("smooth", true); series.add(durationSeries); double maxDuration = 0, minDuration = Double.MAX_VALUE; @@ -508,22 +500,20 @@ private ObjectNode computeDistributionJson(List histor minDuration = h.getDuration(); } } - double extraDuration = Math.max(0.001, (maxDuration - minDuration) * 0.05); - minDuration = Math.max(0.0, minDuration - extraDuration); - maxDuration = maxDuration + extraDuration; - int[] counts = new int[100]; - int smoothBuffer = 2; - double[] lrX = new double[counts.length + smoothBuffer * 2 + 1]; - double[] lrY = new double[counts.length + smoothBuffer * 2 + 1]; - double scale = maxDuration - minDuration; - double step = scale / counts.length; + // double extraDuration = Math.max(0.001, (maxDuration - minDuration) * 0.05); + minDuration = Math.max(0.0, minDuration); + int buckets = 50; + double[] lrX = new double[buckets]; + double[] lrY = new double[buckets]; + double domain = maxDuration - minDuration; + double step = domain / buckets; for (HistoryTestResultSummary h : history) { - int idx = smoothBuffer + (int) Math.round((h.getDuration() - minDuration) / step); + int idx = (int) Math.round((h.getDuration() - minDuration) / step); int idx2 = Math.max(0, Math.min(idx, lrY.length - 1)); lrY[idx2]++; } for (int i = 0; i < lrY.length; ++i) { - lrX[i] = ((minDuration + (maxDuration - minDuration) / lrY.length * i) / scale * 100.0); + lrX[i] = minDuration + step * (i + 0.5); } ObjectNode xAxis = MAPPER.createObjectNode(); @@ -547,24 +537,25 @@ private ObjectNode computeDistributionJson(List histor mul = 1.0d / 3600.0d; roundMul = 100.0; } + xAxis.put("min", (float) (mul * lrX[0] - (step * mul * 0.5))); + xAxis.put("max", (float) (mul * lrX[lrX.length - 1] + (step * mul * 0.5))); + xAxis.put("interval", (float) (step * mul)); + xAxis.put("roundingFactor", (float) roundMul); int maxBuilds = 0; - SmoothingCubicSpline scs = new SmoothingCubicSpline(lrX, lrY, 0.1); - int smoothPts = counts.length * 4; - double k = (double) counts.length / smoothPts; - final double splineRoundMul = 1000.0; - for (double z = minDuration; z < maxDuration; z += step * k) { - double v = Math.round(splineRoundMul * Math.max(0.0, scs.evaluate(z / scale * 100.0))) / splineRoundMul; - durationData.add((float) v); - maxBuilds = Math.max(maxBuilds, (int) Math.ceil(v)); - // Use float for smaller JSONs. - domainAxisLabels.add((float) (Math.round(mul * z * roundMul) / roundMul)); + for (int i = 0; i < lrY.length; i++) { + double scaledX = mul * lrX[i]; + double y = lrY[i]; + ArrayNode data = MAPPER.createArrayNode(); + data.add((float) scaledX); + data.add((float) y); + durationData.add(data); + maxBuilds = Math.max(maxBuilds, (int) Math.ceil(y)); } root.set("series", series); root.put("integerRangeAxis", true); root.put("domainAxisItemName", "Number of Builds"); - root.set("domainAxisLabels", domainAxisLabels); root.set("xAxis", xAxis); if (maxBuilds >= 10) { root.put("rangeMax", maxBuilds); diff --git a/src/main/resources/hudson/tasks/junit/History/history.js b/src/main/resources/hudson/tasks/junit/History/history.js index 3c849202..8d5b891f 100644 --- a/src/main/resources/hudson/tasks/junit/History/history.js +++ b/src/main/resources/hudson/tasks/junit/History/history.js @@ -323,12 +323,16 @@ function onBuildIntervalChange(selectObj) { top: '20%', }, xAxis: { - type: 'category', - boundaryGap: false, + type: 'value', axisLabel: { - color: textColor + color: textColor, + formatter: function(value) { + return Math.round(value * model.distribution.xAxis.roundingFactor) / model.distribution.xAxis.roundingFactor; + } }, - data: model.distribution.domainAxisLabels, + min: model.distribution.xAxis.min, + max: model.distribution.xAxis.max, + minInterval: model.distribution.xAxis.interval, name: model.distribution.xAxis.name, nameLocation: 'middle', nameGap: 26, @@ -343,6 +347,7 @@ function onBuildIntervalChange(selectObj) { axisLabel: { color: textColor }, + minInterval: model.result.integerRangeAxis ? 1 : null, name: 'Build Count', nameLocation: 'middle', nameGap: 60, From dc851918cd75367cebc3f6db6839d72ea52215d8 Mon Sep 17 00:00:00 2001 From: Devin Nusbaum Date: Tue, 20 Aug 2024 13:37:30 -0400 Subject: [PATCH 3/4] Remove ssj dependency and spline trend lines when over 200 tests are shown --- pom.xml | 12 ---- src/main/java/hudson/tasks/junit/History.java | 58 ------------------- 2 files changed, 70 deletions(-) diff --git a/pom.xml b/pom.xml index 01bc3915..513e2344 100644 --- a/pom.xml +++ b/pom.xml @@ -59,18 +59,6 @@ - - ca.umontreal.iro.simul - ssj - 3.3.2 - - - - * - * - - - com.pivovarit parallel-collectors diff --git a/src/main/java/hudson/tasks/junit/History.java b/src/main/java/hudson/tasks/junit/History.java index f988f6d5..7a272d91 100644 --- a/src/main/java/hudson/tasks/junit/History.java +++ b/src/main/java/hudson/tasks/junit/History.java @@ -49,7 +49,6 @@ import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.bind.JavaScriptMethod; -import umontreal.ssj.functionfit.SmoothingCubicSpline; /** * History of {@link hudson.tasks.test.TestObject} over time. @@ -208,16 +207,6 @@ private ObjectNode computeDurationTrendJson(List histo 0, 0, roundMul); // "--blue" - createSplineTrend( - series, - history, - lrX, - lrY, - "Smooth of " + durationStr, - "rgba(120, 50, 255, 0.5)", - 0, - 0, - roundMul); // "--indigo" } root.set("series", series); root.set("domainAxisLabels", domainAxisLabels); @@ -279,51 +268,6 @@ private void createLinearTrend( } } - private void createSplineTrend( - ArrayNode series, - List history, - double[] lrX, - double[] lrY, - String title, - String color, - int xAxisIndex, - int yAxisIndex, - double roundMul) { - if (history.size() < 200) { - return; - } - double rho = Math.min(1.0, 1.0 / Math.max(1, (history.size() / 2))); - if (rho > 0.75) { - return; // Too close to linear - } - SmoothingCubicSpline scs = new SmoothingCubicSpline(lrX, lrY, 0.001); - ObjectNode lrSeries = MAPPER.createObjectNode(); - series.add(lrSeries); - lrSeries.put("name", title); - lrSeries.put("preferScreenOrient", "landscape"); - lrSeries.put("type", "line"); - lrSeries.put("symbol", "circle"); - lrSeries.put("symbolSize", 0); - lrSeries.put("xAxisIndex", xAxisIndex); - lrSeries.put("yAxisIndex", yAxisIndex); - ArrayNode lrData = MAPPER.createArrayNode(); - lrSeries.set("data", lrData); - ObjectNode lrStyle = MAPPER.createObjectNode(); - lrSeries.set("itemStyle", lrStyle); - lrStyle.put("color", color); - ObjectNode lrAreaStyle = MAPPER.createObjectNode(); - lrSeries.set("areaStyle", lrAreaStyle); - lrAreaStyle.put("color", "rgba(0, 0, 0, 0)"); - - if (roundMul < 10.0) { - roundMul = 10.0; - } - for (int index = 0; index < history.size(); ++index) { - // Use float to reduce JSON size. - lrData.add((float) (Math.round(scs.evaluate(index) * roundMul) / roundMul)); - } - } - static boolean EXTRA_GRAPH_MATH_ENABLED = Boolean.parseBoolean(System.getProperty(History.class.getName() + ".EXTRA_GRAPH_MATH_ENABLED", "true")); @@ -464,8 +408,6 @@ private ObjectNode computeResultTrendJson(List history 1, 1, 10.0); // "--dark-blue" - createSplineTrend( - series, history, lrX, lrY, "Smooth of Passed", "rgba(255, 50, 255, 0.5)", 1, 1, 10.0); // "--purple" } root.set("series", series); From 88552fcec2b97d8f3278c247e020aba280a4f212 Mon Sep 17 00:00:00 2001 From: Devin Nusbaum Date: Tue, 20 Aug 2024 14:30:19 -0400 Subject: [PATCH 4/4] Remove commented-out line of code --- src/main/java/hudson/tasks/junit/History.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/hudson/tasks/junit/History.java b/src/main/java/hudson/tasks/junit/History.java index 7a272d91..8a76a7be 100644 --- a/src/main/java/hudson/tasks/junit/History.java +++ b/src/main/java/hudson/tasks/junit/History.java @@ -442,7 +442,6 @@ private ObjectNode computeDistributionJson(List histor minDuration = h.getDuration(); } } - // double extraDuration = Math.max(0.001, (maxDuration - minDuration) * 0.05); minDuration = Math.max(0.0, minDuration); int buckets = 50; double[] lrX = new double[buckets];