diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/ProblemSizeStatistics.java b/core/src/main/java/ai/timefold/solver/core/api/solver/ProblemSizeStatistics.java index 08e22ca9250..f59ec245dc3 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/ProblemSizeStatistics.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/ProblemSizeStatistics.java @@ -1,7 +1,5 @@ package ai.timefold.solver.core.api.solver; -import java.text.DecimalFormat; -import java.text.DecimalFormatSymbols; import java.util.Locale; import ai.timefold.solver.core.impl.util.MathUtils; @@ -23,13 +21,6 @@ public record ProblemSizeStatistics(long entityCount, long approximateValueCount, double approximateProblemSizeLog) { - private static final Locale FORMATTER_LOCALE = Locale.getDefault(); - private static final DecimalFormat BASIC_FORMATTER = new DecimalFormat("#,###"); - - // Exponent should not use grouping, unlike basic - private static final DecimalFormat EXPONENT_FORMATTER = new DecimalFormat("#"); - private static final DecimalFormat SIGNIFICANT_FIGURE_FORMATTER = new DecimalFormat("0.######"); - /** * Return the {@link #approximateProblemSizeLog} as a fixed point integer. */ @@ -38,48 +29,7 @@ public long approximateProblemScaleLogAsFixedPointLong() { } public String approximateProblemScaleAsFormattedString() { - return approximateProblemScaleAsFormattedString(Locale.getDefault()); - } - - String approximateProblemScaleAsFormattedString(Locale locale) { - if (Double.isNaN(approximateProblemSizeLog) || Double.isInfinite(approximateProblemSizeLog)) { - return "0"; - } - - if (approximateProblemSizeLog < 10) { // log_10(10_000_000_000) = 10 - return "%s".formatted(format(Math.pow(10d, approximateProblemSizeLog), BASIC_FORMATTER, locale)); - } - // The actual number will often be too large to fit in a double, so cannot use basic - // formatting. - // Separate the exponent into its integral and fractional parts - // Use the integral part as the power of 10, and the fractional part as the significant digits. - double exponentPart = Math.floor(approximateProblemSizeLog); - double remainderPartAsExponent = approximateProblemSizeLog - exponentPart; - double remainderPart = Math.pow(10, remainderPartAsExponent); - return "%s × 10^%s".formatted( - format(remainderPart, SIGNIFICANT_FIGURE_FORMATTER, locale), - format(exponentPart, EXPONENT_FORMATTER, locale)); - } - - /** - * In order for tests to work currently regardless of the default system locale, - * we need to set the locale to a known value before running the tests. - * And because the {@link DecimalFormat} instances are initialized statically for reasons of performance, - * we cannot expect them to be in the locale that the test expects them to be in. - * This method exists to allow for an override. - * - * @return the given decimalFormat with the given locale - */ - private static String format(double number, DecimalFormat decimalFormat, Locale locale) { - if (locale.equals(FORMATTER_LOCALE)) { - return decimalFormat.format(number); - } - try { // Slow path for corner cases where input locale doesn't match the default locale. - decimalFormat.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(locale)); - return decimalFormat.format(number); - } finally { - decimalFormat.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(FORMATTER_LOCALE)); - } + return MathUtils.approximateProblemScaleAsFormattedString(approximateProblemSizeLog, Locale.getDefault()); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/MathUtils.java b/core/src/main/java/ai/timefold/solver/core/impl/util/MathUtils.java index 8c3d3aa1d5d..7984ee8266d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/MathUtils.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/MathUtils.java @@ -1,5 +1,9 @@ package ai.timefold.solver.core.impl.util; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; + /** * Includes code taken from Apache's commons-math library, v3.6.1 (Apache 2.0-licensed) * These methods are clearly marked in comments, their modifications listed. @@ -9,6 +13,11 @@ public class MathUtils { public static final long LOG_PRECISION = 1_000_000L; private static final int FACTORIAL_MAX_N = 20; private static final long[] FACTORIAL_CACHE = new long[FACTORIAL_MAX_N - 1]; // 0 and 1 are hard-coded. + public static final Locale FORMATTER_LOCALE = Locale.getDefault(); + private static final DecimalFormat BASIC_FORMATTER = new DecimalFormat("#,###"); + // Exponent should not use grouping, unlike basic + private static final DecimalFormat EXPONENT_FORMATTER = new DecimalFormat("#"); + private static final DecimalFormat SIGNIFICANT_FIGURE_FORMATTER = new DecimalFormat("0.######"); private MathUtils() { } @@ -346,4 +355,44 @@ private static long mulAndCheck(long a, long b) { } } + public static String approximateProblemScaleAsFormattedString(double approximateProblemSizeLog, Locale locale) { + if (Double.isNaN(approximateProblemSizeLog) || Double.isInfinite(approximateProblemSizeLog)) { + return "0"; + } + + if (approximateProblemSizeLog < 10) { // log_10(10_000_000_000) = 10 + return "%s".formatted(formatNumber(Math.pow(10d, approximateProblemSizeLog), BASIC_FORMATTER, locale)); + } + // The actual number will often be too large to fit in a double, so cannot use basic + // formatting. + // Separate the exponent into its integral and fractional parts + // Use the integral part as the power of 10, and the fractional part as the significant digits. + double exponentPart = Math.floor(approximateProblemSizeLog); + double remainderPartAsExponent = approximateProblemSizeLog - exponentPart; + double remainderPart = Math.pow(10, remainderPartAsExponent); + return "%s × 10^%s".formatted( + formatNumber(remainderPart, SIGNIFICANT_FIGURE_FORMATTER, locale), + formatNumber(exponentPart, EXPONENT_FORMATTER, locale)); + } + + /** + * In order for tests to work currently regardless of the default system locale, + * we need to set the locale to a known value before running the tests. + * And because the {@link DecimalFormat} instances are initialized statically for reasons of performance, + * we cannot expect them to be in the locale that the test expects them to be in. + * This method exists to allow for an override. + * + * @return the given decimalFormat with the given locale + */ + private static String formatNumber(double number, DecimalFormat decimalFormat, Locale locale) { + if (locale.equals(MathUtils.FORMATTER_LOCALE)) { + return decimalFormat.format(number); + } + try { // Slow path for corner cases where input locale doesn't match the default locale. + decimalFormat.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(locale)); + return decimalFormat.format(number); + } finally { + decimalFormat.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(MathUtils.FORMATTER_LOCALE)); + } + } } diff --git a/tools/benchmark-aggregator/src/main/java/ai/timefold/solver/benchmark/aggregator/swingui/BenchmarkAggregatorFrame.java b/tools/benchmark-aggregator/src/main/java/ai/timefold/solver/benchmark/aggregator/swingui/BenchmarkAggregatorFrame.java index b401f3efd29..d06b2f1ec3c 100644 --- a/tools/benchmark-aggregator/src/main/java/ai/timefold/solver/benchmark/aggregator/swingui/BenchmarkAggregatorFrame.java +++ b/tools/benchmark-aggregator/src/main/java/ai/timefold/solver/benchmark/aggregator/swingui/BenchmarkAggregatorFrame.java @@ -518,12 +518,13 @@ private MixedCheckBox createSolverBenchmarkCheckBox(SolverBenchmarkResult solver } private MixedCheckBox createProblemBenchmarkCheckBox(ProblemBenchmarkResult problemBenchmarkResult) { - String problemBenchmarkDetail = String.format( - "Entity count: %d%n" - + "Problem scale: %d%n" - + "Used memory: %s", + var problemBenchmarkDetail = String.format( + """ + Entity count: %d%n\ + Problem scale: %s%n\ + Used memory: %s""", problemBenchmarkResult.getEntityCount(), - problemBenchmarkResult.getProblemScale(), + problemBenchmarkResult.getFormattedProblemScale(), toEmptyStringIfNull(problemBenchmarkResult.getAverageUsedMemoryAfterInputSolution())); return new MixedCheckBox(problemBenchmarkResult.getName(), problemBenchmarkDetail); } diff --git a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java index d12d0ee41cd..5fee501b3f8 100644 --- a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java +++ b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java @@ -372,8 +372,8 @@ private List> createBestScoreScalabilitySummaryChart() { String solverLabel = solverBenchmarkResult.getNameWithFavoriteSuffix(); for (SingleBenchmarkResult singleBenchmarkResult : solverBenchmarkResult.getSingleBenchmarkResultList()) { if (singleBenchmarkResult.hasAllSuccess()) { - long problemScale = singleBenchmarkResult.getProblemBenchmarkResult().getProblemScale(); - double[] levelValues = singleBenchmarkResult.getAverageScore().toLevelDoubles(); + var problemScale = singleBenchmarkResult.getProblemBenchmarkResult().getProblemScale(); + var levelValues = singleBenchmarkResult.getAverageScore().toLevelDoubles(); for (int i = 0; i < levelValues.length && i < CHARTED_SCORE_LEVEL_SIZE; i++) { if (i >= builderList.size()) { builderList.add(new LineChart.Builder<>()); @@ -556,8 +556,8 @@ private LineChart createScalabilitySummaryChart(ToLongFunction { - long problemScale = singleBenchmarkResult.getProblemBenchmarkResult().getProblemScale(); - long timeMillisSpent = valueFunction.applyAsLong(singleBenchmarkResult); + var problemScale = singleBenchmarkResult.getProblemBenchmarkResult().getProblemScale(); + var timeMillisSpent = valueFunction.applyAsLong(singleBenchmarkResult); builder.add(solverLabel, problemScale, timeMillisSpent); }); } @@ -570,8 +570,8 @@ private List> createBestScorePerTimeSpentSummaryChart() String solverLabel = solverBenchmarkResult.getNameWithFavoriteSuffix(); for (SingleBenchmarkResult singleBenchmarkResult : solverBenchmarkResult.getSingleBenchmarkResultList()) { if (singleBenchmarkResult.hasAllSuccess()) { - long timeMillisSpent = singleBenchmarkResult.getTimeMillisSpent(); - double[] levelValues = singleBenchmarkResult.getAverageScore().toLevelDoubles(); + var timeMillisSpent = singleBenchmarkResult.getTimeMillisSpent(); + var levelValues = singleBenchmarkResult.getAverageScore().toLevelDoubles(); for (int i = 0; i < levelValues.length && i < CHARTED_SCORE_LEVEL_SIZE; i++) { if (i >= builderList.size()) { builderList.add(new LineChart.Builder<>()); diff --git a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java index 4827809baa2..f12ad914978 100644 --- a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java +++ b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java @@ -9,6 +9,7 @@ import java.util.Comparator; import java.util.IdentityHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; @@ -23,7 +24,7 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; -import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; +import ai.timefold.solver.core.impl.util.MathUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,7 +68,7 @@ public class PlannerBenchmarkResult { // ************************************************************************ private Integer failureCount = null; - private Long averageProblemScale = null; + private String averageProblemScale = null; private Score averageScore = null; private SolverBenchmarkResult favoriteSolverBenchmarkResult = null; @@ -184,7 +185,7 @@ public Integer getFailureCount() { return failureCount; } - public Long getAverageProblemScale() { + public String getAverageProblemScale() { return averageProblemScale; } @@ -316,22 +317,24 @@ public void accumulateResults(BenchmarkReport benchmarkReport) { private > void determineTotalsAndAverages() { failureCount = 0; - long totalProblemScale = 0L; - int problemScaleCount = 0; - for (ProblemBenchmarkResult problemBenchmarkResult : unifiedProblemBenchmarkResultList) { - Long problemScale = problemBenchmarkResult.getProblemScale(); + var totalProblemScale = 0L; + var problemScaleCount = 0; + for (var problemBenchmarkResult : unifiedProblemBenchmarkResultList) { + var problemScale = problemBenchmarkResult.getProblemScale(); if (problemScale != null && problemScale >= 0L) { totalProblemScale += problemScale; problemScaleCount++; } failureCount += problemBenchmarkResult.getFailureCount(); } - averageProblemScale = problemScaleCount == 0 ? null : totalProblemScale / problemScaleCount; + averageProblemScale = problemScaleCount == 0 ? null + : MathUtils.approximateProblemScaleAsFormattedString( + (double) totalProblemScale / problemScaleCount / MathUtils.LOG_PRECISION, Locale.getDefault()); Score_ totalScore = null; - int solverBenchmarkCount = 0; - boolean firstSolverBenchmarkResult = true; - for (SolverBenchmarkResult solverBenchmarkResult : solverBenchmarkResultList) { - EnvironmentMode solverEnvironmentMode = solverBenchmarkResult.getEnvironmentMode(); + var solverBenchmarkCount = 0; + var firstSolverBenchmarkResult = true; + for (var solverBenchmarkResult : solverBenchmarkResultList) { + var solverEnvironmentMode = solverBenchmarkResult.getEnvironmentMode(); if (firstSolverBenchmarkResult && solverEnvironmentMode != null) { environmentMode = solverEnvironmentMode; firstSolverBenchmarkResult = false; @@ -339,9 +342,9 @@ private > void determineTotalsAndAverages() { environmentMode = null; } - Score_ score = (Score_) solverBenchmarkResult.getAverageScore(); + var score = (Score_) solverBenchmarkResult.getAverageScore(); if (score != null) { - ScoreDefinition scoreDefinition = solverBenchmarkResult.getScoreDefinition(); + var scoreDefinition = solverBenchmarkResult.getScoreDefinition(); if (totalScore != null && !scoreDefinition.isCompatibleArithmeticArgument(totalScore)) { // Mixing different use cases with different score definitions. totalScore = null; diff --git a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/ProblemBenchmarkResult.java b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/ProblemBenchmarkResult.java index b278b9d1d15..fe7dfcb80d0 100644 --- a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/ProblemBenchmarkResult.java +++ b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/ProblemBenchmarkResult.java @@ -84,6 +84,10 @@ public class ProblemBenchmarkResult { private Long entityCount = null; private Long variableCount = null; private Long maximumValueCount = null; + private String formattedProblemScale = null; + /** + * Note: this is a fixed-point long of the problem scale log + */ private Long problemScale = null; private Long inputSolutionLoadingTimeMillisSpent = null; @@ -177,6 +181,13 @@ public Long getMaximumValueCount() { return maximumValueCount; } + public String getFormattedProblemScale() { + return formattedProblemScale; + } + + /** + * Returns the fixed-point long of the problem scale log + */ public Long getProblemScale() { return problemScale; } @@ -454,13 +465,15 @@ public void registerProblemSizeStatistics(ProblemSizeStatistics problemSizeStati // The approximateValueCount is not unknown (null), but known to be ambiguous maximumValueCount = -1L; } - if (problemScale == null) { + if (formattedProblemScale == null) { + formattedProblemScale = problemSizeStatistics.approximateProblemScaleAsFormattedString(); problemScale = problemSizeStatistics.approximateProblemScaleLogAsFixedPointLong(); - } else if (problemScale.longValue() != problemSizeStatistics.approximateProblemScaleLogAsFixedPointLong()) { + } else if (!Objects.equals(problemScale, problemSizeStatistics.approximateProblemScaleLogAsFixedPointLong())) { LOGGER.warn("The problemBenchmarkResult ({}) has different problemScale values ([{},{}]).\n" + "This is normally impossible for 1 inputSolutionFile.", - getName(), problemScale, problemSizeStatistics.approximateProblemScaleLogAsFixedPointLong()); + getName(), formattedProblemScale, problemSizeStatistics.approximateProblemScaleLogAsFixedPointLong()); // The problemScale is not unknown (null), but known to be ambiguous + formattedProblemScale = "-1"; problemScale = -1L; } } @@ -516,6 +529,7 @@ protected static Map newResult.entityCount = oldResult.entityCount; newResult.variableCount = oldResult.variableCount; newResult.maximumValueCount = oldResult.maximumValueCount; + newResult.formattedProblemScale = oldResult.formattedProblemScale; newResult.problemScale = oldResult.problemScale; problemProviderToNewResultMap.put(oldResult.problemProvider, newResult); newPlannerBenchmarkResult.getUnifiedProblemBenchmarkResultList().add(newResult); @@ -533,6 +547,8 @@ protected static Map newResult.variableCount = ConfigUtils.meldProperty(oldResult.variableCount, newResult.variableCount); newResult.maximumValueCount = ConfigUtils.meldProperty(oldResult.maximumValueCount, newResult.maximumValueCount); + newResult.formattedProblemScale = + ConfigUtils.meldProperty(oldResult.formattedProblemScale, newResult.formattedProblemScale); newResult.problemScale = ConfigUtils.meldProperty(oldResult.problemScale, newResult.problemScale); } mergeMap.put(oldResult, newResult); diff --git a/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl b/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl index 235481ef52d..00e46061df0 100644 --- a/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl +++ b/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl @@ -404,7 +404,7 @@ Problem scale ${benchmarkReport.plannerBenchmarkResult.averageProblemScale!""} <#list benchmarkReport.plannerBenchmarkResult.unifiedProblemBenchmarkResultList as problemBenchmarkResult> - ${problemBenchmarkResult.problemScale!""} + ${problemBenchmarkResult.formattedProblemScale!""} @@ -472,7 +472,7 @@ Problem scale ${benchmarkReport.plannerBenchmarkResult.averageProblemScale!""} <#list benchmarkReport.plannerBenchmarkResult.unifiedProblemBenchmarkResultList as problemBenchmarkResult> - ${problemBenchmarkResult.problemScale!""} + ${problemBenchmarkResult.formattedProblemScale!""} @@ -585,7 +585,7 @@ Problem scale ${benchmarkReport.plannerBenchmarkResult.averageProblemScale!""} <#list benchmarkReport.plannerBenchmarkResult.unifiedProblemBenchmarkResultList as problemBenchmarkResult> - ${problemBenchmarkResult.problemScale!""} + ${problemBenchmarkResult.formattedProblemScale!""} @@ -668,7 +668,7 @@ Problem scale - ${problemBenchmarkResult.problemScale!""} + ${problemBenchmarkResult.formattedProblemScale!""} <#if problemBenchmarkResult.inputSolutionLoadingTimeMillisSpent??>