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);
+ }
+ }
+}