diff --git a/subprojects/store/src/main/java/tools/refinery/store/map/ConsolidatedDiffCursor.java b/subprojects/store/src/main/java/tools/refinery/store/map/ConsolidatedDiffCursor.java
new file mode 100644
index 000000000..459850292
--- /dev/null
+++ b/subprojects/store/src/main/java/tools/refinery/store/map/ConsolidatedDiffCursor.java
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2026 The Refinery Authors
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package tools.refinery.store.map;
+
+import java.util.AbstractMap;
+import java.util.AbstractMap.SimpleEntry;
+import java.util.HashMap;
+import java.util.Objects;
+
+class ConsolidatedDiffCursor implements DiffCursor {
+
+ private final DiffCursor wrappedDiffCursor;
+ private DiffEntry[] diff;
+ private int cursorIndex = -1;
+
+ private record DiffEntry(K key, V from, V to) {
+ }
+
+ public ConsolidatedDiffCursor(DiffCursor diffCursor) {
+ wrappedDiffCursor = diffCursor;
+ }
+
+ @Override
+ public K getKey() {
+ return diff != null && 0 <= cursorIndex && cursorIndex < diff.length ? diff[cursorIndex].key : null;
+ }
+
+ @Override
+ public V getValue() {
+ return getToValue();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return diff != null && cursorIndex >= diff.length;
+ }
+
+ @Override
+ public boolean move() {
+ if (diff == null) {
+ consolidate();
+ }
+
+ cursorIndex++;
+ return cursorIndex < diff.length;
+ }
+
+ @Override
+ public V getFromValue() {
+ return diff != null && 0 <= cursorIndex && cursorIndex < diff.length ? diff[cursorIndex].from : null;
+ }
+
+ @Override
+ public V getToValue() {
+ return diff != null && 0 <= cursorIndex && cursorIndex < diff.length ? diff[cursorIndex].to : null;
+ }
+
+ private void consolidate() {
+ HashMap> consolidatedChanges = new HashMap<>();
+ while (wrappedDiffCursor.move()) {
+ var storedChange = consolidatedChanges.get(wrappedDiffCursor.getKey());
+ V fromValue;
+ if (storedChange != null) {
+ if (!Objects.equals(storedChange.getValue(), wrappedDiffCursor.getFromValue())) {
+ throw new IllegalStateException("Inconsistent diff cursor: mismatched previous value and from value");
+ }
+ fromValue = storedChange.getKey();
+ } else {
+ fromValue = wrappedDiffCursor.getFromValue();
+ }
+ V toValue = wrappedDiffCursor.getToValue();
+
+ if (Objects.equals(fromValue, toValue)) {
+ consolidatedChanges.remove(wrappedDiffCursor.getKey());
+ } else {
+ consolidatedChanges.put(wrappedDiffCursor.getKey(), new SimpleEntry<>(fromValue, toValue));
+ }
+ }
+
+ diff = new DiffEntry[consolidatedChanges.size()];
+ int i = 0;
+ for (var entry : consolidatedChanges.entrySet()) {
+ diff[i] = new DiffEntry<>(entry.getKey(), entry.getValue().getKey(), entry.getValue().getValue());
+ i++;
+ }
+ }
+}
diff --git a/subprojects/store/src/main/java/tools/refinery/store/map/VersionedMap.java b/subprojects/store/src/main/java/tools/refinery/store/map/VersionedMap.java
index 28194b58e..24da5470e 100644
--- a/subprojects/store/src/main/java/tools/refinery/store/map/VersionedMap.java
+++ b/subprojects/store/src/main/java/tools/refinery/store/map/VersionedMap.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors
+ * SPDX-FileCopyrightText: 2021-2026 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
@@ -17,4 +17,11 @@ public non-sealed interface VersionedMap extends AnyVersionedMap {
void putAll(Cursor cursor);
DiffCursor getDiffCursor(Version state);
+
+ default DiffCursor getDiffCursor(Version state, boolean consolidate) {
+ if (!consolidate) {
+ return getDiffCursor(state);
+ }
+ return new ConsolidatedDiffCursor<>(getDiffCursor(state));
+ }
}
diff --git a/subprojects/store/src/main/java/tools/refinery/store/map/internal/state/VersionedMapStateImpl.java b/subprojects/store/src/main/java/tools/refinery/store/map/internal/state/VersionedMapStateImpl.java
index 57eeccf6e..cf052b6f7 100644
--- a/subprojects/store/src/main/java/tools/refinery/store/map/internal/state/VersionedMapStateImpl.java
+++ b/subprojects/store/src/main/java/tools/refinery/store/map/internal/state/VersionedMapStateImpl.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: 2023 The Refinery Authors
+ * SPDX-FileCopyrightText: 2023-2026 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
@@ -82,7 +82,7 @@ public void putAll(Cursor cursor) {
while (keyIterator.hasNext()) {
var key = keyIterator.next();
var value = valueIterator.next();
- this.put(key,value);
+ this.put(key, value);
}
} else {
while (cursor.move()) {
@@ -122,6 +122,10 @@ public DiffCursor getDiffCursor(Version toVersion) {
return new MapDiffCursor<>(this.defaultValue, fromCursor, toCursor);
}
+ @Override
+ public DiffCursor getDiffCursor(Version state, boolean consolidate) {
+ return getDiffCursor(state);
+ }
@Override
public Version commit() {
@@ -157,7 +161,7 @@ public void checkIntegrity() {
@Override
public int contentHashCode(ContentHashCode mode) {
// Calculating the root hashCode is always fast, because {@link Node} caches its hashCode.
- if(root == null) {
+ if (root == null) {
return 0;
} else {
return root.hashCode();
diff --git a/subprojects/store/src/main/java/tools/refinery/store/model/Interpretation.java b/subprojects/store/src/main/java/tools/refinery/store/model/Interpretation.java
index 1b15e4cff..0c17072c3 100644
--- a/subprojects/store/src/main/java/tools/refinery/store/model/Interpretation.java
+++ b/subprojects/store/src/main/java/tools/refinery/store/model/Interpretation.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors
+ * SPDX-FileCopyrightText: 2021-2026 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
@@ -25,7 +25,11 @@ public non-sealed interface Interpretation extends AnyInterpretation {
void putAll(Cursor cursor);
- DiffCursor getDiffCursor(Version to);
+ default DiffCursor getDiffCursor(Version to) {
+ return getDiffCursor(to, false);
+ }
+
+ DiffCursor getDiffCursor(Version to, boolean consolidate);
void addListener(InterpretationListener listener, boolean alsoWhenRestoring);
diff --git a/subprojects/store/src/main/java/tools/refinery/store/model/Model.java b/subprojects/store/src/main/java/tools/refinery/store/model/Model.java
index 3eb6ef9a1..ca7af1916 100644
--- a/subprojects/store/src/main/java/tools/refinery/store/model/Model.java
+++ b/subprojects/store/src/main/java/tools/refinery/store/model/Model.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors
+ * SPDX-FileCopyrightText: 2021-2026 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
@@ -29,7 +29,11 @@ default AnyInterpretation getInterpretation(AnySymbol symbol) {
Interpretation getInterpretation(Symbol symbol);
- ModelDiffCursor getDiffCursor(Version to);
+ default ModelDiffCursor getDiffCursor(Version to) {
+ return getDiffCursor(to, false);
+ }
+
+ ModelDiffCursor getDiffCursor(Version to, boolean consolidate);
Optional tryGetAdapter(Class extends T> adapterType);
diff --git a/subprojects/store/src/main/java/tools/refinery/store/model/internal/ModelImpl.java b/subprojects/store/src/main/java/tools/refinery/store/model/internal/ModelImpl.java
index 0fc3ca5ab..6624c9b2c 100644
--- a/subprojects/store/src/main/java/tools/refinery/store/model/internal/ModelImpl.java
+++ b/subprojects/store/src/main/java/tools/refinery/store/model/internal/ModelImpl.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors
+ * SPDX-FileCopyrightText: 2021-2026 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
@@ -60,11 +60,12 @@ public Interpretation getInterpretation(Symbol symbol) {
}
@Override
- public ModelDiffCursor getDiffCursor(Version to) {
+ public ModelDiffCursor getDiffCursor(Version to, boolean consolidate) {
var diffCursors = HashMap.>newHashMap(interpretations.size());
int i = 0;
for (var entry : interpretations.entrySet()) {
- diffCursors.put(entry.getKey(), entry.getValue().getDiffCursor(ModelVersion.getInternalVersion(to, i++)));
+ diffCursors.put(entry.getKey(), entry.getValue().getDiffCursor(ModelVersion.getInternalVersion(to, i++),
+ consolidate));
}
return new ModelDiffCursor(diffCursors);
}
diff --git a/subprojects/store/src/main/java/tools/refinery/store/model/internal/VersionedInterpretation.java b/subprojects/store/src/main/java/tools/refinery/store/model/internal/VersionedInterpretation.java
index dcf0ad08b..4988c5fc6 100644
--- a/subprojects/store/src/main/java/tools/refinery/store/model/internal/VersionedInterpretation.java
+++ b/subprojects/store/src/main/java/tools/refinery/store/model/internal/VersionedInterpretation.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors
+ * SPDX-FileCopyrightText: 2021-2026 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
@@ -106,8 +106,8 @@ public void putAll(Cursor cursor) {
}
@Override
- public DiffCursor getDiffCursor(Version to) {
- return map.getDiffCursor(to);
+ public DiffCursor getDiffCursor(Version to, boolean consolidate) {
+ return map.getDiffCursor(to, consolidate);
}
Version commit() {
diff --git a/subprojects/store/src/test/java/tools/refinery/store/map/tests/fuzz/DiffCursorFuzzTest.java b/subprojects/store/src/test/java/tools/refinery/store/map/tests/fuzz/DiffCursorFuzzTest.java
index 94259edc1..899f693be 100644
--- a/subprojects/store/src/test/java/tools/refinery/store/map/tests/fuzz/DiffCursorFuzzTest.java
+++ b/subprojects/store/src/test/java/tools/refinery/store/map/tests/fuzz/DiffCursorFuzzTest.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors
+ * SPDX-FileCopyrightText: 2021-2026 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
@@ -10,7 +10,11 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
-import tools.refinery.store.map.*;
+import tools.refinery.store.map.DiffCursor;
+import tools.refinery.store.map.Version;
+import tools.refinery.store.map.VersionedMap;
+import tools.refinery.store.map.VersionedMapStore;
+import tools.refinery.store.map.VersionedMapStoreFactoryBuilder;
import tools.refinery.store.map.tests.fuzz.utils.FuzzTestUtils;
import tools.refinery.store.map.tests.utils.MapTestEnvironment;
@@ -19,13 +23,20 @@
import java.util.Random;
import java.util.stream.Stream;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.fail;
-import static tools.refinery.store.map.tests.fuzz.utils.FuzzTestCollections.*;
+import static tools.refinery.store.map.tests.fuzz.utils.FuzzTestCollections.commitFrequencyOptions;
+import static tools.refinery.store.map.tests.fuzz.utils.FuzzTestCollections.keyCounts;
+import static tools.refinery.store.map.tests.fuzz.utils.FuzzTestCollections.nullDefaultOptions;
+import static tools.refinery.store.map.tests.fuzz.utils.FuzzTestCollections.randomSeedOptions;
+import static tools.refinery.store.map.tests.fuzz.utils.FuzzTestCollections.storeConfigs;
+import static tools.refinery.store.map.tests.fuzz.utils.FuzzTestCollections.valueCounts;
class DiffCursorFuzzTest {
private void runFuzzTest(String scenario, int seed, int steps, int maxKey, int maxValue, boolean nullDefault,
- int commitFrequency, boolean commitBeforeDiffCursor,
- VersionedMapStoreFactoryBuilder builder) {
+ int commitFrequency, boolean commitBeforeDiffCursor,
+ VersionedMapStoreFactoryBuilder builder) {
String[] values = MapTestEnvironment.prepareValues(maxValue, nullDefault);
VersionedMapStore store = builder.defaultValue(values[0]).build().createOne();
@@ -34,11 +45,11 @@ private void runFuzzTest(String scenario, int seed, int steps, int maxKey, int m
}
private void iterativeRandomPutsAndCommitsThenDiffCursor(String scenario, VersionedMapStore store,
- int steps, int maxKey, String[] values, int seed,
- int commitFrequency, boolean commitBeforeDiffCursor) {
+ int steps, int maxKey, String[] values, int seed,
+ int commitFrequency, boolean commitBeforeDiffCursor) {
int largestCommit = -1;
- Map index2Version = new HashMap<>();
+ Map index2Version = new HashMap<>();
{
// 1. build a map with versions
@@ -56,7 +67,7 @@ private void iterativeRandomPutsAndCommitsThenDiffCursor(String scenario, Versio
}
if (index % commitFrequency == 0) {
Version version = versioned.commit();
- index2Version.put(index,version);
+ index2Version.put(index, version);
largestCommit = index;
}
if (index % 10000 == 0)
@@ -78,13 +89,28 @@ private void iterativeRandomPutsAndCommitsThenDiffCursor(String scenario, Versio
VersionedMap oracle = store.createMap(index2Version.get(travelToVersion));
- if(commitBeforeDiffCursor) {
+ if (commitBeforeDiffCursor) {
moving.commit();
}
DiffCursor diffCursor = moving.getDiffCursor(index2Version.get(travelToVersion));
+
+ DiffCursor consolidatedDiffCursor =
+ moving.getDiffCursor(index2Version.get(travelToVersion), true);
+ HashMap consolidatedToValues = new HashMap<>();
+ while (consolidatedDiffCursor.move()) {
+ assertEquals(moving.get(consolidatedDiffCursor.getKey()),
+ consolidatedDiffCursor.getFromValue());
+ assertFalse(consolidatedToValues.containsKey(consolidatedDiffCursor.getKey()));
+ consolidatedToValues.put(consolidatedDiffCursor.getKey(), consolidatedDiffCursor.getToValue());
+ }
+
moving.putAll(diffCursor);
moving.commit();
+ for (var entry : consolidatedToValues.entrySet()) {
+ assertEquals(moving.get(entry.getKey()), entry.getValue());
+ }
+
MapTestEnvironment.compareTwoMaps(scenario + ":c" + index, oracle, moving);
moving.restore(index2Version.get(travelToVersion));
@@ -114,15 +140,15 @@ private void iterativeRandomPutsAndCommitsThenDiffCursor(String scenario, Versio
@Timeout(value = 10)
@Tag("fuzz")
void parametrizedFuzz(int ignoredTests, int steps, int noKeys, int noValues, boolean nullDefault,
- int commitFrequency, int seed, boolean commitBeforeDiffCursor,
- VersionedMapStoreFactoryBuilder builder) {
+ int commitFrequency, int seed, boolean commitBeforeDiffCursor,
+ VersionedMapStoreFactoryBuilder builder) {
runFuzzTest("DiffCursorS" + steps + "K" + noKeys + "V" + noValues + "s" + seed, seed, steps,
noKeys, noValues, nullDefault, commitFrequency, commitBeforeDiffCursor, builder);
}
static Stream parametrizedFuzz() {
return FuzzTestUtils.permutationWithSize(new Object[]{100}, keyCounts, valueCounts, nullDefaultOptions,
- commitFrequencyOptions, randomSeedOptions, new Object[]{false,true}, storeConfigs);
+ commitFrequencyOptions, randomSeedOptions, new Object[]{false, true}, storeConfigs);
}
@ParameterizedTest(name = title)
@@ -130,7 +156,7 @@ static Stream parametrizedFuzz() {
@Tag("fuzz")
@Tag("slow")
void parametrizedSlowFuzz(int ignoredTests, int steps, int noKeys, int noValues, boolean nullDefault, int commitFrequency,
- int seed, boolean commitBeforeDiffCursor, VersionedMapStoreFactoryBuilder builder) {
+ int seed, boolean commitBeforeDiffCursor, VersionedMapStoreFactoryBuilder builder) {
runFuzzTest("DiffCursorS" + steps + "K" + noKeys + "V" + noValues + "s" + seed, seed, steps, noKeys, noValues,
nullDefault, commitFrequency, commitBeforeDiffCursor, builder);
}