001/*
002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de)
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.benchmarking.common.converter;
017
018import com.google.gson.Gson;
019import com.google.gson.JsonArray;
020import com.google.gson.JsonElement;
021import com.google.gson.JsonObject;
022import de.cuioss.benchmarking.common.config.BenchmarkType;
023import de.cuioss.benchmarking.common.model.BenchmarkData;
024import de.cuioss.benchmarking.common.report.MetricConversionUtil;
025
026import java.io.IOException;
027import java.nio.file.Files;
028import java.nio.file.Path;
029import java.time.Instant;
030import java.time.ZoneOffset;
031import java.time.format.DateTimeFormatter;
032import java.util.*;
033
034/**
035 * Converts JMH benchmark results to the central BenchmarkData model
036 */
037public class JmhBenchmarkConverter implements BenchmarkConverter {
038
039    private static final Gson GSON = new Gson();
040    public static final String THRPT = "thrpt";
041
042    private final BenchmarkType benchmarkType;
043    private final String configuredThroughputName;
044    private final String configuredLatencyName;
045    private final String projectName;
046
047    public JmhBenchmarkConverter(BenchmarkType benchmarkType) {
048        this(benchmarkType, null, null, null);
049    }
050
051    public JmhBenchmarkConverter(BenchmarkType benchmarkType,
052            String configuredThroughputName, String configuredLatencyName) {
053        this(benchmarkType, configuredThroughputName, configuredLatencyName, null);
054    }
055
056    public JmhBenchmarkConverter(BenchmarkType benchmarkType,
057            String configuredThroughputName, String configuredLatencyName, String projectName) {
058        this.benchmarkType = benchmarkType;
059        this.configuredThroughputName = configuredThroughputName;
060        this.configuredLatencyName = configuredLatencyName;
061        this.projectName = projectName;
062    }
063
064    @Override
065    public BenchmarkData convert(Path sourcePath) throws IOException {
066        String json = Files.readString(sourcePath);
067        JsonArray jmhResults = GSON.fromJson(json, JsonArray.class);
068
069        List<BenchmarkData.Benchmark> benchmarks = new ArrayList<>();
070
071        for (JsonElement element : jmhResults) {
072            JsonObject jmhBenchmark = element.getAsJsonObject();
073            benchmarks.add(convertJmhBenchmark(jmhBenchmark));
074        }
075
076        return BenchmarkData.builder()
077                .metadata(createMetadata())
078                .overview(createOverview(benchmarks))
079                .benchmarks(benchmarks)
080                .build();
081    }
082
083    @Override
084    public boolean canConvert(Path sourcePath) {
085        return sourcePath.getFileName().toString().endsWith(".json") &&
086                (sourcePath.getFileName().toString().contains("jmh") ||
087                        sourcePath.getFileName().toString().contains("result"));
088    }
089
090    private BenchmarkData.Benchmark convertJmhBenchmark(JsonObject jmh) {
091        String name = jmh.get("benchmark").getAsString();
092        String mode = jmh.get("mode").getAsString();
093
094        JsonObject primaryMetric = jmh.getAsJsonObject("primaryMetric");
095        double score = primaryMetric.get("score").getAsDouble();
096        String scoreUnit = primaryMetric.get("scoreUnit").getAsString();
097
098        // Convert units for better readability
099        double convertedScore = score;
100        String convertedUnit = scoreUnit;
101
102        // Convert ops/ms to ops/s for throughput benchmarks
103        if (THRPT.equals(mode) && "ops/ms".equals(scoreUnit)) {
104            convertedScore = score * 1000; // Convert ops/ms to ops/s
105            convertedUnit = "ops/s";
106        }
107
108        Map<String, Double> percentiles = new LinkedHashMap<>();
109        if (primaryMetric.has("scorePercentiles")) {
110            JsonObject scorePercentiles = primaryMetric.getAsJsonObject("scorePercentiles");
111            for (Map.Entry<String, JsonElement> entry : scorePercentiles.entrySet()) {
112                double percentileValue = entry.getValue().getAsDouble();
113                // Apply same unit conversions to percentiles
114                if (THRPT.equals(mode) && "ops/ms".equals(scoreUnit)) {
115                    percentileValue = percentileValue * 1000;
116                } else {
117                    // Convert latency percentiles to milliseconds
118                    percentileValue = switch (scoreUnit) {
119                        case "us/op" -> percentileValue / 1000.0;
120                        case "ns/op" -> percentileValue / 1_000_000.0;
121                        case "s/op" -> percentileValue * 1000.0;
122                        default -> percentileValue;
123                    };
124                }
125                percentiles.put(entry.getKey(), percentileValue);
126            }
127        }
128
129        return BenchmarkData.Benchmark.builder()
130                .name(extractSimpleName(name))
131                .fullName(name)
132                .mode(mode)
133                .rawScore(convertedScore)
134                .score(formatScore(convertedScore, convertedUnit))
135                .scoreUnit(convertedUnit)
136                .throughput(THRPT.equals(mode) ? formatScore(convertedScore, convertedUnit) : null)
137                .latency("avgt".equals(mode) || "sample".equals(mode) ? formatScore(score, scoreUnit) : null)
138                .error(primaryMetric.has("scoreError") ? primaryMetric.get("scoreError").getAsDouble() : 0.0)
139                .percentiles(percentiles)
140                .build();
141    }
142
143    private BenchmarkData.Metadata createMetadata() {
144        Instant now = Instant.now();
145        return BenchmarkData.Metadata.builder()
146                .timestamp(now.toString())
147                .displayTimestamp(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss 'UTC'")
148                        .withZone(ZoneOffset.UTC)
149                        .format(now))
150                .benchmarkType(benchmarkType.getDisplayName())
151                .reportVersion("2.0")
152                .projectName(projectName)
153                .build();
154    }
155
156    private BenchmarkData.Overview createOverview(List<BenchmarkData.Benchmark> benchmarks) {
157        // Find throughput benchmark: prefer configured name, fall back to max-score heuristic
158        Optional<BenchmarkData.Benchmark> bestThroughput = findByConfiguredName(
159                benchmarks, configuredThroughputName, THRPT)
160                .or(() -> benchmarks.stream()
161                        .filter(b -> THRPT.equals(b.getMode()))
162                        .max(Comparator.comparing(BenchmarkData.Benchmark::getRawScore)));
163
164        // Find latency benchmark: prefer configured name, fall back to min-score heuristic
165        Optional<BenchmarkData.Benchmark> bestLatency = findByConfiguredName(
166                benchmarks, configuredLatencyName, "avgt")
167                .or(() -> benchmarks.stream()
168                        .filter(b -> "avgt".equals(b.getMode()) || "sample".equals(b.getMode()))
169                        .min(Comparator.comparing(BenchmarkData.Benchmark::getRawScore)));
170
171        double throughput = bestThroughput.map(BenchmarkData.Benchmark::getRawScore).orElse(0.0);
172        // IMPORTANT: Convert latency to milliseconds using the benchmark's unit
173        // The rawScore is in the original unit (us/op, ms/op, etc.)
174        // We need to convert to milliseconds per operation for consistent reporting
175        double latency = bestLatency.map(b -> {
176            double rawLatency = b.getRawScore();
177            String unit = b.getScoreUnit();
178            // Convert from various time units to milliseconds
179            return switch (unit) {
180                case "us/op" -> rawLatency / 1000.0; // microseconds to milliseconds
181                case "ns/op" -> rawLatency / 1_000_000.0; // nanoseconds to milliseconds
182                case "s/op" -> rawLatency * 1000.0; // seconds to milliseconds
183                default -> rawLatency; // "ms/op" or unknown - assume already in milliseconds
184            };
185        }).orElse(0.0);
186
187        int score = calculatePerformanceScore(throughput, latency);
188        String grade = calculatePerformanceGrade(score);
189
190        return BenchmarkData.Overview.builder()
191                .throughput(bestThroughput.map(BenchmarkData.Benchmark::getScore).orElse("N/A"))
192                .latency(latency > 0 ? MetricConversionUtil.formatLatency(latency) : "N/A")
193                .throughputOpsPerSec(throughput)  // Store numeric value used for score calculation
194                .latencyMs(latency)               // Store numeric value used for score calculation
195                .throughputBenchmarkName(bestThroughput.map(BenchmarkData.Benchmark::getName).orElse(""))
196                .latencyBenchmarkName(bestLatency.map(BenchmarkData.Benchmark::getName).orElse(""))
197                .performanceScore(score)
198                .performanceGrade(grade)
199                .performanceGradeClass("grade-" + grade.toLowerCase())
200                .build();
201    }
202
203    private static Optional<BenchmarkData.Benchmark> findByConfiguredName(
204            List<BenchmarkData.Benchmark> benchmarks, String configuredName, String expectedMode) {
205        if (configuredName == null || configuredName.isBlank()) {
206            return Optional.empty();
207        }
208        return benchmarks.stream()
209                .filter(b -> expectedMode.equals(b.getMode()))
210                .filter(b -> b.getName().equals(configuredName) || b.getFullName().equals(configuredName))
211                .findFirst();
212    }
213
214    private String extractSimpleName(String fullName) {
215        int lastDot = fullName.lastIndexOf('.');
216        return lastDot >= 0 ? fullName.substring(lastDot + 1) : fullName;
217    }
218
219    private String formatScore(double score, String unit) {
220        if (score >= 1000) {
221            return String.format(Locale.US, "%.1fK %s", score / 1000, unit);
222        }
223        return String.format(Locale.US, "%.1f %s", score, unit);
224    }
225
226    private int calculatePerformanceScore(double throughput, double latency) {
227        // Performance scoring per benchmarking/doc/performance-scoring.adoc
228        // Performance Score = (Throughput_Score × 0.5) + (Latency_Score × 0.5)
229        // Where:
230        // - Throughput_Score = Throughput ÷ 100
231        // - Latency_Score = 100 ÷ Latency_ms
232        // Scores are NOT capped - exceptional performance can exceed 100
233        double throughputScore = throughput / 100.0;
234        double latencyScore = latency > 0 ? 100.0 / latency : 0.0;
235        double rawScore = (throughputScore * 0.5) + (latencyScore * 0.5);
236        return (int) Math.round(rawScore);
237    }
238
239    private String calculatePerformanceGrade(int score) {
240        if (score >= 95) return "A+";
241        if (score >= 90) return "A";
242        if (score >= 75) return "B";
243        if (score >= 60) return "C";
244        if (score >= 40) return "D";
245        return "F";
246    }
247}