diff --git a/python-utils/baseline.py b/python-utils/baseline.py index ac648bd..259d796 100644 --- a/python-utils/baseline.py +++ b/python-utils/baseline.py @@ -284,7 +284,7 @@ def export_site_data(merged_data: dict, site_data_dir: str) -> None: # 2. Query runs table writer.writerow([ "engine", "dataset", "queryId", "queryCategory", "medianMs", - "medianTtfrMs", "heapDeltaMb" + "medianTtfrMs", "heapDeltaMb", "rowCount", "resultHash", "status" ]) for q in merged_data.get("queryRuns", []): writer.writerow([ @@ -295,5 +295,8 @@ def export_site_data(merged_data: dict, site_data_dir: str) -> None: q.get("medianMs", -1), q.get("medianTtfrMs", -1), _fmt_float(q.get("heapDeltaMb")), + q.get("rowCount", -1), + q.get("resultHash", ""), + q.get("status", "PASS" if q.get("medianMs", -1) >= 0 else "TIMEOUT"), ]) print(f"[site] Wrote unified CSV {csv_path}") diff --git a/site/app.js b/site/app.js index 004b1c4..80169f7 100644 --- a/site/app.js +++ b/site/app.js @@ -1618,10 +1618,17 @@ function renderQuerySection(queryRuns) { const winner = minMs !== null ? engines[rowData.indexOf(minMs)] : null; const allFailed = validTimes.length === 0; - const cells = engines.map((engine, i) => { - const ms = rowData[i]; - if (ms === null) return `—`; - return `${fmt(ms)} ms`; + const cells = engines.map(engine => { + const r = queryRuns.find(item => item.engine === engine && item.queryId === qid); + if (!r || r.medianMs < 0) { + const title = r && r.status ? `Status: ${r.status}` : 'No result'; + return `—`; + } + const rowInfo = r.rowCount != null && r.rowCount >= 0 ? ` · ${r.rowCount} rows` : ''; + const hashInfo = r.resultHash ? ` [${r.resultHash.substring(0, 8)}]` : ''; + const title = `${r.status || 'PASS'}${rowInfo}${hashInfo}`; + const mismatchBadge = r.status === 'MISMATCH' ? ' ⚠️' : ''; + return `${fmt(r.medianMs)} ms${mismatchBadge}`; }).join(''); const bestCell = winner diff --git a/src/main/java/fr/inria/corese/benchmark/Main.java b/src/main/java/fr/inria/corese/benchmark/Main.java index b806073..35a88ce 100644 --- a/src/main/java/fr/inria/corese/benchmark/Main.java +++ b/src/main/java/fr/inria/corese/benchmark/Main.java @@ -1,5 +1,7 @@ package fr.inria.corese.benchmark; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import fr.inria.corese.benchmark.driver.DriverFactory; import fr.inria.corese.benchmark.driver.TripleStoreDriver; import fr.inria.corese.benchmark.harness.BenchmarkHarness; @@ -15,13 +17,17 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; /** * Entry point for the benchmark harness. + * Supports running multiple engines sequentially in isolated child JVM processes + * to prevent JIT and GC cross-contamination. */ public class Main { private static final Logger log = LoggerFactory.getLogger(Main.class); + private static final long CHILD_TIMEOUT_MINUTES = 30; private record BenchmarkOptions( String datasetDir, @@ -31,7 +37,8 @@ private record BenchmarkOptions( String queriesFile, boolean inference, boolean quick, - int queryTimeoutSecs + int queryTimeoutSecs, + boolean isolateProcess ) {} private Main() {} @@ -49,6 +56,7 @@ private Main() {} *
  • {@code queriesFile} — (optional) path to a file with one SPARQL query per line
  • *
  • {@code --inference} — (optional flag) enables RDFS inference for Corese drivers
  • *
  • {@code --quick} — (optional flag) quick mode (1 repetition)
  • + *
  • {@code --no-isolate} — (optional flag) disable subprocess JVM isolation
  • * * * @param args command-line arguments as described above @@ -65,10 +73,18 @@ public static void main(String[] args) { List allFileResults = new ArrayList<>(); List allQueryResults = new ArrayList<>(); + boolean shouldIsolate = opts.isolateProcess() && opts.engines().size() > 1; for (String engineName : opts.engines()) { - runEngine(engineName.trim(), opts, queries, allFileResults, allQueryResults); + String cleanName = engineName.trim(); + if (shouldIsolate) { + runChild(cleanName, opts, allFileResults, allQueryResults); + } else { + runEngine(cleanName, opts, queries, allFileResults, allQueryResults); + } } + crossValidateQueries(allQueryResults); + try { BenchmarkReport.write(allFileResults, allQueryResults, queries, opts.outputFile()); } catch (Exception e) { @@ -77,6 +93,48 @@ public static void main(String[] args) { } } + private static void crossValidateQueries(List queryResults) { + if (queryResults == null || queryResults.isEmpty()) { + return; + } + java.util.Map> grouped = new java.util.HashMap<>(); + for (QueryResult qr : queryResults) { + String key = qr.dataset() + "::" + qr.queryId(); + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(qr); + } + + for (int i = 0; i < queryResults.size(); i++) { + QueryResult qr = queryResults.get(i); + if (!"PASS".equals(qr.status()) || qr.resultHash().isEmpty()) { + continue; + } + String key = qr.dataset() + "::" + qr.queryId(); + List peers = grouped.get(key); + if (peers == null || peers.size() <= 1) { + continue; + } + boolean mismatch = false; + for (QueryResult peer : peers) { + if (peer == qr || !"PASS".equals(peer.status()) || peer.resultHash().isEmpty()) { + continue; + } + if (!peer.resultHash().equals(qr.resultHash()) || peer.rowCount() != qr.rowCount()) { + mismatch = true; + break; + } + } + if (mismatch) { + log.warn("Cross-engine mismatch detected for query {} on dataset {}: engine {} produced hash {} (rows: {})", + qr.queryId(), qr.dataset(), qr.engine(), qr.resultHash().substring(0, Math.min(8, qr.resultHash().length())), qr.rowCount()); + queryResults.set(i, new QueryResult( + qr.engine(), qr.dataset(), qr.queryId(), qr.queryCategory(), + qr.medianMs(), qr.repetitionsMs(), qr.medianTtfrMs(), qr.repetitionsTtfrMs(), + qr.heapDeltaMb(), qr.rowCount(), qr.resultHash(), "MISMATCH" + )); + } + } + } + private static BenchmarkOptions parseOptions(String[] args) { String datasetDir = args[0]; String outputFile = args[1]; @@ -87,6 +145,7 @@ private static BenchmarkOptions parseOptions(String[] args) { boolean inference = false; boolean quick = false; int queryTimeoutSecs = -1; + boolean isolate = true; int i = 4; while (i < args.length) { @@ -97,6 +156,9 @@ private static BenchmarkOptions parseOptions(String[] args) { } else if ("--quick".equals(arg)) { quick = true; i++; + } else if ("--no-isolate".equals(arg)) { + isolate = false; + i++; } else if ("--query-timeout".equals(arg) && i + 1 < args.length) { queryTimeoutSecs = parseTimeout(args[i + 1]); i += 2; @@ -110,7 +172,7 @@ private static BenchmarkOptions parseOptions(String[] args) { return new BenchmarkOptions( datasetDir, outputFile, engines, datasetName, - queriesFile, inference, quick, queryTimeoutSecs + queriesFile, inference, quick, queryTimeoutSecs, isolate ); } @@ -167,4 +229,98 @@ private static void runEngine( log.error("Engine {} failed: {} — skipping to next engine", engineName, e.getMessage(), e); } } + + private static void runChild( + String engineName, + BenchmarkOptions opts, + List allFileResults, + List allQueryResults + ) { + log.info("Launching isolated child JVM for engine: {}", engineName); + Path tmp = null; + Process p = null; + try { + tmp = Files.createTempFile("corese-bench-" + engineName.replaceAll("[^a-zA-Z0-9.-]", "_") + "-", ".json"); + List cmd = buildChildCommand(opts, engineName, tmp); + p = new ProcessBuilder(cmd).inheritIO().start(); + boolean completed = p.waitFor(CHILD_TIMEOUT_MINUTES, TimeUnit.MINUTES); + if (!completed) { + p.destroyForcibly(); + log.error("Engine {} timed out in isolated subprocess after {} minutes", engineName, CHILD_TIMEOUT_MINUTES); + return; + } + if (p.exitValue() != 0) { + log.error("Engine {} failed in isolated subprocess with exit code {}", engineName, p.exitValue()); + return; + } + readChildReport(tmp, allFileResults, allQueryResults); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Isolated execution interrupted for engine: {}", engineName); + } catch (Exception e) { + log.error("Failed to run isolated subprocess for engine {}: {}", engineName, e.getMessage(), e); + } finally { + if (p != null && p.isAlive()) { + p.destroyForcibly(); + } + if (tmp != null) { + try { + Files.deleteIfExists(tmp); + } catch (Exception ignored) { + // Ignore temp file cleanup failure + } + } + } + } + + private static void readChildReport( + Path tmp, + List allFileResults, + List allQueryResults + ) throws Exception { + if (!Files.exists(tmp)) { + return; + } + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(tmp.toFile()); + if (root.has("runs")) { + FileResult[] frs = mapper.treeToValue(root.get("runs"), FileResult[].class); + allFileResults.addAll(Arrays.asList(frs)); + } + if (root.has("queryRuns")) { + QueryResult[] qrs = mapper.treeToValue(root.get("queryRuns"), QueryResult[].class); + allQueryResults.addAll(Arrays.asList(qrs)); + } + } + + private static List buildChildCommand(BenchmarkOptions opts, String engineName, Path tempOutput) { + List cmd = new ArrayList<>(); + String javaBin = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + cmd.add(javaBin); + cmd.add("-Xms4g"); + cmd.add("-Xmx8g"); + cmd.add("-XX:+UseG1GC"); + cmd.add("-cp"); + cmd.add(System.getProperty("java.class.path")); + cmd.add("fr.inria.corese.benchmark.Main"); + cmd.add(opts.datasetDir()); + cmd.add(tempOutput.toString()); + cmd.add(engineName); + cmd.add(opts.datasetName()); + if (opts.queriesFile() != null) { + cmd.add(opts.queriesFile()); + } + if (opts.inference()) { + cmd.add("--inference"); + } + if (opts.quick()) { + cmd.add("--quick"); + } + if (opts.queryTimeoutSecs() > 0) { + cmd.add("--query-timeout"); + cmd.add(String.valueOf(opts.queryTimeoutSecs())); + } + cmd.add("--no-isolate"); + return cmd; + } } diff --git a/src/main/java/fr/inria/corese/benchmark/driver/CoreseDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/CoreseDriver.java index b5fc87f..1c2704b 100644 --- a/src/main/java/fr/inria/corese/benchmark/driver/CoreseDriver.java +++ b/src/main/java/fr/inria/corese/benchmark/driver/CoreseDriver.java @@ -1,12 +1,19 @@ package fr.inria.corese.benchmark.driver; import fr.inria.corese.benchmark.metrics.MetricsCollector; +import fr.inria.corese.benchmark.validation.SparqlResultHasher; import fr.inria.corese.core.Graph; +import fr.inria.corese.core.kgram.api.core.Node; +import fr.inria.corese.core.kgram.core.Mapping; +import fr.inria.corese.core.kgram.core.Mappings; import fr.inria.corese.core.load.Load; import fr.inria.corese.core.api.Loader; import fr.inria.corese.core.query.QueryProcess; import fr.inria.corese.core.rule.RuleEngine; +import java.util.ArrayList; +import java.util.List; + /** * {@link TripleStoreDriver} implementation for Corese 4.x using the classic * {@code Graph} / {@code Load} / {@code QueryProcess} API. @@ -83,10 +90,30 @@ public QueryMetrics queryWithMetrics(String sparql) throws Exception { long heapBefore = MetricsCollector.usedHeapBytes(); QueryProcess exec = QueryProcess.create(graph); long start = System.currentTimeMillis(); - var maps = exec.query(sparql); + var res = exec.query(sparql); long total = System.currentTimeMillis() - start; - long count = maps != null ? maps.size() : -1L; - return new QueryMetrics(total, total, count, MetricsCollector.usedHeapBytes() - heapBefore); + long count = -1L; + String hash = ""; + + if (res instanceof Mappings maps) { + count = maps.size(); + SparqlResultHasher hasher = new SparqlResultHasher(); + List varNames = new ArrayList<>(); + for (Node varNode : maps.getSelect()) { + varNames.add(varNode.getLabel()); + } + java.util.Collections.sort(varNames); + for (Mapping m : maps) { + List row = new ArrayList<>(varNames.size()); + for (String var : varNames) { + Node val = m.getNode(var); + row.add(val != null ? val.getLabel() : null); + } + hasher.addRow(row); + } + hash = hasher.computeHash(); + } + return new QueryMetrics(total, total, count, MetricsCollector.usedHeapBytes() - heapBefore, hash); } /** Executes a SPARQL query and returns {@code [timeMs, resultCount]}. */ diff --git a/src/main/java/fr/inria/corese/benchmark/driver/JenaDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/JenaDriver.java index 97ca06a..0f51033 100644 --- a/src/main/java/fr/inria/corese/benchmark/driver/JenaDriver.java +++ b/src/main/java/fr/inria/corese/benchmark/driver/JenaDriver.java @@ -1,11 +1,17 @@ package fr.inria.corese.benchmark.driver; import fr.inria.corese.benchmark.metrics.MetricsCollector; +import fr.inria.corese.benchmark.validation.SparqlResultHasher; import org.apache.jena.query.*; +import org.apache.jena.rdf.model.RDFNode; import org.apache.jena.riot.Lang; import org.apache.jena.riot.RDFDataMgr; import org.apache.jena.system.Txn; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * Benchmark driver for Apache Jena. * @@ -92,24 +98,31 @@ public QueryMetrics queryWithMetrics(String sparql) { long heapBefore = MetricsCollector.usedHeapBytes(); long start = System.currentTimeMillis(); long[] resultHolder = new long[]{-1L, -1L}; // [ttfr, count] + String[] hashHolder = new String[]{""}; Txn.executeRead(dataset, () -> { try (QueryExecution qe = QueryExecution.dataset(dataset).query(sparql).build()) { if (qe.getQuery().isSelectType()) { ResultSet rs = qe.execSelect(); + List varNames = new ArrayList<>(rs.getResultVars()); + Collections.sort(varNames); + SparqlResultHasher hasher = new SparqlResultHasher(); long count = 0; if (rs.hasNext()) { - rs.next(); + QuerySolution qs = rs.next(); count++; + addJenaRow(hasher, qs, varNames); resultHolder[0] = System.currentTimeMillis() - start; while (rs.hasNext()) { - rs.next(); + qs = rs.next(); count++; + addJenaRow(hasher, qs, varNames); } } else { resultHolder[0] = System.currentTimeMillis() - start; } resultHolder[1] = count; + hashHolder[0] = hasher.computeHash(); } else if (qe.getQuery().isConstructType()) { qe.execConstruct(); resultHolder[0] = System.currentTimeMillis() - start; @@ -126,7 +139,26 @@ public QueryMetrics queryWithMetrics(String sparql) { long total = System.currentTimeMillis() - start; long ttfr = resultHolder[0] < 0 ? total : resultHolder[0]; long count = resultHolder[1]; - return new QueryMetrics(total, ttfr, count, MetricsCollector.usedHeapBytes() - heapBefore); + return new QueryMetrics(total, ttfr, count, MetricsCollector.usedHeapBytes() - heapBefore, hashHolder[0]); + } + + private static void addJenaRow(SparqlResultHasher hasher, QuerySolution qs, List sortedVarNames) { + List values = new ArrayList<>(sortedVarNames.size()); + for (String var : sortedVarNames) { + RDFNode node = qs.get(var); + values.add(extractJenaTerm(node)); + } + hasher.addRow(values); + } + + private static String extractJenaTerm(RDFNode node) { + if (node == null) return null; + if (node.isLiteral()) { + return node.asLiteral().getLexicalForm(); + } else if (node.isURIResource()) { + return node.asResource().getURI(); + } + return node.toString(); } /** Executes a SPARQL SELECT query and returns {@code [timeMs, resultCount]}. */ diff --git a/src/main/java/fr/inria/corese/benchmark/driver/QueryMetrics.java b/src/main/java/fr/inria/corese/benchmark/driver/QueryMetrics.java index e744648..7a4d585 100644 --- a/src/main/java/fr/inria/corese/benchmark/driver/QueryMetrics.java +++ b/src/main/java/fr/inria/corese/benchmark/driver/QueryMetrics.java @@ -10,5 +10,16 @@ * @param resultCount number of result rows/triples returned; {@code -1} if not applicable or not counted * @param heapDeltaBytes heap memory delta in bytes (used-after minus used-before, without forced GC); * may be negative if GC ran during query execution + * @param resultHash canonical SHA-256 hash of result bindings (empty string if not computed) */ -public record QueryMetrics(long totalMs, long ttfrMs, long resultCount, long heapDeltaBytes) {} +public record QueryMetrics( + long totalMs, + long ttfrMs, + long resultCount, + long heapDeltaBytes, + String resultHash +) { + public QueryMetrics(long totalMs, long ttfrMs, long resultCount, long heapDeltaBytes) { + this(totalMs, ttfrMs, resultCount, heapDeltaBytes, ""); + } +} diff --git a/src/main/java/fr/inria/corese/benchmark/driver/RDF4JDriver.java b/src/main/java/fr/inria/corese/benchmark/driver/RDF4JDriver.java index 546d30d..9423aa5 100644 --- a/src/main/java/fr/inria/corese/benchmark/driver/RDF4JDriver.java +++ b/src/main/java/fr/inria/corese/benchmark/driver/RDF4JDriver.java @@ -1,6 +1,9 @@ package fr.inria.corese.benchmark.driver; import fr.inria.corese.benchmark.metrics.MetricsCollector; +import fr.inria.corese.benchmark.validation.SparqlResultHasher; +import org.eclipse.rdf4j.model.Value; +import org.eclipse.rdf4j.query.BindingSet; import org.eclipse.rdf4j.repository.sail.SailRepository; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.sail.memory.MemoryStore; @@ -18,6 +21,8 @@ import java.io.InputStreamReader; import java.io.StringReader; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; /** * Benchmark driver for Eclipse RDF4J 5. @@ -166,20 +171,40 @@ public QueryMetrics queryWithMetrics(String sparql) { long start = System.currentTimeMillis(); long ttfr; long count = 0; + String hash = ""; try (RepositoryConnection conn = repository.getConnection()) { TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, sparql); try (TupleQueryResult result = q.evaluate()) { + List bindingNames = new ArrayList<>(result.getBindingNames()); + java.util.Collections.sort(bindingNames); + SparqlResultHasher hasher = new SparqlResultHasher(); if (result.hasNext()) { - result.next(); count++; + BindingSet bs = result.next(); + count++; + addRdf4jRow(hasher, bs, bindingNames); ttfr = System.currentTimeMillis() - start; - while (result.hasNext()) { result.next(); count++; } + while (result.hasNext()) { + bs = result.next(); + count++; + addRdf4jRow(hasher, bs, bindingNames); + } } else { ttfr = System.currentTimeMillis() - start; } + hash = hasher.computeHash(); } } long total = System.currentTimeMillis() - start; - return new QueryMetrics(total, ttfr, count, MetricsCollector.usedHeapBytes() - heapBefore); + return new QueryMetrics(total, ttfr, count, MetricsCollector.usedHeapBytes() - heapBefore, hash); + } + + private static void addRdf4jRow(SparqlResultHasher hasher, BindingSet bs, List bindingNames) { + List values = new ArrayList<>(bindingNames.size()); + for (String name : bindingNames) { + Value val = bs.getValue(name); + values.add(val != null ? val.stringValue() : null); + } + hasher.addRow(values); } /** Executes a SPARQL SELECT query and returns {@code [timeMs, resultCount]}. */ diff --git a/src/main/java/fr/inria/corese/benchmark/harness/BenchmarkHarness.java b/src/main/java/fr/inria/corese/benchmark/harness/BenchmarkHarness.java index 8c5b7a4..1c3d233 100644 --- a/src/main/java/fr/inria/corese/benchmark/harness/BenchmarkHarness.java +++ b/src/main/java/fr/inria/corese/benchmark/harness/BenchmarkHarness.java @@ -182,31 +182,41 @@ private QueryResult benchmarkSingleQuery(String sparql, String queryId, String c long[] times = new long[QUERY_REPS]; long[] ttfrTimes = new long[QUERY_REPS]; long[] heapDeltas = new long[QUERY_REPS]; + long[] rowCounts = new long[QUERY_REPS]; + String[] hashes = new String[QUERY_REPS]; + String[] statuses = new String[QUERY_REPS]; for (int i = 0; i < QUERY_REPS; i++) { - executeSingleRepetition(sparql, queryId, i, times, ttfrTimes, heapDeltas); + executeSingleRepetition(sparql, queryId, i, times, ttfrTimes, heapDeltas, rowCounts, hashes, statuses); } long medianMs = MetricsCollector.median(times); long medianTtfrMs = MetricsCollector.median(ttfrTimes); double heapDeltaMb = medianLong(heapDeltas) / (1024.0 * 1024.0); + long count = rowCounts[0]; + String hash = hashes[0] != null ? hashes[0] : ""; + String status = medianMs >= 0 ? "PASS" : (statuses[0] != null ? statuses[0] : "ERROR"); - log.info(" {} [{}] | {} ms | ttfr {} ms", queryId, category, + log.info(" {} [{}] | {} ms | ttfr {} ms | rows {} | status {}", queryId, category, medianMs >= 0 ? medianMs : "TIMEOUT", - medianTtfrMs >= 0 ? medianTtfrMs : "TIMEOUT"); + medianTtfrMs >= 0 ? medianTtfrMs : "TIMEOUT", + count >= 0 ? count : "-", + status); return new QueryResult( driver.getName(), datasetName, queryId, category, medianMs, times, medianTtfrMs, ttfrTimes, - heapDeltaMb + heapDeltaMb, + count, hash, status ); } private void executeSingleRepetition( String sparql, String queryId, int rep, - long[] times, long[] ttfrTimes, long[] heapDeltas + long[] times, long[] ttfrTimes, long[] heapDeltas, + long[] rowCounts, String[] hashes, String[] statuses ) { try (ExecutorService executor = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "query-runner-" + queryId + "-rep" + rep); @@ -219,21 +229,33 @@ private void executeSingleRepetition( times[rep] = m.totalMs(); ttfrTimes[rep] = m.ttfrMs(); heapDeltas[rep] = m.heapDeltaBytes(); + rowCounts[rep] = m.resultCount(); + hashes[rep] = m.resultHash(); + statuses[rep] = "PASS"; } catch (TimeoutException e) { future.cancel(true); executor.shutdownNow(); log.warn(" {} rep {} timed out after {}s", queryId, rep + 1, queryTimeoutS); times[rep] = -1L; ttfrTimes[rep] = -1L; + rowCounts[rep] = -1L; + hashes[rep] = ""; + statuses[rep] = "TIMEOUT"; } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn(" {} rep {} interrupted", queryId, rep + 1); times[rep] = -1L; ttfrTimes[rep] = -1L; + rowCounts[rep] = -1L; + hashes[rep] = ""; + statuses[rep] = "ERROR"; } catch (ExecutionException e) { log.warn(" {} rep {} failed: {}", queryId, rep + 1, e.getCause().getMessage()); times[rep] = -1L; ttfrTimes[rep] = -1L; + rowCounts[rep] = -1L; + hashes[rep] = ""; + statuses[rep] = "ERROR"; } } } diff --git a/src/main/java/fr/inria/corese/benchmark/metrics/QueryResult.java b/src/main/java/fr/inria/corese/benchmark/metrics/QueryResult.java index 7cb78c8..6b5cc0e 100644 --- a/src/main/java/fr/inria/corese/benchmark/metrics/QueryResult.java +++ b/src/main/java/fr/inria/corese/benchmark/metrics/QueryResult.java @@ -24,6 +24,9 @@ * @param repetitionsTtfrMs raw per-repetition TTFR values; same sentinel convention as above * @param heapDeltaMb median heap memory delta in MB during query execution (may be negative * if GC ran during the measurement window) + * @param rowCount number of result rows returned (-1 if not counted or error) + * @param resultHash canonical SHA-256 hash of result bindings (empty string if unavailable) + * @param status evaluation status ("PASS", "MISMATCH", "TIMEOUT", "ERROR") */ public record QueryResult( String engine, @@ -34,26 +37,46 @@ public record QueryResult( long[] repetitionsMs, long medianTtfrMs, long[] repetitionsTtfrMs, - double heapDeltaMb + double heapDeltaMb, + long rowCount, + String resultHash, + String status ) { + public QueryResult( + String engine, + String dataset, + String queryId, + String queryCategory, + long medianMs, + long[] repetitionsMs, + long medianTtfrMs, + long[] repetitionsTtfrMs, + double heapDeltaMb + ) { + this(engine, dataset, queryId, queryCategory, medianMs, repetitionsMs, medianTtfrMs, repetitionsTtfrMs, heapDeltaMb, -1L, "", medianMs >= 0 ? "PASS" : "TIMEOUT"); + } + @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof QueryResult other)) return false; return medianMs == other.medianMs && medianTtfrMs == other.medianTtfrMs && + rowCount == other.rowCount && Double.compare(other.heapDeltaMb, heapDeltaMb) == 0 && Objects.equals(engine, other.engine) && Objects.equals(dataset, other.dataset) && Objects.equals(queryId, other.queryId) && Objects.equals(queryCategory, other.queryCategory) && + Objects.equals(resultHash, other.resultHash) && + Objects.equals(status, other.status) && Arrays.equals(repetitionsMs, other.repetitionsMs) && Arrays.equals(repetitionsTtfrMs, other.repetitionsTtfrMs); } @Override public int hashCode() { - int result = Objects.hash(engine, dataset, queryId, queryCategory, medianMs, medianTtfrMs, heapDeltaMb); + int result = Objects.hash(engine, dataset, queryId, queryCategory, medianMs, medianTtfrMs, heapDeltaMb, rowCount, resultHash, status); result = 31 * result + Arrays.hashCode(repetitionsMs); result = 31 * result + Arrays.hashCode(repetitionsTtfrMs); return result; @@ -70,6 +93,9 @@ public String toString() { "repetitionsMs=" + Arrays.toString(repetitionsMs) + ", " + "medianTtfrMs=" + medianTtfrMs + ", " + "repetitionsTtfrMs=" + Arrays.toString(repetitionsTtfrMs) + ", " + - "heapDeltaMb=" + heapDeltaMb + ']'; + "heapDeltaMb=" + heapDeltaMb + ", " + + "rowCount=" + rowCount + ", " + + "resultHash=" + resultHash + ", " + + "status=" + status + ']'; } } diff --git a/src/main/java/fr/inria/corese/benchmark/report/BenchmarkReport.java b/src/main/java/fr/inria/corese/benchmark/report/BenchmarkReport.java index 0a2f724..13465a1 100644 --- a/src/main/java/fr/inria/corese/benchmark/report/BenchmarkReport.java +++ b/src/main/java/fr/inria/corese/benchmark/report/BenchmarkReport.java @@ -82,12 +82,19 @@ public static void write(List results, List queryResult mapper.writeValue(jsonPath.toFile(), report); log.info("Report written to: {}", outputPath); - // CSV — sibling csv/ directory alongside json/ - Path csvDir = jsonPath.getParent().getParent().resolve("csv"); - Files.createDirectories(csvDir); - Path csvPath = csvDir.resolve(jsonPath.getFileName().toString().replace(".json", ".csv")); - writeCsv(results, queryResults, csvPath.toString()); - log.info("CSV written to: {}", csvPath); + // CSV — sibling csv/ directory alongside json/ if possible, otherwise alongside the json file + try { + Path parent = jsonPath.getParent(); + Path csvDir = (parent != null && parent.getParent() != null) + ? parent.getParent().resolve("csv") + : (parent != null ? parent.resolve("csv") : Path.of("csv")); + Files.createDirectories(csvDir); + Path csvPath = csvDir.resolve(jsonPath.getFileName().toString().replace(".json", ".csv")); + writeCsv(results, queryResults, csvPath.toString()); + log.info("CSV written to: {}", csvPath); + } catch (Exception e) { + log.warn("Could not generate sibling CSV for {}: {}", outputPath, e.getMessage()); + } } /** @@ -119,14 +126,15 @@ private static void writeCsv(List results, List queryRe if (queryResults != null && !queryResults.isEmpty()) { pw.println(); pw.println("# QUERY BENCHMARK"); - pw.println("engine,dataset,queryId,queryCategory,medianMs,medianTtfrMs,heapDeltaMb,rep1Ms,rep2Ms,rep3Ms"); + pw.println("engine,dataset,queryId,queryCategory,medianMs,medianTtfrMs,heapDeltaMb,rowCount,resultHash,status,rep1Ms,rep2Ms,rep3Ms"); for (QueryResult r : queryResults) { String reps = Arrays.stream(r.repetitionsMs()) .mapToObj(String::valueOf) .collect(Collectors.joining(",")); - pw.printf(Locale.US, "%s,%s,%s,%s,%d,%d,%.3f,%s%n", + pw.printf(Locale.US, "%s,%s,%s,%s,%d,%d,%.3f,%d,%s,%s,%s%n", r.engine(), r.dataset(), r.queryId(), r.queryCategory(), - r.medianMs(), r.medianTtfrMs(), r.heapDeltaMb(), reps); + r.medianMs(), r.medianTtfrMs(), r.heapDeltaMb(), + r.rowCount(), r.resultHash(), r.status(), reps); } } } diff --git a/src/main/java/fr/inria/corese/benchmark/validation/SparqlResultHasher.java b/src/main/java/fr/inria/corese/benchmark/validation/SparqlResultHasher.java new file mode 100644 index 0000000..24cf76a --- /dev/null +++ b/src/main/java/fr/inria/corese/benchmark/validation/SparqlResultHasher.java @@ -0,0 +1,132 @@ +package fr.inria.corese.benchmark.validation; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Computes a deterministic SHA-256 fingerprint of a SPARQL solution sequence. + * + *
      + *
    • ordered (ORDER BY queries): rows are streamed into the digest as received;
    • + *
    • unordered: rows are buffered and sorted lexicographically (unsigned bytes) + * before hashing, so the result is independent of engine row order.
    • + *
    + * + *

    Each row is encoded as a value count followed by length-prefixed UTF-8 values; + * unbound (null) values use length -1, so ["ab", null] never collides with ["a", "b"]. + * Only values are hashed; {@link #addRow(Map)} orders them by variable name. + * + *

    Not thread-safe. + */ +public final class SparqlResultHasher { + + private static final String ALGORITHM = "SHA-256"; + private static final int NULL_MARKER = -1; + + private final boolean ordered; + private final MessageDigest streamingDigest; + private final List bufferedRows = new ArrayList<>(); + private long rowCount; + + /** Unordered mode: rows are sorted before hashing. */ + public SparqlResultHasher() { + this(false); + } + + /** @param ordered true if rows already arrive in a deterministic order (streaming mode) */ + public SparqlResultHasher(boolean ordered) { + this.ordered = ordered; + this.streamingDigest = ordered ? newDigest() : null; + } + + /** Adds a binding; values are taken in ascending variable-name order. Null map/values = unbound. */ + public SparqlResultHasher addRow(Map binding) { + if (binding == null) { + return addRow(List.of()); + } + Map sorted = new TreeMap<>(); + binding.forEach((name, value) -> sorted.put(Objects.requireNonNull(name, "variable name"), value)); + return addRow(new ArrayList<>(sorted.values())); + } + + /** Adds a row whose values are already ordered by variable name; null elements are unbound. */ + public SparqlResultHasher addRow(List values) { + byte[] encoded = encode(values == null ? List.of() : values); + if (ordered) { + streamingDigest.update(encoded); + } else { + bufferedRows.add(encoded); + } + rowCount++; + return this; + } + + public long getRowCount() { + return rowCount; + } + + /** Lowercase hex SHA-256 of the rows so far; repeatable, rows may be added afterwards. */ + public String computeHash() { + MessageDigest digest = ordered ? cloneStreamingDigest() : hashSortedRows(); + return HexFormat.of().formatHex(digest.digest()); + } + + private MessageDigest hashSortedRows() { + List sorted = new ArrayList<>(bufferedRows); + sorted.sort(Arrays::compareUnsigned); + MessageDigest digest = newDigest(); + sorted.forEach(digest::update); + return digest; + } + + private MessageDigest cloneStreamingDigest() { + try { + return (MessageDigest) streamingDigest.clone(); + } catch (CloneNotSupportedException e) { + throw new IllegalStateException(ALGORITHM + " digest is not cloneable", e); + } + } + + private static byte[] encode(List values) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeInt(out, values.size()); + for (String value : values) { + writeValue(out, value); + } + return out.toByteArray(); + } + + private static void writeValue(ByteArrayOutputStream out, String value) { + if (value == null) { + writeInt(out, NULL_MARKER); + return; + } + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + writeInt(out, bytes.length); + out.writeBytes(bytes); + } + + private static void writeInt(ByteArrayOutputStream out, int v) { + out.write(v >>> 24); + out.write(v >>> 16); + out.write(v >>> 8); + out.write(v); + } + + private static MessageDigest newDigest() { + try { + return MessageDigest.getInstance(ALGORITHM); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(ALGORITHM + " is required by the Java platform", e); + } + } +} diff --git a/src/test/java/fr/inria/corese/benchmark/harness/QueryResultAccuracyTest.java b/src/test/java/fr/inria/corese/benchmark/harness/QueryResultAccuracyTest.java new file mode 100644 index 0000000..05d5c38 --- /dev/null +++ b/src/test/java/fr/inria/corese/benchmark/harness/QueryResultAccuracyTest.java @@ -0,0 +1,98 @@ +package fr.inria.corese.benchmark.harness; + +import fr.inria.corese.benchmark.driver.JenaDriver; +import fr.inria.corese.benchmark.driver.RDF4JDriver; +import fr.inria.corese.benchmark.metrics.QueryResult; +import fr.inria.corese.benchmark.report.BenchmarkReport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class QueryResultAccuracyTest { + + @Test + @DisplayName("Cross-engine accuracy verification: Jena and RDF4J return identical count and hash on same dataset") + void testCrossEngineResultAccuracy(@TempDir Path tempDir) throws Exception { + Path ntFile = tempDir.resolve("data.nt"); + Files.writeString(ntFile, + " \"Alice\" .\n" + + " \"Bob\" .\n" + + " .\n" + ); + + String query = "SELECT ?name WHERE { ?s ?name }"; + List queries = List.of(query); + + // 1. Run Jena + JenaDriver jena = new JenaDriver("jena.test"); + BenchmarkHarness harnessJena = new BenchmarkHarness(jena, "test_accuracy", 1); + List jenaResults = harnessJena.runQueryBenchmark(tempDir.toString(), queries); + + // 2. Run RDF4J + RDF4JDriver rdf4j = new RDF4JDriver("rdf4j.test"); + BenchmarkHarness harnessRdf4j = new BenchmarkHarness(rdf4j, "test_accuracy", 1); + List rdf4jResults = harnessRdf4j.runQueryBenchmark(tempDir.toString(), queries); + + assertEquals(1, jenaResults.size()); + assertEquals(1, rdf4jResults.size()); + + QueryResult qJena = jenaResults.get(0); + QueryResult qRdf4j = rdf4jResults.get(0); + + // Verify count matches + assertEquals(2, qJena.rowCount(), "Jena should return 2 rows"); + assertEquals(2, qRdf4j.rowCount(), "RDF4J should return 2 rows"); + assertEquals(qJena.rowCount(), qRdf4j.rowCount()); + + // Verify canonical hash matches across engines + assertFalse(qJena.resultHash().isEmpty()); + assertFalse(qRdf4j.resultHash().isEmpty()); + assertEquals(qJena.resultHash(), qRdf4j.resultHash(), "Canonical hash must match across Jena and RDF4J"); + + // Verify PASS status + assertEquals("PASS", qJena.status()); + assertEquals("PASS", qRdf4j.status()); + + // 3. Test report serialization with new fields + Path jsonOut = tempDir.resolve("json/report.json"); + Files.createDirectories(jsonOut.getParent()); + BenchmarkReport.write(Collections.emptyList(), List.of(qJena, qRdf4j), queries, jsonOut.toString()); + + String jsonContent = Files.readString(jsonOut); + assertTrue(jsonContent.contains("\"rowCount\" : 2")); + assertTrue(jsonContent.contains("\"resultHash\" : \"" + qJena.resultHash() + "\"")); + assertTrue(jsonContent.contains("\"status\" : \"PASS\"")); + + Path csvOut = tempDir.resolve("csv/report.csv"); + assertTrue(Files.exists(csvOut)); + String csvContent = Files.readString(csvOut); + assertTrue(csvContent.contains("rowCount,resultHash,status")); + assertTrue(csvContent.contains(",2," + qJena.resultHash() + ",PASS,")); + } + + @Test + @DisplayName("Cross-validation flags MISMATCH when results or counts differ") + void testCrossEngineMismatchDetection() { + QueryResult q1 = new QueryResult("engineA", "ds", "q1", "PATTERN", 10, new long[]{10}, 10, new long[]{10}, 0.1, 10, "hashAAA", "PASS"); + QueryResult q2 = new QueryResult("engineB", "ds", "q1", "PATTERN", 12, new long[]{12}, 12, new long[]{12}, 0.1, 8, "hashBBB", "PASS"); + + List list = new java.util.ArrayList<>(List.of(q1, q2)); + // Trigger reflection or call Main via CLI / method + try { + java.lang.reflect.Method m = fr.inria.corese.benchmark.Main.class.getDeclaredMethod("crossValidateQueries", List.class); + m.setAccessible(true); + m.invoke(null, list); + assertEquals("MISMATCH", list.get(0).status()); + assertEquals("MISMATCH", list.get(1).status()); + } catch (Exception e) { + fail("Reflection call failed: " + e.getMessage()); + } + } +} diff --git a/src/test/java/fr/inria/corese/benchmark/validation/SparqlResultHasherTest.java b/src/test/java/fr/inria/corese/benchmark/validation/SparqlResultHasherTest.java new file mode 100644 index 0000000..7239987 --- /dev/null +++ b/src/test/java/fr/inria/corese/benchmark/validation/SparqlResultHasherTest.java @@ -0,0 +1,73 @@ +package fr.inria.corese.benchmark.validation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class SparqlResultHasherTest { + + @Test + @DisplayName("Unordered mode produces identical hash regardless of row insertion order") + void unorderedModeIsOrderIndependent() { + SparqlResultHasher h1 = new SparqlResultHasher(); + h1.addRow(Map.of("x", "http://example.org/1", "y", "42")); + h1.addRow(Map.of("x", "http://example.org/2", "y", "99")); + + SparqlResultHasher h2 = new SparqlResultHasher(); + h2.addRow(Map.of("x", "http://example.org/2", "y", "99")); + h2.addRow(Map.of("x", "http://example.org/1", "y", "42")); + + assertEquals(2, h1.getRowCount()); + assertEquals(2, h2.getRowCount()); + assertEquals(h1.computeHash(), h2.computeHash()); + } + + @Test + @DisplayName("Ordered mode distinguishes different row orderings") + void orderedModeIsOrderSensitive() { + SparqlResultHasher h1 = new SparqlResultHasher(true); + h1.addRow(List.of("A", "B")); + h1.addRow(List.of("C", "D")); + + SparqlResultHasher h2 = new SparqlResultHasher(true); + h2.addRow(List.of("C", "D")); + h2.addRow(List.of("A", "B")); + + assertEquals(2, h1.getRowCount()); + assertEquals(2, h2.getRowCount()); + assertNotEquals(h1.computeHash(), h2.computeHash()); + } + + @Test + @DisplayName("Unbound values do not collide with adjacent non-null strings") + void unboundDoesNotCollide() { + SparqlResultHasher h1 = new SparqlResultHasher(); + h1.addRow(ArraysListHelper.of("ab", null)); + + SparqlResultHasher h2 = new SparqlResultHasher(); + h2.addRow(ArraysListHelper.of("a", "b")); + + assertNotEquals(h1.computeHash(), h2.computeHash()); + } + + @Test + @DisplayName("Empty result set produces consistent 64-char hex hash") + void emptyResultSetHash() { + SparqlResultHasher h = new SparqlResultHasher(); + assertEquals(0, h.getRowCount()); + String hash = h.computeHash(); + assertNotNull(hash); + assertEquals(64, hash.length()); + } + + private static class ArraysListHelper { + @SafeVarargs + static List of(T... elements) { + return java.util.Arrays.asList(elements); + } + } +}