diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d78f2fa..7f4ab5dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). * Added a new `automata-serialization-mata` module for serializing (explicit) NFAs in the `.mata` format as used by the [mata library](https://github.com/VeriFIT/mata). * `automata-modelchecking-m3c` now supports ARM-based macOS systems. * `automata-modelchecking-m3c` can now be included in jlink images. +* Added `KWay{State,Transition}CoverTestsIterator`s to `automata-util` as a new means for conformance testing. +* Added `CollectionUtil#allCombintationsIterator` and `CollectionUtil#allPermutationsIterator` for computing k-combinations and k-permutations. ### Changed diff --git a/commons/util/src/main/java/net/automatalib/common/util/array/ArrayUtil.java b/commons/util/src/main/java/net/automatalib/common/util/array/ArrayUtil.java index 0ab030c70..086d4cf33 100644 --- a/commons/util/src/main/java/net/automatalib/common/util/array/ArrayUtil.java +++ b/commons/util/src/main/java/net/automatalib/common/util/array/ArrayUtil.java @@ -163,7 +163,17 @@ public static void heapsort(int[] arr, int[] keys) { } } - private static void swap(int[] arr, int i, int j) { + /** + * Swaps the two elements at the given position in-place. + * + * @param arr + * the array + * @param i + * the first index + * @param j + * the second index + */ + public static void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; diff --git a/commons/util/src/main/java/net/automatalib/common/util/collection/AllCombinationsIterator.java b/commons/util/src/main/java/net/automatalib/common/util/collection/AllCombinationsIterator.java index 81f5e9b1e..7a149f0c0 100644 --- a/commons/util/src/main/java/net/automatalib/common/util/collection/AllCombinationsIterator.java +++ b/commons/util/src/main/java/net/automatalib/common/util/collection/AllCombinationsIterator.java @@ -15,84 +15,85 @@ */ package net.automatalib.common.util.collection; -import java.util.ArrayList; -import java.util.Iterator; +import java.util.AbstractList; +import java.util.Collection; import java.util.List; -import java.util.NoSuchElementException; /** - * An iterator that iterates over the cartesian product of its given source domains. Each intermediate combination of - * elements is computed lazily. - *

- * Note: Subsequent calls to the {@link #next()} method return a reference to the same list, and only update the - * contents of the list. If you plan to reuse intermediate results, you'll need to explicitly copy them. + * Iterator for computing all k-combinations of a given collection. Implementation is based on https://hmkcode.com/calculate-find-all-possible-combinations-of-an-array-using-java/. * * @param - * type of elements + * element type */ -final class AllCombinationsIterator implements Iterator> { +final class AllCombinationsIterator extends AbstractSimplifiedIterator> { - private final Iterable[] iterables; - private final Iterator[] iterators; - private final List current; - private boolean first = true; - private boolean empty; + private final int[] pointers; + private final int n; + private final int k; - @SuppressWarnings("unchecked") - @SafeVarargs - AllCombinationsIterator(Iterable... iterables) { - this.iterables = iterables; - this.iterators = new Iterator[iterables.length]; - this.current = new ArrayList<>(iterables.length); - for (int i = 0; i < iterators.length; i++) { - Iterator it = iterables[i].iterator(); - if (!it.hasNext()) { - empty = true; - break; - } - this.iterators[i] = it; - this.current.add(it.next()); - } - } + private int r; // index for combination array + private int i; // index for elements array - @Override - public boolean hasNext() { - if (empty) { - return false; + AllCombinationsIterator(Collection elements, int k) { + if (k < 0 || k > elements.size()) { + throw new IllegalArgumentException("k is not within its expected bounds of 0 and " + elements.size()); } - for (Iterator it : iterators) { - if (it.hasNext()) { - return true; - } - } + this.n = elements.size(); + this.k = k; + + // we only read this array + @SuppressWarnings({"unchecked", "PMD.ClassCastExceptionWithToArray"}) + final T[] pool = (T[]) elements.toArray(); + this.pointers = new int[k]; - return first; + // always use same instance which is modified in-place + super.nextValue = new MappedList<>(pool, this.pointers, this.k); } @Override - public List next() { - if (empty) { - throw new NoSuchElementException(); - } else if (first) { - first = false; - return current; + protected boolean calculateNext() { + while (r >= 0) { + if (i <= n + r - k) { // forward step if i < (n + (r-K)) + pointers[r] = i; + if (r == k - 1) { // if combination array is full print and increment i; + i++; + return true; + } else { // if combination is not full yet, select next element + i = pointers[r] + 1; + r++; + } + } else { // backward step + r--; + if (r >= 0) { + i = pointers[r] + 1; + } + } } + return false; + } - for (int i = 0; i < iterators.length; i++) { - Iterator it = iterators[i]; + static class MappedList extends AbstractList { - if (iterators[i].hasNext()) { - current.set(i, it.next()); - return current; - } + private final T[] pool; + private final int[] pointers; + private final int k; - it = iterables[i].iterator(); - iterators[i] = it; - current.set(i, it.next()); + MappedList(T[] pool, int[] pointers, int k) { + this.pool = pool; + this.pointers = pointers; + this.k = k; } - throw new NoSuchElementException(); - } + @Override + public T get(int index) { + return pool[pointers[index]]; + } + @Override + public int size() { + return k; + } + } } diff --git a/commons/util/src/main/java/net/automatalib/common/util/collection/AllPermutationsIterator.java b/commons/util/src/main/java/net/automatalib/common/util/collection/AllPermutationsIterator.java new file mode 100644 index 000000000..b322bd421 --- /dev/null +++ b/commons/util/src/main/java/net/automatalib/common/util/collection/AllPermutationsIterator.java @@ -0,0 +1,88 @@ +/* Copyright (C) 2013-2025 TU Dortmund University + * This file is part of AutomataLib . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.automatalib.common.util.collection; + +import java.util.Collection; +import java.util.List; + +import net.automatalib.common.util.array.ArrayUtil; +import net.automatalib.common.util.collection.AllCombinationsIterator.MappedList; + +/** + * Iterator for computing all k-permutations of a given collection. Implementation is based on itertools.permutations. + * + * @param + * element type + */ +final class AllPermutationsIterator extends AbstractSimplifiedIterator> { + + private final int n; + private final int[] indices; + private final int[] cycles; + private final int r; + + private boolean initial; + + AllPermutationsIterator(Collection elements, int k) { + if (k < 0 || k > elements.size()) { + throw new IllegalArgumentException("k is not within its expected bounds of 0 and " + elements.size()); + } + + this.n = elements.size(); + this.r = k; + + // we only read this array + @SuppressWarnings({"unchecked", "PMD.ClassCastExceptionWithToArray"}) + final T[] pool = (T[]) elements.toArray(); + this.indices = new int[this.n]; + this.cycles = new int[k]; + + for (int j = 0; j < n; j++) { + indices[j] = j; + if (j < k) { + cycles[j] = n - j; + } + } + + // always use same instance which is modified in-place + super.nextValue = new MappedList<>(pool, this.indices, k); + } + + @Override + protected boolean calculateNext() { + if (!initial) { // the first element is the unaltered one + initial = true; + return true; + } + + for (int i = r - 1; i >= 0; i--) { + cycles[i] -= 1; + if (cycles[i] == 0) { + int old = indices[i]; + System.arraycopy(indices, i + 1, indices, i, n - i - 1); + indices[n - 1] = old; + cycles[i] = n - i; + } else { + int j = cycles[i]; + ArrayUtil.swap(indices, i, n - j); + return true; + } + } + return false; + } + +} diff --git a/commons/util/src/main/java/net/automatalib/common/util/collection/CartesianProductIterator.java b/commons/util/src/main/java/net/automatalib/common/util/collection/CartesianProductIterator.java new file mode 100644 index 000000000..efb45b94f --- /dev/null +++ b/commons/util/src/main/java/net/automatalib/common/util/collection/CartesianProductIterator.java @@ -0,0 +1,100 @@ +/* Copyright (C) 2013-2025 TU Dortmund University + * This file is part of AutomataLib . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.automatalib.common.util.collection; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * An iterator that iterates over the cartesian product of its given source domains. Each intermediate combination of + * elements is computed lazily. + *

+ * Note: Subsequent calls to the {@link #next()} method return a reference to the same list, and only update the + * contents of the list. If you plan to reuse intermediate results, you'll need to explicitly copy them. + * + * @param + * type of elements + */ +final class CartesianProductIterator implements Iterator> { + + private final Iterable[] iterables; + private final Iterator[] iterators; + private final List current; + private boolean first; + private boolean empty; + + @SuppressWarnings("unchecked") + @SafeVarargs + CartesianProductIterator(Iterable... iterables) { + this.iterables = iterables; + this.iterators = new Iterator[iterables.length]; + this.current = new ArrayList<>(iterables.length); + this.first = true; + + for (int i = 0; i < iterators.length; i++) { + Iterator it = iterables[i].iterator(); + if (!it.hasNext()) { + empty = true; + break; + } + this.iterators[i] = it; + this.current.add(it.next()); + } + } + + @Override + public boolean hasNext() { + if (empty) { + return false; + } + + for (Iterator it : iterators) { + if (it.hasNext()) { + return true; + } + } + + return first; + } + + @Override + public List next() { + if (empty) { + throw new NoSuchElementException(); + } else if (first) { + first = false; + return current; + } + + for (int i = 0; i < iterators.length; i++) { + Iterator it = iterators[i]; + + if (iterators[i].hasNext()) { + current.set(i, it.next()); + return current; + } + + it = iterables[i].iterator(); + iterators[i] = it; + current.set(i, it.next()); + } + + throw new NoSuchElementException(); + } + +} diff --git a/commons/util/src/main/java/net/automatalib/common/util/collection/CollectionUtil.java b/commons/util/src/main/java/net/automatalib/common/util/collection/CollectionUtil.java index 892ed103f..8e9b9af44 100644 --- a/commons/util/src/main/java/net/automatalib/common/util/collection/CollectionUtil.java +++ b/commons/util/src/main/java/net/automatalib/common/util/collection/CollectionUtil.java @@ -55,6 +55,67 @@ public static List randomAccessList(Collection coll) { return new ArrayList<>(coll); } + /** + * Return {@code k}-length subsequences of elements from the input collection. + *

+ * Note: + * Elements are treated as unique based on their position, not on their value. If the input elements are unique, + * there will be no repeated values within each combination. Subsequent calls to the returned iterator's + * {@link Iterator#next() next()} method return a reference to the same list, and only update the contents of the + * list. If you plan to reuse intermediate results, you'll need to explicitly copy them. + * + * @param elements + * the collection for the source elements + * @param k + * the length of the (partial) combination + * @param + * type of elements + * + * @return an iterator iterating over all k-combinations + */ + public static Iterator> allCombintationsIterator(Collection elements, int k) { + return new AllCombinationsIterator<>(elements, k); + } + + /** + * Convenience method for {@link #allPermutationsIterator(Collection, int)} which uses the number of elements as + * {@code k}. + * + * @param elements + * the collection for the source elements + * @param + * type of elements + * + * @return an iterator iterating over all k-permutations + * + * @see #allPermutationsIterator(Collection, int) + */ + public static Iterator> allPermutationsIterator(Collection elements) { + return allPermutationsIterator(elements, elements.size()); + } + + /** + * Return {@code k}-length permutations of elements from the input collection. + *

+ * Note: + * Elements are treated as unique based on their position, not on their value. If the input elements are unique, + * there will be no repeated values within each permutation. Subsequent calls to the returned iterator's + * {@link Iterator#next() next()} method return a reference to the same list, and only update the contents of the + * list. If you plan to reuse intermediate results, you'll need to explicitly copy them. + * + * @param elements + * the collection for the source elements + * @param k + * the length of the (partial) permutation + * @param + * type of elements + * + * @return an iterator iterating over all k-permutations + */ + public static Iterator> allPermutationsIterator(Collection elements, int k) { + return new AllPermutationsIterator<>(elements, k); + } + /** * Returns a (mutable) view on the given collection that transforms its elements as specified by the given mapping. * diff --git a/commons/util/src/main/java/net/automatalib/common/util/collection/IterableUtil.java b/commons/util/src/main/java/net/automatalib/common/util/collection/IterableUtil.java index 5a7d93d30..23380fa78 100644 --- a/commons/util/src/main/java/net/automatalib/common/util/collection/IterableUtil.java +++ b/commons/util/src/main/java/net/automatalib/common/util/collection/IterableUtil.java @@ -31,6 +31,22 @@ private IterableUtil() { // prevent instantiation } + /** + * Convenience method for {@link #allTuples(Iterable, int, int)} that uses {@code length} for both {@code minLength} + * and {@code maxLength}. + * + * @param domain + * the iterables for the source domain + * @param length + * the minimal length of the tuple + * @param + * type of elements + * + * @return an iterator that iterates over all tuples of the given source domain whose length (dimension) is the + * given parameter + * + * @see #allTuples(Iterable, int, int) + */ public static Iterable> allTuples(Iterable domain, int length) { return allTuples(domain, length, length); } @@ -44,7 +60,7 @@ public static Iterable> allTuples(Iterable domain, int * you'll need to explicitly copy them. * * @param domain - * the iterables for the source domains + * the iterables for the source domain * @param minLength * the minimal length of the tuple * @param maxLength @@ -90,7 +106,7 @@ public static Iterable> cartesianProduct(Iterable... iterables) { return Collections.singletonList(Collections.emptyList()); } - return () -> new AllCombinationsIterator<>(iterables); + return () -> new CartesianProductIterator<>(iterables); } /** diff --git a/commons/util/src/test/java/net/automatalib/common/util/collection/CollectionUtilTest.java b/commons/util/src/test/java/net/automatalib/common/util/collection/CollectionUtilTest.java index 7bccee55c..753fa00ec 100644 --- a/commons/util/src/test/java/net/automatalib/common/util/collection/CollectionUtilTest.java +++ b/commons/util/src/test/java/net/automatalib/common/util/collection/CollectionUtilTest.java @@ -15,7 +15,12 @@ */ package net.automatalib.common.util.collection; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; +import java.util.Iterator; +import java.util.List; import java.util.Set; import org.testng.Assert; @@ -23,6 +28,8 @@ public class CollectionUtilTest { + private static final List COLLECTION = Arrays.asList('A', 'B', 'C', 'D'); + @Test public void testAdd() { final Set set = new HashSet<>(); @@ -33,4 +40,158 @@ public void testAdd() { Assert.assertFalse(CollectionUtil.add(set, elements.iterator())); Assert.assertEquals(set, elements); } + + @Test + public void testIllegalPermutations() { + Assert.assertThrows(IllegalArgumentException.class, + () -> CollectionUtil.allPermutationsIterator(COLLECTION, -1)); + Assert.assertThrows(IllegalArgumentException.class, + () -> CollectionUtil.allPermutationsIterator(COLLECTION, 5)); + } + + @Test + public void testAllTwoPermutations() { + final int k = 2; + final Iterator> lists = CollectionUtil.allPermutationsIterator(COLLECTION, k); + final List tuples = new ArrayList<>(); + + while (lists.hasNext()) { + StringBuilder sb = new StringBuilder(k); + lists.next().forEach(sb::append); + Assert.assertTrue(tuples.add(sb.toString())); + } + + Assert.assertEquals(tuples, + Arrays.asList("AB", "AC", "AD", "BA", "BC", "BD", "CA", "CB", "CD", "DA", "DB", "DC")); + } + + @Test + public void testAllThreePermutations() { + final int k = 3; + final Iterator> lists = CollectionUtil.allPermutationsIterator(COLLECTION, k); + final List tuples = new ArrayList<>(); + + while (lists.hasNext()) { + StringBuilder sb = new StringBuilder(k); + lists.next().forEach(sb::append); + Assert.assertTrue(tuples.add(sb.toString())); + } + + Assert.assertEquals(tuples, + Arrays.asList("ABC", + "ABD", + "ACB", + "ACD", + "ADB", + "ADC", + "BAC", + "BAD", + "BCA", + "BCD", + "BDA", + "BDC", + "CAB", + "CAD", + "CBA", + "CBD", + "CDA", + "CDB", + "DAB", + "DAC", + "DBA", + "DBC", + "DCA", + "DCB")); + } + + @Test + public void testAllFourPermutations() { + final int n = COLLECTION.size(); + final Iterator> lists = CollectionUtil.allPermutationsIterator(COLLECTION); + final List tuples = new ArrayList<>(); + + while (lists.hasNext()) { + StringBuilder sb = new StringBuilder(n); + lists.next().forEach(sb::append); + Assert.assertTrue(tuples.add(sb.toString())); + } + + final List expected = new ArrayList<>(); + // brute-force 4 dimensional tuples + for (int i1 = 0; i1 < n; i1++) { + for (int i2 = 0; i2 < n; i2++) { + if (i1 != i2) { + for (int i3 = 0; i3 < n; i3++) { + if (i3 != i1 && i3 != i2) { + for (int i4 = 0; i4 < n; i4++) { + if (i4 != i1 && i4 != i2 && i4 != i3) { + final StringBuilder sb = new StringBuilder(); + sb.append(COLLECTION.get(i1)) + .append(COLLECTION.get(i2)) + .append(COLLECTION.get(i3)) + .append(COLLECTION.get(i4)); + expected.add(sb.toString()); + } + } + } + } + } + } + } + + Assert.assertEquals(tuples, expected); + } + + @Test + public void testIllegalCombinations() { + Assert.assertThrows(IllegalArgumentException.class, + () -> CollectionUtil.allCombintationsIterator(COLLECTION, -1)); + Assert.assertThrows(IllegalArgumentException.class, + () -> CollectionUtil.allCombintationsIterator(COLLECTION, 5)); + } + + @Test + public void testAllTwoCombinations() { + final int k = 2; + final Iterator> lists = CollectionUtil.allCombintationsIterator(COLLECTION, k); + final List tuples = new ArrayList<>(); + + while (lists.hasNext()) { + StringBuilder sb = new StringBuilder(k); + lists.next().forEach(sb::append); + Assert.assertTrue(tuples.add(sb.toString())); + } + + Assert.assertEquals(tuples, Arrays.asList("AB", "AC", "AD", "BC", "BD", "CD")); + } + + @Test + public void testAllThreeCombinations() { + final int k = 3; + final Iterator> lists = CollectionUtil.allCombintationsIterator(COLLECTION, k); + final List tuples = new ArrayList<>(); + + while (lists.hasNext()) { + StringBuilder sb = new StringBuilder(k); + lists.next().forEach(sb::append); + Assert.assertTrue(tuples.add(sb.toString())); + } + + Assert.assertEquals(tuples, Arrays.asList("ABC", "ABD", "ACD", "BCD")); + } + + @Test + public void testAllFourCombinations() { + final int k = 4; + final Iterator> lists = CollectionUtil.allCombintationsIterator(COLLECTION, k); + final List tuples = new ArrayList<>(); + + while (lists.hasNext()) { + StringBuilder sb = new StringBuilder(k); + lists.next().forEach(sb::append); + Assert.assertTrue(tuples.add(sb.toString())); + } + + Assert.assertEquals(tuples, Collections.singleton("ABCD")); + } } diff --git a/commons/util/src/test/java/net/automatalib/common/util/collection/IteratableUtilTest.java b/commons/util/src/test/java/net/automatalib/common/util/collection/IteratableUtilTest.java index a8c7d6c13..073f53fa3 100644 --- a/commons/util/src/test/java/net/automatalib/common/util/collection/IteratableUtilTest.java +++ b/commons/util/src/test/java/net/automatalib/common/util/collection/IteratableUtilTest.java @@ -16,7 +16,11 @@ package net.automatalib.common.util.collection; import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; import java.util.List; +import java.util.NoSuchElementException; +import java.util.Set; import org.testng.Assert; import org.testng.annotations.Test; @@ -34,4 +38,22 @@ public void testConcat() { Assert.assertEquals(concat1, Arrays.asList(1, 2, 4, 3)); Assert.assertEquals(concat2, Arrays.asList(4, 3, 1, 2)); } + + @Test + public void testExhaustiveness() { + + Set iterable = Collections.singleton(1); + Iterator> iter1 = IterableUtil.cartesianProduct(iterable).iterator(); + Iterator> iter2 = IterableUtil.allTuples(iterable, 1, 1).iterator(); + + // consume iterators + IteratorUtil.size(iter1); + IteratorUtil.size(iter2); + + Assert.assertFalse(iter1.hasNext()); + Assert.assertThrows(NoSuchElementException.class, iter1::next); + + Assert.assertFalse(iter2.hasNext()); + Assert.assertThrows(NoSuchElementException.class, iter2::next); + } } diff --git a/commons/util/src/test/java/net/automatalib/common/util/collection/IteratorUtilTest.java b/commons/util/src/test/java/net/automatalib/common/util/collection/IteratorUtilTest.java index 9b579c8af..02e426f60 100644 --- a/commons/util/src/test/java/net/automatalib/common/util/collection/IteratorUtilTest.java +++ b/commons/util/src/test/java/net/automatalib/common/util/collection/IteratorUtilTest.java @@ -27,7 +27,7 @@ public class IteratorUtilTest { @Test public void testBatch() { - final Iterator iterator = Stream.iterate(0, i -> i+1).limit(50).iterator(); + final Iterator iterator = Stream.iterate(0, i -> i + 1).limit(50).iterator(); final Iterator> batchIterator = IteratorUtil.batch(iterator, 20); Assert.assertTrue(batchIterator.hasNext()); @@ -39,7 +39,7 @@ public void testBatch() { Assert.assertFalse(batchIterator.hasNext()); Assert.assertThrows(NoSuchElementException.class, batchIterator::next); - final Iterator iterator2 = Stream.iterate(0, i -> i+1).limit(60).iterator(); + final Iterator iterator2 = Stream.iterate(0, i -> i + 1).limit(60).iterator(); final Iterator> batchIterator2 = IteratorUtil.batch(iterator2, 20); Assert.assertTrue(batchIterator2.hasNext()); diff --git a/util/src/main/java/net/automatalib/util/automaton/conformance/KWayStateCoverTestsIterator.java b/util/src/main/java/net/automatalib/util/automaton/conformance/KWayStateCoverTestsIterator.java new file mode 100644 index 000000000..f5b2da3af --- /dev/null +++ b/util/src/main/java/net/automatalib/util/automaton/conformance/KWayStateCoverTestsIterator.java @@ -0,0 +1,248 @@ +/* Copyright (C) 2013-2025 TU Dortmund University + * This file is part of AutomataLib . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.automatalib.util.automaton.conformance; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.stream.Stream; + +import net.automatalib.automaton.DeterministicAutomaton; +import net.automatalib.common.util.HashUtil; +import net.automatalib.common.util.collection.AbstractSimplifiedIterator; +import net.automatalib.common.util.collection.CollectionUtil; +import net.automatalib.common.util.collection.IteratorUtil; +import net.automatalib.common.util.mapping.Mapping; +import net.automatalib.common.util.mapping.Mappings; +import net.automatalib.common.util.random.RandomUtil; +import net.automatalib.util.automaton.cover.Covers; +import net.automatalib.word.Word; +import net.automatalib.word.WordBuilder; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A randomized state cover test generator based on the concepts of mutation testing as described in the paper Learning from Faults: Mutation Testing in Active Automata + * Learning by Bernhard K. Aichernig and Martin Tappler. + *

+ * A test case will be computed for every k-combination or k-permutation of states with additional random walk at the + * end. + *

+ * Implementation detail: Note that this test generator heavily relies on the sampling of states. If the given + * automaton has very few or very many states, the number of generated test cases may be very low or high, respectively. + * As a result, it may be advisable to {@link IteratorUtil#concat(Iterator[]) combine} this generator with other + * generators or {@link Stream#limit(long) limit} the number of generated test cases. + * + * @param + * automaton state type + * @param + * input symbol type + * @param + * automaton type + */ +public class KWayStateCoverTestsIterator> + extends AbstractSimplifiedIterator> { + + /** + * The default value of k used in the k-way combinations/permutations. + */ + public static final int DEFAULT_K = 2; + + /** + * The default length of random walks performed at the end of each combination/permutation. + */ + public static final int DEFAULT_R_WALK_LEN = 20; + + private final A automaton; + private final List alphabet; + private final Random random; + private final int randomWalkLen; + + private final Iterator> combIter; + private final Set>> cache; + private final @Nullable S initial; + private final Map>> apsp; + + /** + * Convenience constructor which uses a fresh {@code random} object. + * + * @param automaton + * the automaton for which to generate test cases + * @param inputs + * the inputs to consider for test case generation + * + * @see #KWayStateCoverTestsIterator(DeterministicAutomaton, Collection, Random) + */ + public KWayStateCoverTestsIterator(A automaton, Collection inputs) { + this(automaton, inputs, new Random()); + } + + /** + * Convenience constructor. Uses k = {@value #DEFAULT_K}, randomWalkLen = + * {@value #DEFAULT_R_WALK_LEN}, and + * method = {@link CombinationMethod#PERMUTATIONS}. + * + * @param automaton + * the automaton for which to generate test cases + * @param inputs + * the inputs to consider for test case generation + * @param random + * the random number generator to use + * + * @see #KWayStateCoverTestsIterator(DeterministicAutomaton, Collection, Random, int, int, CombinationMethod) + */ + public KWayStateCoverTestsIterator(A automaton, Collection inputs, Random random) { + this(automaton, inputs, random, DEFAULT_R_WALK_LEN, DEFAULT_K, CombinationMethod.PERMUTATIONS); + } + + /** + * Constructor. + * + * @param automaton + * the automaton for which to generate test cases + * @param inputs + * the inputs to consider for test case generation + * @param random + * the random number generator to use + * @param randomWalkLen + * length of random walks performed at the end of each combination/permutation + * @param k + * k value used for k-way combinations/permutations of states + * @param method + * the method for computing combinations + */ + public KWayStateCoverTestsIterator(A automaton, + Collection inputs, + Random random, + int randomWalkLen, + int k, + CombinationMethod method) { + this.automaton = automaton; + this.alphabet = CollectionUtil.randomAccessList(inputs); + this.random = random; + this.randomWalkLen = randomWalkLen; + + this.cache = new HashSet<>(); + this.initial = automaton.getInitialState(); + + if (this.initial == null) { + this.combIter = Collections.emptyIterator(); + this.apsp = Collections.emptyMap(); + } else { + final List states = new ArrayList<>(automaton.getStates()); + Collections.shuffle(states, random); + this.combIter = method.getCombinations(states, Math.min(k, automaton.size())); + this.apsp = new HashMap<>(HashUtil.capacity(automaton.size())); + this.apsp.put(this.initial, Covers.cover(automaton, inputs, this.initial, w -> {}, w -> {})); + } + + } + + @Override + protected boolean calculateNext() { + + while (combIter.hasNext()) { + final List comb = combIter.next(); + final Set> prefixes = new HashSet<>(HashUtil.capacity(comb.size())); + + Word path = null; + assert initial != null; + + for (S c : comb) { + Word sp = apsp.getOrDefault(initial, Mappings.nullMapping()).get(c); + if (sp != null) { + prefixes.add(sp); + if (path == null) { + path = sp; + } + } + } + + if (path == null || !cache.add(prefixes)) { + continue; + } + + final WordBuilder pathBuilder = new WordBuilder<>(path); + + /* + * in case of non-strongly connected automata test case might not be possible as a path between 2 states + * might not exist + */ + boolean possibleTestCase = true; + for (int index = 0; index < comb.size() - 1; index++) { + final Word pathBetweenStates = apsp.computeIfAbsent(comb.get(index), + k -> Covers.cover(automaton, + alphabet, + k, + w -> {}, + w -> {})) + .get(comb.get(index + 1)); + + if (pathBetweenStates == null || pathBetweenStates.isEmpty()) { + possibleTestCase = false; + break; + } + + pathBuilder.append(pathBetweenStates); + } + + if (possibleTestCase) { + pathBuilder.append(RandomUtil.sample(random, alphabet, randomWalkLen)); + super.nextValue = pathBuilder.toWord(); + return true; + } + } + + return false; + } + + /** + * The specific method for generating combinations of states during exploration. + */ + public enum CombinationMethod { + /** + * Generate all k-combinations of states. + * + * @see CollectionUtil#allCombintationsIterator(Collection, int) + */ + COMBINATIONS { + @Override + Iterator> getCombinations(List states, int k) { + return CollectionUtil.allCombintationsIterator(states, k); + } + }, + /** + * Generate all k-permutations of states. + * + * @see CollectionUtil#allPermutationsIterator(Collection, int) + */ + PERMUTATIONS { + @Override + Iterator> getCombinations(List states, int k) { + return CollectionUtil.allPermutationsIterator(states, k); + } + }; + + abstract Iterator> getCombinations(List states, int k); + } +} diff --git a/util/src/main/java/net/automatalib/util/automaton/conformance/KWayTransitionCoverTestsIterator.java b/util/src/main/java/net/automatalib/util/automaton/conformance/KWayTransitionCoverTestsIterator.java new file mode 100644 index 000000000..960cf9aba --- /dev/null +++ b/util/src/main/java/net/automatalib/util/automaton/conformance/KWayTransitionCoverTestsIterator.java @@ -0,0 +1,475 @@ +/* Copyright (C) 2013-2025 TU Dortmund University + * This file is part of AutomataLib . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.automatalib.util.automaton.conformance; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Random; +import java.util.Set; +import java.util.stream.Stream; + +import net.automatalib.automaton.DeterministicAutomaton; +import net.automatalib.common.util.HashUtil; +import net.automatalib.common.util.collection.AbstractSimplifiedIterator; +import net.automatalib.common.util.collection.AbstractTwoLevelIterator; +import net.automatalib.common.util.collection.CollectionUtil; +import net.automatalib.common.util.collection.IterableUtil; +import net.automatalib.common.util.collection.IteratorUtil; +import net.automatalib.common.util.mapping.Mapping; +import net.automatalib.common.util.random.RandomUtil; +import net.automatalib.util.automaton.cover.Covers; +import net.automatalib.word.Word; +import net.automatalib.word.WordBuilder; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A randomized transition cover test generator based on the concepts of mutation testing as described in the paper Learning from Faults: Mutation Testing in Active Automata + * Learning by Bernhard K. Aichernig and Martin Tappler. + *

+ * This iterator selects test cases based on k-way transitions coverage. It does that by generating random test words + * and finding the smallest subset with the highest coverage. In other words, this iterator generates test words by + * running random paths that cover all pairwise / k-way transitions. + *

+ * Implementation detail: Note that this test generator heavily relies on the sampling of states. If the given + * automaton has very few or very many states, the number of generated test cases may be very low or high, respectively. + * As a result, it may be advisable to {@link IteratorUtil#concat(Iterator[]) combine} this generator with other + * generators or {@link Stream#limit(long) limit} the number of generated test cases. + * + * @param + * automaton state type + * @param + * input symbol type + * @param + * automaton type + */ +public class KWayTransitionCoverTestsIterator> + implements Iterator> { + + /** + * The default value of k used in the k-way computations. + */ + public static final int DEFAULT_K = 2; + + /** + * The default for the maximum step size of {@link GenerationMethod#RANDOM randomly}-generated paths. + */ + public static final int DEFAULT_MAX_PATH_LENGTH = 50; + + /** + * The default (unlimited) threshold for the number of steps after which no more new test words will be generated. + */ + public static final int DEFAULT_MAX_NUM_STEPS = 0; + + /** + * The default number of {@link GenerationMethod#RANDOM randomly}-generated tests used to find the optimal subset. + */ + public static final int DEFAULT_NUM_GEN_PATHS = 1_000; + + /** + * The default number of steps that are appended to {@link GenerationMethod#PREFIX prefix}-generated paths. + */ + public static final int DEFAULT_R_WALK_LEN = 10; + + private final A automaton; + private final List alphabet; + private final Random random; + private final int randomWalkLen; + private final int numGeneratePaths; + private final int maxPathLen; + private final int maxNumberOfSteps; + private final int k; + private final OptimizationMetric optimizationMetric; + + private final Iterator> iterator; + + /** + * Convenience constructor which uses a fresh {@code random} object. + * + * @param automaton + * the automaton for which to generate test cases + * @param inputs + * the inputs to consider for test case generation + * + * @see #KWayTransitionCoverTestsIterator(DeterministicAutomaton, Collection, Random) + */ + public KWayTransitionCoverTestsIterator(A automaton, Collection inputs) { + this(automaton, inputs, new Random()); + } + + /** + * Convenience constructor. Uses randomWalkLen = {@value #DEFAULT_R_WALK_LEN}, numGeneratePaths = + * {@value #DEFAULT_NUM_GEN_PATHS}, maxPathLen = {@value #DEFAULT_MAX_PATH_LENGTH}, + * maxNumberOfSteps = {@value #DEFAULT_MAX_NUM_STEPS}, k = {@value DEFAULT_K}, + * optimizationMetric = {@link OptimizationMetric#STEPS STEPS}, and generationMethod = + * {@link GenerationMethod#RANDOM RANDOM}. + * + * @param automaton + * the automaton for which to generate test cases + * @param inputs + * the inputs to consider for test case generation + * @param random + * the random number generator to use + * + * @see #KWayTransitionCoverTestsIterator(DeterministicAutomaton, Collection, Random, int, int, int, int, int, + * OptimizationMetric, GenerationMethod) + */ + public KWayTransitionCoverTestsIterator(A automaton, Collection inputs, Random random) { + this(automaton, + inputs, + random, + DEFAULT_R_WALK_LEN, + DEFAULT_NUM_GEN_PATHS, + DEFAULT_MAX_PATH_LENGTH, + DEFAULT_MAX_NUM_STEPS, + DEFAULT_K, + OptimizationMetric.STEPS, + GenerationMethod.RANDOM); + } + + /** + * Constructor. + * + * @param automaton + * the automaton for which to generate test cases + * @param inputs + * the inputs to consider for test case generation + * @param random + * the random number generator to use + * @param randomWalkLen + * the number of steps that are appended to {@link GenerationMethod#PREFIX prefix}-generated paths + * @param numGeneratePaths + * number of {@link GenerationMethod#RANDOM randomly}-generated tests used to find the optimal subset + * @param maxPathLen + * the maximum step size of {@link GenerationMethod#RANDOM randomly}-generated paths + * @param maxNumberOfSteps + * threshold for the number of steps after which no more new test words will be generated (a value less than + * zero means no limit) + * @param k + * k value used for K-Way transitions, i.e.,the number of steps between the start and the end of a + * transition + * @param optimizationMetric + * the metric after which test cases are minimized + * @param generationMethod + * defines how the tests are generated + */ + public KWayTransitionCoverTestsIterator(A automaton, + Collection inputs, + Random random, + int randomWalkLen, + int numGeneratePaths, + int maxPathLen, + int maxNumberOfSteps, + int k, + OptimizationMetric optimizationMetric, + GenerationMethod generationMethod) { + this.automaton = automaton; + this.alphabet = CollectionUtil.randomAccessList(inputs); + this.random = random; + + this.randomWalkLen = randomWalkLen; + this.numGeneratePaths = numGeneratePaths; + this.maxPathLen = maxPathLen; + this.maxNumberOfSteps = maxNumberOfSteps; + this.k = Math.min(k, automaton.size()); + this.optimizationMetric = optimizationMetric; + + final S initial = automaton.getInitialState(); + + if (automaton.size() == 0 || initial == null) { + this.iterator = Collections.emptyIterator(); + } else { + this.iterator = generationMethod.getIterator(this, initial); + } + + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Word next() { + return iterator.next(); + } + + private Set> generateRandomPaths(A hypothesis, S initial) { + final Set> result = new HashSet<>(HashUtil.capacity(numGeneratePaths)); + + for (int i = 0; i < numGeneratePaths; i++) { + final int randomLength = random.nextInt(maxPathLen - k + 1) + k; // Ensuring length is at least `k` + final Word steps = Word.fromList(RandomUtil.sample(random, alphabet, randomLength)); + final Path path = createPath(hypothesis, initial, steps); + result.add(path); + } + + return result; + } + + private Path createPath(A hypothesis, S initial, Word steps) { + final Set> transitions = new HashSet<>(); + + final List<@Nullable S> prevStates = new ArrayList<>(steps.size()); + final List<@Nullable S> endStates = new ArrayList<>(steps.size()); + + S iter = initial; + + for (I i : steps) { + prevStates.add(iter); + iter = iter == null ? null : hypothesis.getSuccessor(iter, i); + endStates.add(iter); + } + + for (int i = 0; i < steps.size() - k + 1; i++) { + final @Nullable S prevState = prevStates.get(i); + final @Nullable S endState = endStates.get(i + k - 1); + final Word chunk = steps.subWord(i, i + k); + + final KWayTransition transition = new KWayTransition<>(prevState, endState, chunk); + + transitions.add(transition); + } + + return new Path<>(steps, transitions); + } + + private Iterator> generatePrefixSteps(A hypothesis, S initial) { + final List states = new ArrayList<>(hypothesis.getStates()); + Collections.reverse(states); + return new PrefixStepsIterator(states.iterator(), initial); + } + + private @Nullable Path selectOptimalPath(Set> covered, Set> paths) { + final Path max = Collections.max(paths, optimizationMetric.getPathComparator(covered)); + return sizeOfSetDifference(max.kWayTransitions, covered) > 0 ? max : null; + } + + static int sizeOfSetDifference(Set minuend, Set subtrahend) { + /* + * This method is performance-critical so we do a little bit more involved computation. + */ + final Set smaller, bigger; + if (minuend.size() < subtrahend.size()) { + smaller = minuend; + bigger = subtrahend; + } else { + smaller = subtrahend; + bigger = minuend; + } + + int size = minuend.size(); + for (T t : smaller) { + if (bigger.contains(t)) { + size--; + } + } + return size; + } + + private class GreedySetCoverIterator extends AbstractSimplifiedIterator> { + + private final S initial; + private final Set> paths; + private final Set> covered; + private final int sizeOfUniverse; + + private int stepCount; + + GreedySetCoverIterator(S initial) { + this.initial = initial; + this.paths = generateRandomPaths(automaton, initial); + this.covered = new HashSet<>(); + this.stepCount = 0; + this.sizeOfUniverse = Math.multiplyExact(automaton.getStates().size(), (int) Math.pow(alphabet.size(), k)); + } + + @Override + protected boolean calculateNext() { + while (sizeOfUniverse > covered.size() && (maxNumberOfSteps == 0 || stepCount <= maxNumberOfSteps)) { + final Path path = selectOptimalPath(covered, paths); + + if (path != null) { + covered.addAll(path.kWayTransitions); + paths.remove(path); + stepCount += path.steps.size(); + super.nextValue = path.steps; + + if (paths.isEmpty()) { + computeNewPaths(); + } + return true; + } else { + computeNewPaths(); + } + } + return false; + } + + private void computeNewPaths() { + final Iterator> prefixIterator = generatePrefixSteps(automaton, initial); + while (prefixIterator.hasNext()) { + final Word generatePrefixStep = prefixIterator.next(); + paths.add(createPath(automaton, initial, generatePrefixStep)); + } + } + } + + private class PrefixStepsIterator extends AbstractTwoLevelIterator, Word> { + + private final Mapping> accessSequences; + + PrefixStepsIterator(Iterator iterator, S initial) { + super(iterator); + this.accessSequences = Covers.cover(automaton, alphabet, initial, w -> {}, w -> {}); + } + + @Override + protected Iterator> l2Iterator(S state) { + /* + * The original code shuffles all tuples globally. Since we can't do this lazily, we approximate this + * behavior by at least shuffling the input symbols for a randomized tuple order. + */ + final List inputs = new ArrayList<>(alphabet); + Collections.shuffle(inputs, random); + final Iterable> lists = IterableUtil.allTuples(inputs, k); + return lists.iterator(); + } + + @Override + protected Word combine(S state, List steps) { + final Word prefix = accessSequences.get(state); + if (prefix == null) { + return Word.epsilon(); + } + + final WordBuilder wb = new WordBuilder<>(prefix.length() + steps.size() + randomWalkLen); + + wb.append(prefix); + wb.append(steps); + wb.append(RandomUtil.sample(random, alphabet, randomWalkLen)); + + return wb.toWord(); + } + } + + private static final class KWayTransition { + + private final @Nullable S startState; + private final @Nullable S endState; + private final Word steps; + + /** + * Since we need to compute the hash code quite often, cache it since we're immutable. + */ + private final int hashCode; + + KWayTransition(@Nullable S startState, @Nullable S endState, Word steps) { + this.startState = startState; + this.endState = endState; + this.steps = steps; + + this.hashCode = computeHashCode(startState, endState, steps); + } + + private int computeHashCode(@Nullable S startState, @Nullable S endState, Word steps) { + final int prime = 31; + int result = 1; + result = prime * result + Objects.hashCode(startState); + result = prime * result + Objects.hashCode(endState); + result = prime * result + Objects.hashCode(steps); + return result; + } + + @Override + public boolean equals(@Nullable Object o) { + return o == this || o instanceof KWayTransition that && Objects.equals(startState, that.startState) && + Objects.equals(endState, that.endState) && steps.equals(that.steps); + } + + @Override + public int hashCode() { + return hashCode; + } + } + + private record Path(Word steps, Set> kWayTransitions) {} + + /** + * Method by which the prefixes of test words should be generated. + */ + public enum GenerationMethod { + /** + * Generate prefixes randomly. + */ + RANDOM { + @Override + > Iterator> getIterator( + KWayTransitionCoverTestsIterator self, + S initial) { + return self.new GreedySetCoverIterator(initial); + } + }, + /** + * Generate prefixes based on access sequences. + */ + PREFIX { + @Override + > Iterator> getIterator( + KWayTransitionCoverTestsIterator self, + S initial) { + return self.generatePrefixSteps(self.automaton, initial); + } + }; + + abstract > Iterator> getIterator( + KWayTransitionCoverTestsIterator self, + S initial); + } + + /** + * The metric by which to optimize path selection. + */ + public enum OptimizationMetric { + /** + * Selects the paths maximum coverage per step, thus reducing the number of total steps. + */ + STEPS { + @Override + Comparator> getPathComparator(Set> covered) { + return Comparator.comparingDouble(p -> ((double) sizeOfSetDifference(p.kWayTransitions, covered)) / + p.steps.size()); + } + }, + /** + * Selects the paths with maximum coverage, thus reducing number of test words. + */ + QUERIES { + @Override + Comparator> getPathComparator(Set> covered) { + return Comparator.comparingInt(p -> sizeOfSetDifference(p.kWayTransitions, covered)); + } + }; + + abstract Comparator> getPathComparator(Set> covered); + } +} diff --git a/util/src/main/java/net/automatalib/util/automaton/cover/Covers.java b/util/src/main/java/net/automatalib/util/automaton/cover/Covers.java index 26653898e..e1f870065 100644 --- a/util/src/main/java/net/automatalib/util/automaton/cover/Covers.java +++ b/util/src/main/java/net/automatalib/util/automaton/cover/Covers.java @@ -26,8 +26,10 @@ import net.automatalib.automaton.DeterministicAutomaton; import net.automatalib.common.util.HashUtil; +import net.automatalib.common.util.mapping.Mapping; import net.automatalib.common.util.mapping.MutableMapping; import net.automatalib.word.Word; +import org.checkerframework.checker.nullness.qual.Nullable; public final class Covers { @@ -48,13 +50,15 @@ private Covers() {} * the set of input symbols allowed in the cover sequences * @param states * the collection in which the sequences will be stored + * @param + * automaton state type * @param * input symbol type */ - public static void stateCover(DeterministicAutomaton automaton, - Collection inputs, - Collection> states) { - cover(automaton, inputs, states::add, w -> {}); + public static void stateCover(DeterministicAutomaton automaton, + Collection inputs, + Collection> states) { + cover(automaton, inputs, automaton.getInitialState(), states::add, w -> {}); } /** @@ -93,13 +97,15 @@ public static Iterator> stateCoverIterator(DeterministicAutomaton + * automaton state type * @param * input symbol type */ - public static void transitionCover(DeterministicAutomaton automaton, - Collection inputs, - Collection> transitions) { - cover(automaton, inputs, w -> {}, transitions::add); + public static void transitionCover(DeterministicAutomaton automaton, + Collection inputs, + Collection> transitions) { + cover(automaton, inputs, automaton.getInitialState(), w -> {}, transitions::add); } /** @@ -133,16 +139,18 @@ public static Iterator> transitionCoverIterator(DeterministicAutomat * the set of input symbols allowed in the cover sequences * @param cover * the collection in which the sequences will be stored + * @param + * automaton state type * @param * input symbol type * * @see #stateCover(DeterministicAutomaton, Collection, Collection) * @see #transitionCover(DeterministicAutomaton, Collection, Collection) */ - public static void structuralCover(DeterministicAutomaton automaton, - Collection inputs, - Collection> cover) { - cover(automaton, inputs, cover::add, cover::add); + public static void structuralCover(DeterministicAutomaton automaton, + Collection inputs, + Collection> cover) { + cover(automaton, inputs, automaton.getInitialState(), cover::add, cover::add); } /** @@ -156,35 +164,67 @@ public static void structuralCover(DeterministicAutomaton automaton * the collection in which the state cover sequences will be stored * @param transitions * the collection in which the transition cover sequences will be stored + * @param + * automaton state type * @param * input symbol type * * @see #stateCover(DeterministicAutomaton, Collection, Collection) * @see #transitionCover(DeterministicAutomaton, Collection, Collection) */ - public static void cover(DeterministicAutomaton automaton, - Collection inputs, - Collection> states, - Collection> transitions) { - cover(automaton, inputs, states::add, transitions::add); + public static void cover(DeterministicAutomaton automaton, + Collection inputs, + Collection> states, + Collection> transitions) { + cover(automaton, inputs, automaton.getInitialState(), states::add, transitions::add); } - private static void cover(DeterministicAutomaton automaton, - Collection inputs, - Consumer> states, - Consumer> transitions) { + /** + * Computes the state and transition covers for a given automaton beginning in the given state. + *

+ * A state cover is a set C of input sequences, such that for each state s of an automaton, there + * exists an input sequence in C that transitions the automaton from its initial state to state s. + *

+ * A transition cover is a set C of input sequences, such that for each state s and each input symbol + * i of an automaton, there exists an input sequence in C that starts from the initial state of the + * automaton and ends with the transition that applies i to state s. + *

+ * Note: if restrictions on the {@code inputs} parameter do not allow to reach certain states or transitions, the + * computed covers are not complete. + * + * @param automaton + * the automaton for which the covers should be computed + * @param inputs + * the set of input symbols allowed in the cover sequences + * @param start + * the state from which to begin the computation of overs + * @param states + * a consumer that accepts the state cover sequences + * @param transitions + * a consumer that accepts the transition cover sequences + * @param + * automaton state type + * @param + * input symbol type + * + * @return a mapping from automaton states to their access sequences + */ + public static Mapping> cover(DeterministicAutomaton automaton, + Collection inputs, + @Nullable S start, + Consumer> states, + Consumer> transitions) { - S init = automaton.getInitialState(); + MutableMapping> reach = automaton.createStaticStateMapping(); - if (init == null) { - return; + if (start == null) { + return reach; } - MutableMapping> reach = automaton.createStaticStateMapping(); - reach.put(init, Word.epsilon()); + reach.put(start, Word.epsilon()); Queue bfsQueue = new ArrayDeque<>(); - bfsQueue.add(init); + bfsQueue.add(start); states.accept(Word.epsilon()); @@ -210,6 +250,8 @@ private static void cover(DeterministicAutomaton automaton, transitions.accept(succAs); } } + + return reach; } /** diff --git a/util/src/main/java/net/automatalib/util/automaton/procedural/SPAs.java b/util/src/main/java/net/automatalib/util/automaton/procedural/SPAs.java index 402e2b481..38cddbdce 100644 --- a/util/src/main/java/net/automatalib/util/automaton/procedural/SPAs.java +++ b/util/src/main/java/net/automatalib/util/automaton/procedural/SPAs.java @@ -277,7 +277,7 @@ private static List> exploreAccessSequences(DFA dfa, final List> result = new ArrayList<>(inputs.size()); final UniversalGraph, ?, ?> tgv = dfa.transitionGraphView(inputs); - final APSPResult> apsp = Graphs.findAPSP(tgv, edge -> 0F); + final APSPResult> apsp = Graphs.findAPSP(tgv); final List acceptingStates = new ArrayList<>(dfa.size()); for (S s : dfa) { diff --git a/util/src/main/java/net/automatalib/util/graph/Graphs.java b/util/src/main/java/net/automatalib/util/graph/Graphs.java index 663b495a5..bc3dd8eb5 100644 --- a/util/src/main/java/net/automatalib/util/graph/Graphs.java +++ b/util/src/main/java/net/automatalib/util/graph/Graphs.java @@ -94,6 +94,25 @@ public static List toNodeList(List edgeList, Graph graph, N i return result; } + /** + * Computes the shortest paths between all pairs of nodes in a graph, using the Floyd-Warshall dynamic programming + * algorithm. This method assumes no edge weights. + * + * @param graph + * the graph + * @param + * node type + * @param + * edge type + * + * @return the all pairs shortest paths result + * + * @see FloydWarshallAPSP + */ + public static APSPResult findAPSP(Graph graph) { + return findAPSP(graph, e -> 0F); + } + /** * Computes the shortest paths between all pairs of nodes in a graph, using the Floyd-Warshall dynamic programming * algorithm. Note that the result is only correct if the graph contains no cycles with negative edge weight sums. diff --git a/util/src/test/java/net/automatalib/util/automaton/conformance/KWayStateCoverTestsIteratorTest.java b/util/src/test/java/net/automatalib/util/automaton/conformance/KWayStateCoverTestsIteratorTest.java new file mode 100644 index 000000000..47ac1fcb9 --- /dev/null +++ b/util/src/test/java/net/automatalib/util/automaton/conformance/KWayStateCoverTestsIteratorTest.java @@ -0,0 +1,194 @@ +/* Copyright (C) 2013-2025 TU Dortmund University + * This file is part of AutomataLib . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.automatalib.util.automaton.conformance; + +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import net.automatalib.alphabet.Alphabet; +import net.automatalib.alphabet.impl.Alphabets; +import net.automatalib.automaton.UniversalDeterministicAutomaton; +import net.automatalib.automaton.fsa.impl.CompactDFA; +import net.automatalib.automaton.transducer.impl.CompactMealy; +import net.automatalib.common.util.HashUtil; +import net.automatalib.common.util.collection.IteratorUtil; +import net.automatalib.util.automaton.builder.AutomatonBuilders; +import net.automatalib.util.automaton.conformance.KWayStateCoverTestsIterator.CombinationMethod; +import net.automatalib.util.automaton.random.RandomAutomata; +import net.automatalib.word.Word; +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +@Test +public class KWayStateCoverTestsIteratorTest { + + private static final Alphabet ALPHABET = Alphabets.characters('a', 'c'); + + @DataProvider(name = "methods") + public static Object[][] getMethods() { + return new Object[][] {{CombinationMethod.COMBINATIONS}, {CombinationMethod.PERMUTATIONS}}; + } + + @Test + public void testEmptyAutomaton() { + final CompactDFA dfa = new CompactDFA<>(ALPHABET); + final List> tests = IteratorUtil.list(new KWayStateCoverTestsIterator<>(dfa, ALPHABET)); + + Assert.assertTrue(tests.isEmpty()); + } + + @Test(dataProvider = "methods") + public void testSingleStateAutomaton(CombinationMethod method) { + final CompactDFA dfa = new CompactDFA<>(ALPHABET); + + final int initial = dfa.addIntInitialState(); + for (int i = 0; i < ALPHABET.size(); i++) { + dfa.setTransition(initial, i, initial); + } + + final int length = KWayStateCoverTestsIterator.DEFAULT_R_WALK_LEN; + final List> tests = IteratorUtil.list(new KWayStateCoverTestsIterator<>(dfa, + ALPHABET, + new Random(42), + length, + KWayStateCoverTestsIterator.DEFAULT_K, + method)); + + // check that the first 'length' queries are the randomly generated ones. + Assert.assertEquals(tests.size(), 1); + Assert.assertEquals(tests.get(0).length(), length); + } + + @Test(dataProvider = "methods") + public void testNoInitialStateAutomaton(CombinationMethod method) { + final CompactDFA dfa = RandomAutomata.randomDFA(new Random(42), 10, ALPHABET); + + dfa.setInitialState(null); + final List> tests = IteratorUtil.list(new KWayStateCoverTestsIterator<>(dfa, + ALPHABET, + new Random(42), + KWayStateCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayStateCoverTestsIterator.DEFAULT_K, + method)); + + Assert.assertTrue(tests.isEmpty()); + } + + @Test(dataProvider = "methods") + public void testPartialAutomaton(CombinationMethod method) { + // @formatter:off + final CompactDFA dfa = AutomatonBuilders.newDFA(ALPHABET) + .from("s0").on('a').to("s1") + .from("s1").on('b').to("s2") + .withInitial("s0") + .withAccepting("s2", "s3") + .create(); + // @formatter:on + + final List> tests = IteratorUtil.list(new KWayStateCoverTestsIterator<>(dfa, + ALPHABET, + new Random(42), + KWayStateCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayStateCoverTestsIterator.DEFAULT_K, + method)); + verifyEachStateVisited(dfa, tests, Set.of(0, 1, 2)); + } + + @Test(dataProvider = "methods") + public void testRandomAutomaton(CombinationMethod method) { + final CompactMealy mealy = + RandomAutomata.randomMealy(new Random(42), 10, ALPHABET, Alphabets.integers(0, 2)); + + final List> tests = IteratorUtil.list(new KWayStateCoverTestsIterator<>(mealy, + ALPHABET, + new Random(42), + KWayStateCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayStateCoverTestsIterator.DEFAULT_K, + method)); + + verifyEachStateVisited(mealy, tests); + } + + @Test(dataProvider = "methods") + public void testKeylockAutomaton(CombinationMethod method) { + final CompactDFA mealy = generateKeylockAutomaton(ALPHABET); + + final List> tests = IteratorUtil.list(new KWayStateCoverTestsIterator<>(mealy, + ALPHABET, + new Random(42), + KWayStateCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayStateCoverTestsIterator.DEFAULT_K, + method)); + + verifyEachStateVisited(mealy, tests); + } + + static void verifyEachStateVisited(UniversalDeterministicAutomaton automaton, + List> tests) { + verifyEachStateVisited(automaton, tests, new HashSet<>(automaton.getStates())); + } + + static void verifyEachStateVisited(UniversalDeterministicAutomaton automaton, + List> tests, + Set expected) { + final Set visited = new HashSet<>(HashUtil.capacity(automaton.size())); + + final S init = automaton.getInitialState(); + Assert.assertNotNull(init); + + visited.add(init); + + for (Word t : tests) { + S iter = init; + for (I i : t) { + S succ = automaton.getSuccessor(iter, i); + if (succ == null) { + break; + } + visited.add(succ); + iter = succ; + } + } + + Assert.assertEquals(visited, expected); + } + + static CompactDFA generateKeylockAutomaton(Alphabet alphabet) { + + final CompactDFA result = new CompactDFA<>(alphabet); + + int iter = result.addIntInitialState(); + + for (int i = 0; i < 10 - 1; i++) { + for (int j = 1; j < alphabet.size(); j++) { + result.setTransition(iter, j, iter); + } + int next = result.addIntState(); + result.setTransition(iter, 0, next); + iter = next; + } + + result.setAccepting(iter, true); + for (int i = 0; i < alphabet.size(); i++) { + result.setTransition(iter, i, iter); + } + + return result; + } +} diff --git a/util/src/test/java/net/automatalib/util/automaton/conformance/KWayTransitionCoverTestsIteratorTest.java b/util/src/test/java/net/automatalib/util/automaton/conformance/KWayTransitionCoverTestsIteratorTest.java new file mode 100644 index 000000000..0e6e76a59 --- /dev/null +++ b/util/src/test/java/net/automatalib/util/automaton/conformance/KWayTransitionCoverTestsIteratorTest.java @@ -0,0 +1,248 @@ +/* Copyright (C) 2013-2025 TU Dortmund University + * This file is part of AutomataLib . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.automatalib.util.automaton.conformance; + +import java.util.List; +import java.util.Random; +import java.util.Set; + +import net.automatalib.alphabet.Alphabet; +import net.automatalib.alphabet.impl.Alphabets; +import net.automatalib.automaton.fsa.impl.CompactDFA; +import net.automatalib.automaton.transducer.impl.CompactMealy; +import net.automatalib.common.util.collection.IteratorUtil; +import net.automatalib.util.automaton.builder.AutomatonBuilders; +import net.automatalib.util.automaton.conformance.KWayTransitionCoverTestsIterator.GenerationMethod; +import net.automatalib.util.automaton.conformance.KWayTransitionCoverTestsIterator.OptimizationMetric; +import net.automatalib.util.automaton.random.RandomAutomata; +import net.automatalib.word.Word; +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +@Test +public class KWayTransitionCoverTestsIteratorTest { + + private static final Alphabet ALPHABET = Alphabets.characters('a', 'c'); + private static final int AUTOMATON_SIZE = 10; + + @DataProvider(name = "config") + public static Object[][] getConfig() { + final Object[][] result = new Object[GenerationMethod.values().length * OptimizationMetric.values().length][]; + int idx = 0; + + for (GenerationMethod method : GenerationMethod.values()) { + for (OptimizationMetric metric : OptimizationMetric.values()) { + result[idx++] = new Object[] {method, metric}; + } + } + + return result; + } + + @Test + public void testSizeOfSetDifference() { + final Set s1 = Set.of('A', 'B', 'C'); + final Set s2 = Set.of('A', 'B'); + final Set s3 = Set.of('A'); + final Set s4 = Set.of('X', 'Y', 'Z'); + final Set s5 = Set.of('A', 'X'); + + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s1, s2), 1); + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s2, s1), 0); + + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s1, s3), 2); + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s3, s1), 0); + + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s1, s4), 3); + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s4, s1), 3); + + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s1, s5), 2); + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s5, s1), 1); + + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s4, s5), 2); + Assert.assertEquals(KWayTransitionCoverTestsIterator.sizeOfSetDifference(s5, s4), 1); + } + + @Test + public void testEmptyAutomaton() { + final CompactDFA dfa = new CompactDFA<>(ALPHABET); + final List> tests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(dfa, ALPHABET)); + + Assert.assertTrue(tests.isEmpty()); + } + + @Test(dataProvider = "config") + public void testSingleStateAutomaton(GenerationMethod method, OptimizationMetric metric) { + final CompactDFA dfa = new CompactDFA<>(ALPHABET); + + final int initial = dfa.addIntInitialState(); + for (int i = 0; i < ALPHABET.size(); i++) { + dfa.setTransition(initial, i, initial); + } + + final List> tests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(dfa, + ALPHABET, + new Random(42), + KWayTransitionCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_PATH_LENGTH, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_NUM_STEPS, + KWayTransitionCoverTestsIterator.DEFAULT_K, + metric, + method)); + KWayStateCoverTestsIteratorTest.verifyEachStateVisited(dfa, tests); + } + + @Test(dataProvider = "config") + public void testNoInitialStateAutomaton(GenerationMethod method, OptimizationMetric metric) { + final CompactDFA dfa = RandomAutomata.randomDFA(new Random(42), AUTOMATON_SIZE, ALPHABET); + + dfa.setInitialState(null); + final List> tests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(dfa, + ALPHABET, + new Random(42), + KWayTransitionCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_PATH_LENGTH, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_NUM_STEPS, + KWayTransitionCoverTestsIterator.DEFAULT_K, + metric, + method)); + Assert.assertTrue(tests.isEmpty()); + } + + @Test(dataProvider = "config") + public void testPartialAutomaton(GenerationMethod method, OptimizationMetric metric) { + // @formatter:off + final CompactDFA dfa = AutomatonBuilders.newDFA(ALPHABET) + .from("s0").on('a').to("s1") + .from("s1").on('b').to("s2") + .withInitial("s0") + .withAccepting("s2", "s3") + .create(); + // @formatter:on + + final List> tests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(dfa, + ALPHABET, + new Random(42), + KWayTransitionCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_PATH_LENGTH, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_NUM_STEPS, + KWayTransitionCoverTestsIterator.DEFAULT_K, + metric, + method)); + KWayStateCoverTestsIteratorTest.verifyEachStateVisited(dfa, tests, Set.of(0, 1, 2)); + } + + @Test(dataProvider = "config") + public void testRandomAutomaton(GenerationMethod method, OptimizationMetric metric) { + final CompactMealy mealy = + RandomAutomata.randomMealy(new Random(42), AUTOMATON_SIZE, ALPHABET, Alphabets.integers(0, 2)); + + final List> tests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(mealy, + ALPHABET, + new Random(42), + KWayTransitionCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_PATH_LENGTH, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_NUM_STEPS, + KWayTransitionCoverTestsIterator.DEFAULT_K, + metric, + method)); + KWayStateCoverTestsIteratorTest.verifyEachStateVisited(mealy, tests); + } + + @Test(dataProvider = "config") + public void testKeylockAutomaton(GenerationMethod method, OptimizationMetric metric) { + final CompactDFA mealy = KWayStateCoverTestsIteratorTest.generateKeylockAutomaton(ALPHABET); + + final List> tests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(mealy, + ALPHABET, + new Random(42), + KWayTransitionCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_PATH_LENGTH, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_NUM_STEPS, + KWayTransitionCoverTestsIterator.DEFAULT_K, + metric, + method)); + KWayStateCoverTestsIteratorTest.verifyEachStateVisited(mealy, tests); + } + + @Test + public void testLimits() { + final Random random = new Random(42); + final Alphabet alphabet = Alphabets.integers(0, 6); + final CompactDFA dfa = RandomAutomata.randomDFA(random, 10, alphabet, false); + final int randomWalkLen = 50; + + final List> rWalkTests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(dfa, + alphabet, + random, + randomWalkLen, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_PATH_LENGTH, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_NUM_STEPS, + KWayTransitionCoverTestsIterator.DEFAULT_K, + OptimizationMetric.QUERIES, + GenerationMethod.PREFIX)); + + for (Word t : rWalkTests) { + Assert.assertTrue(t.size() > randomWalkLen, t.toString()); + } + + final int maxPathLength = 10; + final List> maxPathTests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(dfa, + alphabet, + random, + maxPathLength * + maxPathLength, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + maxPathLength, + KWayTransitionCoverTestsIterator.DEFAULT_MAX_NUM_STEPS, + KWayTransitionCoverTestsIterator.DEFAULT_K, + OptimizationMetric.QUERIES, + GenerationMethod.RANDOM)); + + for (Word t : maxPathTests) { + final int length = t.size(); + /* + * In case the random generation can no longer find any meaningful test sequences it re-generates candidates + * via the prefix method which uses the randomWalkLength. We make this value reasonably large to check that + * test sequences were only generated from these two possibilities. + */ + Assert.assertTrue(length <= maxPathLength || length > maxPathLength * maxPathLength, t.toString()); + } + + final int maxNumSteps = 10; + final List> maxNumStepsTests = IteratorUtil.list(new KWayTransitionCoverTestsIterator<>(dfa, + alphabet, + random, + KWayTransitionCoverTestsIterator.DEFAULT_R_WALK_LEN, + KWayTransitionCoverTestsIterator.DEFAULT_NUM_GEN_PATHS, + maxPathLength, + maxNumSteps, + KWayTransitionCoverTestsIterator.DEFAULT_K, + OptimizationMetric.STEPS, + GenerationMethod.RANDOM)); + + final int numSteps = maxNumStepsTests.stream().mapToInt(Word::size).sum(); + Assert.assertTrue(numSteps >= maxNumSteps, maxNumStepsTests.toString()); + Assert.assertTrue(numSteps <= maxNumSteps + maxPathLength, maxNumStepsTests.toString()); + } +}