:').
+ """
self.automaton_type = automaton_type
if automaton_type == 'mdp':
self.initial_value = sul.query(tuple())
@@ -108,19 +145,18 @@ def __init__(self, sul: SUL, n_c, eq_oracle, automaton_type, compatibility_check
self.last_cex = None
self.last_tree_cex = None
- def back_to_root(self):
+ def back_to_root(self) -> None:
+ """
+ Reset the current node to the root of the trace tree.
+ """
self.curr_node = self.root_node
- def add(self, inp, out):
+ def add(self, inp: Any, out: Any) -> None:
"""
- Adds a input/output to the tree.
-
- Args:
-
- inp: input
- out: output
-
+ Add an input/output pair to the trace tree.
+ :param Any inp: Input.
+ :param Any out: Output.
"""
self.curr_node.input_frequencies[inp] += 1
if inp not in self.curr_node.children.keys() or out not in self.curr_node.children[inp].keys():
@@ -130,19 +166,13 @@ def add(self, inp, out):
self.curr_node = self.curr_node.children[inp][out]
self.curr_node.frequency += 1
- def frequency_query(self, s: tuple, e: tuple):
- """Output frequencies observed after trace s + e.
-
- Args:
-
- s: sequence from S set
- e: sequence from E set
-
-
- Returns:
-
- sum of output frequencies
+ def frequency_query(self, s: tuple, e: tuple) -> dict:
+ """
+ Get the output frequencies observed after trace s + e.
+ :param tuple s: Sequence from S set.
+ :param tuple e: Sequence from E set.
+ :return dict: Sum of output frequencies.
"""
if self.automaton_type == 'mdp':
s = s[1:]
@@ -163,20 +193,14 @@ def frequency_query(self, s: tuple, e: tuple):
self.complete_query_cache.add(s + e)
return output_freq
- def complete_query(self, s: tuple, e: tuple):
+ def complete_query(self, s: tuple, e: tuple) -> bool:
"""
- Given a test sequences returns true if sufficient information is available to estimate an output distribution
- from frequency queries; returns false otherwise.
-
- Args:
-
- s: sequence from S set
- e: sequence from E set
-
- Returns:
-
- True if cell is completed, false otherwise
+ Determine whether sufficient information is available to estimate an output distribution
+ from frequency queries for a given test sequence.
+ :param tuple s: Sequence from S set.
+ :param tuple e: Sequence from E set.
+ :return bool: True if cell is completed, false otherwise.
"""
# extract inputs and outputs
@@ -208,19 +232,13 @@ def complete_query(self, s: tuple, e: tuple):
self.complete_query_cache.add(s + e)
return sum_freq >= self.n_c
- def tree_query(self, pta_root):
+ def tree_query(self, pta_root: Node) -> None:
"""
- Execute a refine query based on input/output trace. If at some point real outputs differ from expected
- outputs, trace to that point is added to the tree, otherwise whole trace is executed.
-
- Args:
-
- pta_root: root of the PTA
-
- Returns:
-
- number of steps taken
+ Execute a refine query based on an input/output trace sampled from the trace tree. If at
+ some point real outputs differ from expected outputs, the trace up to that point is added
+ to the tree, otherwise the whole trace is executed.
+ :param Node pta_root: Root of the PTA.
"""
self.sul.pre()
curr_node = pta_root
@@ -264,7 +282,14 @@ def tree_query(self, pta_root):
self.sul.post()
return
- def single_dfs_for_cex(self, stop_prob, hypothesis):
+ def single_dfs_for_cex(self, stop_prob: float, hypothesis: Mdp | StochasticMealyMachine) -> tuple | None:
+ """
+ Perform a single randomized depth-first search over the trace tree for a counterexample.
+
+ :param float stop_prob: Probability of stopping the search at each step.
+ :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample trace, or None if none was found in this pass.
+ """
curr_node = self.root_node
curr_state = hypothesis.initial_state
if self.automaton_type == "mdp":
@@ -303,14 +328,29 @@ def single_dfs_for_cex(self, stop_prob, hypothesis):
curr_state = next_state
trace = trace + (i,) + (o,)
- def dfs_for_cex_in_tree(self, hypothesis, nr_traces, stop_prob):
+ def dfs_for_cex_in_tree(self, hypothesis: Mdp | StochasticMealyMachine, nr_traces: int,
+ stop_prob: float) -> tuple | None:
+ """
+ Repeatedly search the trace tree for a counterexample using randomized depth-first search.
+
+ :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis.
+ :param int nr_traces: Number of search attempts to perform.
+ :param float stop_prob: Probability of stopping each search early.
+ :return tuple | None: Counterexample trace, or None if none was found.
+ """
for i in range(nr_traces):
cex = self.single_dfs_for_cex(stop_prob, hypothesis)
if cex:
return cex
return None
- def bfs_for_cex_in_tree(self, hypothesis):
+ def bfs_for_cex_in_tree(self, hypothesis: Mdp | StochasticMealyMachine) -> tuple | None:
+ """
+ Search the trace tree for a counterexample using breadth-first search.
+
+ :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample trace, or None if none was found.
+ """
# BFS for cex
if self.automaton_type == "mdp":
to_check = [(self.root_node, hypothesis.initial_state, tuple(self.initial_value))]
@@ -341,18 +381,13 @@ def bfs_for_cex_in_tree(self, hypothesis):
to_check.append((c, next_state, new_trace))
return None
- def equivalence_query(self, hypothesis):
+ def equivalence_query(self, hypothesis: Mdp | StochasticMealyMachine) -> tuple | None:
"""
- Finds and returns a counterexample
-
- Args:
-
- hypothesis: current hypothesis
-
- Returns:
-
- counterexample
+ Find and return a counterexample, preferring cached or tree-based candidates before
+ falling back to the wrapped equivalence oracle.
+ :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample, or None if none was found.
"""
if self.last_cex and not self.is_cex_processed(hypothesis, self.last_cex):
return self.last_cex
@@ -380,7 +415,15 @@ def equivalence_query(self, hypothesis):
self.last_cex = cex
return cex
- def is_cex_processed(self, hypothesis, cex):
+ def is_cex_processed(self, hypothesis: Mdp | StochasticMealyMachine, cex: tuple) -> bool:
+ """
+ Check whether a previously found counterexample is still a valid counterexample on the
+ given hypothesis.
+
+ :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis.
+ :param tuple cex: Previously found counterexample.
+ :return bool: True if the counterexample is still valid (unprocessed), False otherwise.
+ """
if self.automaton_type == 'mdp':
cex = cex[1:]
last_inp = cex[-1]
diff --git a/aalpy/learning_algs/stochastic/__init__.py b/aalpy/learning_algs/stochastic/__init__.py
index e69de29bb2d..3b2b240a094 100644
--- a/aalpy/learning_algs/stochastic/__init__.py
+++ b/aalpy/learning_algs/stochastic/__init__.py
@@ -0,0 +1 @@
+# Sampling-based stochastic L* algorithm and supporting components for learning MDPs and stochastic Mealy machines.
diff --git a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
index 5abdfcdc4c7..ee338a9e9db 100644
--- a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
+++ b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
@@ -1,7 +1,11 @@
+# Active Alergia: samples from the system under learning based on intermediate hypotheses to augment the
+# learning data used by (passive) Alergia/IOAlergia.
from abc import ABC, abstractmethod
from random import randint, choice
+from aalpy.base import SUL
from aalpy.learning_algs import run_Alergia
+from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import CompatibilityChecker
class Sampler(ABC):
@@ -10,30 +14,42 @@ class Sampler(ABC):
"""
@abstractmethod
- def sample(self, sul, model):
+ def sample(self, sul: SUL, model: 'Mdp') -> list:
"""
Abstract method implementing sampling strategy.
- Args:
-
- sul: system under learning
- model: current learned model
-
- Returns:
-
- Data to be added to the data set for the passive learnign.
-
+ :param SUL sul: System under learning.
+ :param Mdp model: Current learned model.
+ :return list: Data to be added to the data set for the passive learning.
"""
pass
class RandomWordSampler(Sampler):
- def __init__(self, num_walks, min_walk_len, max_walk_len):
+ """
+ Sampler that generates random walks over the input alphabet of the current hypothesis.
+ """
+
+ def __init__(self, num_walks: int, min_walk_len: int, max_walk_len: int) -> None:
+ """
+ Creates a random word sampler.
+
+ :param int num_walks: Number of random walks to sample per iteration.
+ :param int min_walk_len: Minimum length of a random walk.
+ :param int max_walk_len: Maximum length of a random walk.
+ """
self.num_walks = num_walks
self.min_walk_len = min_walk_len
self.max_walk_len = max_walk_len
- def sample(self, sul, model):
+ def sample(self, sul: SUL, model: 'Mdp') -> list:
+ """
+ Samples num_walks random walks of random length over the current hypothesis' input alphabet.
+
+ :param SUL sul: System under learning.
+ :param Mdp model: Current learned model, used to extract the input alphabet.
+ :return list: List of sampled traces in the form [output, (input, output), ...].
+ """
input_al = list({el for s in model.states for el in s.transitions.keys()})
samples = []
@@ -52,28 +68,23 @@ def sample(self, sul, model):
return samples
-def run_active_Alergia(data, sul, sampler, n_iter, eps=0.05, compatibility_checker=None, automaton_type='mdp',
- print_info=True):
+def run_active_Alergia(data: list, sul: SUL, sampler: Sampler, n_iter: int, eps: float | str = 0.05,
+ compatibility_checker: CompatibilityChecker | None = None, automaton_type: str = 'mdp',
+ print_info: bool = True) -> 'Mdp':
"""
Active version of IOAlergia algorithm. Based on intermediate hypothesis sampling on the system is performed.
Sampled data is added to the learning data and more accurate model is learned.
- Proposed in "Aichernig and Tappler, Probabilistic Black-Box Reachability Checking"
-
- Args:
-
- data: initial learning data, in form [[O, (I,O), (I,O)...] ,...] where O is outputs and I input.
- sul: system under learning which is basis for sampling
- sampler: instance of Sampler class
- n_iter: number of iterations of active learning
- eps: epsilon value if the default checker is used. Look in run_Alergia for description
- compatibility_checker: passed to run_Alergia, check there for description
- automaton_type: either 'mdp' or 'smm' (Markov decision process or Stochastic Mealy Machine)
- print_info: print current learning iteration
-
- Returns:
-
- learned MDP
-
+ Proposed in "Aichernig and Tappler, Probabilistic Black-Box Reachability Checking".
+
+ :param list data: Initial learning data, in form [[O, (I,O), (I,O)...] ,...] where O is outputs and I input.
+ :param SUL sul: System under learning which is basis for sampling.
+ :param Sampler sampler: Instance of Sampler class.
+ :param int n_iter: Number of iterations of active learning.
+ :param float | str eps: Epsilon value if the default checker is used. Look in run_Alergia for description.
+ :param CompatibilityChecker | None compatibility_checker: Passed to run_Alergia, check there for description.
+ :param str automaton_type: Either 'mdp' or 'smm' (Markov decision process or Stochastic Mealy Machine).
+ :param bool print_info: Print current learning iteration.
+ :return Mdp: Learned MDP.
"""
model = None
for i in range(n_iter):
@@ -85,4 +96,3 @@ def run_active_Alergia(data, sul, sampler, n_iter, eps=0.05, compatibility_check
data.extend(new_samples)
return model
-
diff --git a/aalpy/learning_algs/stochastic_passive/Alergia.py b/aalpy/learning_algs/stochastic_passive/Alergia.py
index 653450432c2..c3d80648ca8 100644
--- a/aalpy/learning_algs/stochastic_passive/Alergia.py
+++ b/aalpy/learning_algs/stochastic_passive/Alergia.py
@@ -1,17 +1,36 @@
+# Implementation of the Alergia/IOAlergia passive learning algorithm for Markov chains, MDPs, and stochastic Mealy
+# machines, plus helper entry points for running it on in-memory data or via the external JAlergia implementation.
import time
from bisect import insort
from aalpy.automata import MarkovChain, MdpState, Mdp, McState, StochasticMealyState, \
StochasticMealyMachine
-from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import HoeffdingCompatibility
-from aalpy.learning_algs.stochastic_passive.FPTA import create_fpta
+from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import CompatibilityChecker, HoeffdingCompatibility
+from aalpy.learning_algs.stochastic_passive.FPTA import AlergiaPtaNode, create_fpta
state_automaton_map = {'mc': (McState, MarkovChain), 'mdp': (MdpState, Mdp),
'smm': (StochasticMealyState, StochasticMealyMachine)}
class Alergia:
- def __init__(self, data, automaton_type, eps=0.05, compatibility_checker=None, print_info=False):
+ """
+ Implementation of the Alergia/IOAlergia state-merging algorithm, building an FPTA from data and merging
+ statistically compatible states to obtain a Markov chain, MDP, or stochastic Mealy machine.
+ """
+
+ def __init__(self, data: list, automaton_type: str, eps: float | str = 0.05,
+ compatibility_checker: CompatibilityChecker | None = None, print_info: bool = False) -> None:
+ """
+ Creates an Alergia instance and constructs the FPTA from the provided data.
+
+ :param list data: Learning data, format depends on automaton_type (see run_Alergia for details).
+ :param str automaton_type: Either 'mdp', 'mc', or 'smm'.
+ :param float | str eps: Epsilon value for the default HoeffdingCompatibility, or 'auto' to compute it from
+ the data.
+ :param CompatibilityChecker | None compatibility_checker: Custom compatibility checker, HoeffdingCompatibility
+ with eps value by default.
+ :param bool print_info: If True, print timing/statistics information.
+ """
assert eps == 'auto' or 0 < eps <= 2
self.automaton_type = automaton_type
@@ -30,7 +49,14 @@ def __init__(self, data, automaton_type, eps=0.05, compatibility_checker=None, p
if self.print_info:
print(f'PTA Construction Time: {pta_time}')
- def compatibility_test(self, a, b):
+ def compatibility_test(self, a: AlergiaPtaNode, b: AlergiaPtaNode) -> bool:
+ """
+ Recursively checks whether two FPTA nodes (and all their reachable descendants) are compatible for merging.
+
+ :param AlergiaPtaNode a: First node.
+ :param AlergiaPtaNode b: Second node.
+ :return bool: True if the nodes are compatible, False otherwise.
+ """
# for MDPs and MC output of the state needs to be the same
if self.automaton_type != 'smm' and a.output != b.output:
@@ -51,7 +77,13 @@ def compatibility_test(self, a, b):
return True
- def merge(self, red_state, blue_state):
+ def merge(self, red_state: AlergiaPtaNode, blue_state: AlergiaPtaNode) -> None:
+ """
+ Merges a blue node into a red (representative) node by rewiring its parent's transition and folding it in.
+
+ :param AlergiaPtaNode red_state: Representative state that will absorb the blue state.
+ :param AlergiaPtaNode blue_state: State to be merged into the red state.
+ """
b_prefix = blue_state.prefix
to_update = self.fpta
for p in b_prefix[:-1]:
@@ -61,7 +93,13 @@ def merge(self, red_state, blue_state):
self.fold(red_state, blue_state)
- def fold(self, red, blue):
+ def fold(self, red: AlergiaPtaNode, blue: AlergiaPtaNode) -> None:
+ """
+ Recursively folds the subtree rooted at blue into the subtree rooted at red, merging input frequencies.
+
+ :param AlergiaPtaNode red: Node into which blue is folded.
+ :param AlergiaPtaNode blue: Node being folded into red.
+ """
for i, blue_child in blue.children.items():
if i in red.children:
red.input_frequency[i] += blue.input_frequency[i]
@@ -70,7 +108,12 @@ def fold(self, red, blue):
red.children[i] = blue.children[i]
red.input_frequency[i] = blue.input_frequency[i]
- def run(self):
+ def run(self) -> MarkovChain | Mdp | StochasticMealyMachine:
+ """
+ Runs the Alergia state-merging loop on the FPTA and converts the resulting red states into an automaton.
+
+ :return MarkovChain | Mdp | StochasticMealyMachine: The learned automaton.
+ """
start_time = time.time()
# representative nodes that will be included in the final output model
@@ -112,7 +155,12 @@ def run(self):
return self.to_automaton(red)
- def normalize(self, red):
+ def normalize(self, red: list) -> None:
+ """
+ Normalizes input/output frequencies of all red states into probabilities.
+
+ :param list red: List of representative (red) AlergiaPtaNode states.
+ """
red_sorted = sorted(list(red), key=lambda x: len(x.prefix))
for r in red_sorted:
# Initializing in here saves many unnecessary initializations
@@ -125,7 +173,13 @@ def normalize(self, red):
for i, o in r.input_frequency.keys():
r.children_prob[(i, o)] = r.input_frequency[(i, o)] / r.get_input_frequency(i)
- def to_automaton(self, red):
+ def to_automaton(self, red: list) -> MarkovChain | Mdp | StochasticMealyMachine:
+ """
+ Converts the list of red FPTA nodes into an automaton of the configured type.
+
+ :param list red: List of representative (red) AlergiaPtaNode states.
+ :return MarkovChain | Mdp | StochasticMealyMachine: The constructed automaton.
+ """
s_c = state_automaton_map[self.automaton_type][0]
a_c = state_automaton_map[self.automaton_type][1]
@@ -162,31 +216,24 @@ def to_automaton(self, red):
return a_c(initial_state, states)
-def run_Alergia(data, automaton_type, eps=0.05, compatibility_checker=None, print_info=False):
+def run_Alergia(data: list, automaton_type: str, eps: float | str = 0.05,
+ compatibility_checker: CompatibilityChecker | None = None,
+ print_info: bool = False) -> MarkovChain | Mdp | StochasticMealyMachine:
"""
Run Alergia or IOAlergia on provided data.
- Args:
-
- data: data either in a form [[I,I,I],[I,I,I],...] if learning Markov Chains or [[O,(I,O),(I,O)...],
- [O,(I,O), (I, O)_,...],..,] if learning MDPs, or [[I,O,I,O...], [I,O_,...],..,] if learning SMMs
- (I represents input, O output).
- Note that in whole data first symbol of each entry should be the same (Initial output of the MDP/MC).
-
- eps: epsilon value if you are using default HoeffdingCompatibility. If it is set to 'auto' it will be computed
- as 10/(all steps in the data)
-
- automaton_type: either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain, or 'smm' if
- you want to learn stochastic Mealy machine
-
- compatibility_checker: impl. of class CompatibilityChecker, HoeffdingCompatibility with eps value by default
-
- (note: not interchangeable, depends on data)
- print_info:
-
- Returns:
-
- mdp, smm, or markov chain
+ :param list data: Data either in a form [[I,I,I],[I,I,I],...] if learning Markov Chains or
+ [[O,(I,O),(I,O)...], [O,(I,O), (I, O)_,...],..,] if learning MDPs, or [[I,O,I,O...], [I,O_,...],..,] if
+ learning SMMs (I represents input, O output). Note that in whole data first symbol of each entry should be
+ the same (Initial output of the MDP/MC).
+ :param str automaton_type: Either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain, or
+ 'smm' if you want to learn stochastic Mealy machine.
+ :param float | str eps: Epsilon value if you are using default HoeffdingCompatibility. If it is set to 'auto' it
+ will be computed as 10/(all steps in the data).
+ :param CompatibilityChecker | None compatibility_checker: Impl. of class CompatibilityChecker,
+ HoeffdingCompatibility with eps value by default (note: not interchangeable, depends on data).
+ :param bool print_info: Print learning statistics.
+ :return MarkovChain | Mdp | StochasticMealyMachine: Learned MDP, SMM, or Markov chain.
"""
assert automaton_type in {'mdp', 'mc', 'smm'}
alergia = Alergia(data, eps=eps, automaton_type=automaton_type,
@@ -196,30 +243,21 @@ def run_Alergia(data, automaton_type, eps=0.05, compatibility_checker=None, prin
return model
-def run_JAlergia(path_to_data_file, automaton_type, path_to_jAlergia_jar, eps=0.05, heap_memory='-Xmx2048M'):
+def run_JAlergia(path_to_data_file: str | list, automaton_type: str, path_to_jAlergia_jar: str,
+ eps: float = 0.05, heap_memory: str = '-Xmx2048M') -> MarkovChain | Mdp | StochasticMealyMachine | None:
"""
- Run Alergia or IOAlergia on provided data.
-
- Args:
-
- path_to_data_file: either a data in a list of lists or a path to file containing data.
- Form [[I,I,I],[I,I,I],...] if learning Markov Chains or
- [[O,I,O,I,O...], [O,I,O_,...],..,] if learning MDPs (I represents input, O output), or
- [[I,O,I,O...], [I,O_,...],..,] if learning SMMs.
- Note that in whole data first symbol of each entry should be the same (Initial output of the MDP/MC).
-
- eps: epsilon value
-
- heap_memory: java heap memory flag, increase if heap is full
-
- automaton_type: either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain,
- or 'smm' if you
- want to learn stochastic Mealy machine
-
-
- Returns:
-
- learnedModel
+ Run Alergia or IOAlergia on provided data using the external JAlergia Java implementation.
+
+ :param str | list path_to_data_file: Either a data in a list of lists or a path to file containing data. Form
+ [[I,I,I],[I,I,I],...] if learning Markov Chains or [[O,I,O,I,O...], [O,I,O_,...],..,] if learning MDPs
+ (I represents input, O output), or [[I,O,I,O...], [I,O_,...],..,] if learning SMMs. Note that in whole data
+ first symbol of each entry should be the same (Initial output of the MDP/MC).
+ :param str automaton_type: Either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain, or
+ 'smm' if you want to learn stochastic Mealy machine.
+ :param str path_to_jAlergia_jar: Path to the JAlergia jar file.
+ :param float eps: Epsilon value.
+ :param str heap_memory: Java heap memory flag, increase if heap is full.
+ :return MarkovChain | Mdp | StochasticMealyMachine | None: Learned model, or None if an error occurred.
"""
assert automaton_type in {'mdp', 'smm', 'mc'}
diff --git a/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py b/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py
index 87c6972ebbc..cad65c117b3 100644
--- a/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py
+++ b/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py
@@ -1,3 +1,4 @@
+# Compatibility checkers used by Alergia/IOAlergia to decide whether two FPTA states may be merged.
from abc import ABC, abstractmethod
from math import sqrt, log
@@ -5,18 +6,47 @@
class CompatibilityChecker(ABC):
+ """
+ Abstract class implemented by all compatibility checkers used to decide whether two states of the FPTA are
+ statistically different (and thus should not be merged).
+ """
@abstractmethod
def are_states_different(self, a: AlergiaPtaNode, b: AlergiaPtaNode, **kwargs) -> bool:
+ """
+ Checks whether two FPTA nodes are statistically different.
+
+ :param AlergiaPtaNode a: First node.
+ :param AlergiaPtaNode b: Second node.
+ :param kwargs: Additional implementation-specific arguments.
+ :return bool: True if the nodes are statistically different, False otherwise.
+ """
pass
class HoeffdingCompatibility(CompatibilityChecker):
- def __init__(self, eps):
+ """
+ Compatibility checker based on the Hoeffding bound, comparing observed output frequency distributions of two
+ FPTA nodes.
+ """
+
+ def __init__(self, eps: float) -> None:
+ """
+ Creates a Hoeffding-bound-based compatibility checker.
+
+ :param float eps: Epsilon value controlling the strictness of the Hoeffding bound.
+ """
self.eps = eps
self.log_term = sqrt(0.5 * log(2 / self.eps))
- def hoeffding_bound(self, a: dict, b: dict):
+ def hoeffding_bound(self, a: dict, b: dict) -> bool:
+ """
+ Checks whether two output frequency distributions differ by more than the Hoeffding bound.
+
+ :param dict a: Frequency distribution of the first node.
+ :param dict b: Frequency distribution of the second node.
+ :return bool: True if the distributions differ by more than the Hoeffding bound, False otherwise.
+ """
n1 = sum(a.values())
n2 = sum(b.values())
@@ -33,7 +63,16 @@ def hoeffding_bound(self, a: dict, b: dict):
return True
return False
- def are_states_different(self, a: AlergiaPtaNode, b: AlergiaPtaNode, **kwargs):
+ def are_states_different(self, a: AlergiaPtaNode, b: AlergiaPtaNode, **kwargs) -> bool:
+ """
+ Checks whether two FPTA nodes are statistically different based on the Hoeffding bound, conditioned on
+ inputs in the IOAlergia case.
+
+ :param AlergiaPtaNode a: First node.
+ :param AlergiaPtaNode b: Second node.
+ :param kwargs: Unused, present for interface compatibility.
+ :return bool: True if the nodes are statistically different, False otherwise.
+ """
# no data available for any node
if len(a.original_input_frequency) * len(b.original_children) == 0:
diff --git a/aalpy/learning_algs/stochastic_passive/FPTA.py b/aalpy/learning_algs/stochastic_passive/FPTA.py
index 08b0dac446b..f0381df1c7c 100644
--- a/aalpy/learning_algs/stochastic_passive/FPTA.py
+++ b/aalpy/learning_algs/stochastic_passive/FPTA.py
@@ -1,12 +1,26 @@
+# Frequency Prefix Tree Acceptor (FPTA) data structure used by Alergia/IOAlergia as the initial hypothesis.
from functools import total_ordering
+from typing import Any
@total_ordering
class AlergiaPtaNode:
+ """
+ Single node of the frequency prefix tree acceptor (FPTA). Keeps both the current (mutable) and original
+ (immutable) children/input frequencies, the latter being used for statistical compatibility checks.
+ """
+
__slots__ = ['prefix', 'output', 'input_frequency', 'children', 'original_input_frequency',
'original_children', 'state_id', 'children_prob']
- def __init__(self, output, prefix):
+ def __init__(self, output: Any, prefix: tuple) -> None:
+ """
+ Creates a new FPTA node.
+
+ :param Any output: Output associated with this node.
+ :param tuple prefix: Sequence of inputs (and/or outputs, depending on automaton type) that lead to this node
+ from the root.
+ """
self.prefix = prefix
self.output = output
# mutable values
@@ -19,38 +33,84 @@ def __init__(self, output, prefix):
self.state_id = None
self.children_prob = None
- def successors(self):
+ def successors(self) -> list['AlergiaPtaNode']:
+ """
+ Returns the (mutable) children of this node.
+
+ :return list[AlergiaPtaNode]: List of successor nodes.
+ """
return list(self.children.values())
- def get_inputs(self):
+ def get_inputs(self) -> set:
+ """
+ Returns the set of inputs observed in the mutable input frequency map.
+
+ :return set: Set of inputs.
+ """
return {i for i, _ in self.input_frequency.keys()}
- def get_input_frequency(self, target_input):
+ def get_input_frequency(self, target_input: Any) -> int:
+ """
+ Computes the total frequency of a given input, summed over all outputs.
+
+ :param Any target_input: Input whose frequency should be computed.
+ :return int: Total observed frequency of the input.
+ """
return sum(freq for (i, _), freq in self.input_frequency.items() if i == target_input)
- def get_output_frequencies(self, target_input):
+ def get_output_frequencies(self, target_input: Any) -> dict:
+ """
+ Returns the frequency of each output observed for a given input.
+
+ :param Any target_input: Input for which output frequencies should be computed.
+ :return dict: Mapping of output to observed frequency.
+ """
return {o: freq for (i, o), freq in self.input_frequency.items() if i == target_input}
- def get_immutable_inputs(self):
+ def get_immutable_inputs(self) -> set:
+ """
+ Returns the set of inputs observed in the original (immutable) children map.
+
+ :return set: Set of inputs.
+ """
return {i for i, _ in self.original_children.keys()}
- def get_immutable_input_frequency(self, target_input):
+ def get_immutable_input_frequency(self, target_input: Any) -> int:
+ """
+ Computes the total original frequency of a given input, summed over all outputs.
+
+ :param Any target_input: Input whose frequency should be computed.
+ :return int: Total original frequency of the input.
+ """
return sum(freq for (i, _), freq in self.original_input_frequency.items() if i == target_input)
- def get_original_output_frequencies(self, target_input):
+ def get_original_output_frequencies(self, target_input: Any) -> dict:
+ """
+ Returns the original frequency of each output observed for a given input.
+
+ :param Any target_input: Input for which output frequencies should be computed.
+ :return dict: Mapping of output to original observed frequency.
+ """
return {o: freq for (i, o), freq in self.original_input_frequency.items() if i == target_input}
- def __lt__(self, other):
+ def __lt__(self, other: 'AlergiaPtaNode') -> bool:
return (len(self.prefix), self.prefix) < (len(other.prefix), other.prefix)
- def __le__(self, other):
+ def __le__(self, other: 'AlergiaPtaNode') -> bool:
return self < other or self == other
- def __eq__(self, other):
+ def __eq__(self, other: 'AlergiaPtaNode') -> bool:
return self.prefix == other.prefix
-def create_fpta(data, automaton_type):
+def create_fpta(data: list, automaton_type: str) -> AlergiaPtaNode:
+ """
+ Builds the frequency prefix tree acceptor (FPTA) from a data set of observed traces.
+
+ :param list data: Data set of traces, format depends on automaton_type (see run_Alergia for details).
+ :param str automaton_type: Either 'mc', 'mdp', or 'smm'.
+ :return AlergiaPtaNode: Root node of the constructed FPTA.
+ """
# in case of SMM, there is no initial input
seq_iter_index = 0 if automaton_type == 'smm' else 1
diff --git a/aalpy/learning_algs/stochastic_passive/__init__.py b/aalpy/learning_algs/stochastic_passive/__init__.py
index e69de29bb2d..0e9be0adb99 100644
--- a/aalpy/learning_algs/stochastic_passive/__init__.py
+++ b/aalpy/learning_algs/stochastic_passive/__init__.py
@@ -0,0 +1 @@
+# Package containing passive stochastic learning algorithms (Alergia/IOAlergia, active Alergia) and the FPTA data structure.
diff --git a/aalpy/oracles/BreadthFirstExplorationEqOracle.py b/aalpy/oracles/BreadthFirstExplorationEqOracle.py
index 0e3ca74b319..3cf01f1f679 100644
--- a/aalpy/oracles/BreadthFirstExplorationEqOracle.py
+++ b/aalpy/oracles/BreadthFirstExplorationEqOracle.py
@@ -1,5 +1,7 @@
+# Equivalence oracle that exhaustively explores all input combinations up to a fixed depth.
from aalpy.base.Oracle import Oracle
from aalpy.base.SUL import SUL
+from aalpy.base.Automaton import Automaton
from itertools import product
from random import shuffle
@@ -11,15 +13,13 @@ class BreadthFirstExplorationEqOracle(Oracle):
Extremely inefficient equivalence oracle and should only be used for demonstrations.
"""
- def __init__(self, alphabet, sul: SUL, depth=5):
+ def __init__(self, alphabet: list, sul: SUL, depth: int = 5) -> None:
"""
- Args:
+ Constructs the oracle and pre-generates all test cases of the given depth.
- alphabet: input alphabet
-
- sul: system under learning
-
- depth: depth of the tree
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int depth: Depth of the tree, i.e. length of each generated test case.
"""
super().__init__(alphabet, sul)
@@ -32,8 +32,13 @@ def __init__(self, alphabet, sul: SUL, depth=5):
shuffle(self.queue)
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | None:
+ """
+ Executes queued test cases against the SUL and hypothesis until a counterexample is found.
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
while self.queue:
test_case = self.queue.pop()
self.reset_hyp_and_sul(hypothesis)
diff --git a/aalpy/oracles/CacheBasedEqOracle.py b/aalpy/oracles/CacheBasedEqOracle.py
index 953999ceeaa..7d6edc5ca1e 100644
--- a/aalpy/oracles/CacheBasedEqOracle.py
+++ b/aalpy/oracles/CacheBasedEqOracle.py
@@ -1,5 +1,9 @@
+# Equivalence oracle that reuses the trace cache built during learning to guide test case selection.
+from typing import Any
+
from aalpy.base import Oracle, SUL
from aalpy.base.SUL import CacheSUL
+from aalpy.base.Automaton import Automaton
from random import choice
@@ -11,21 +15,17 @@ class CacheBasedEqOracle(Oracle):
of length (max_tree_depth + 'depth_increase') - len(prefix), where prefix is a path to the leaf.
"""
- def __init__(self, alphabet: list, sul: SUL, num_walks=100, depth_increase=5, reset_after_cex=True):
+ def __init__(self, alphabet: list, sul: SUL, num_walks: int = 100, depth_increase: int = 5,
+ reset_after_cex: bool = True) -> None:
"""
-
- Args:
-
- alphabet: input alphabet
-
- sul: system under learning
-
- num_walks: number of random walks to perform
-
- depth_increase: length of random walk that exceeds the maximum depth of the tree
-
- reset_after_cex: if False, total number of queries will equal num_walks, if True, in each execution of
- find_cex method at most num_walks will be executed
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning. Must wrap or be a CacheSUL.
+ :param int num_walks: Number of random walks to perform.
+ :param int depth_increase: Length of random walk that exceeds the maximum depth of the tree.
+ :param bool reset_after_cex: If False, total number of queries will equal num_walks, if True, in each
+ execution of find_cex method at most num_walks will be executed.
"""
super().__init__(alphabet, sul)
@@ -35,8 +35,13 @@ def __init__(self, alphabet: list, sul: SUL, num_walks=100, depth_increase=5, re
self.reset_after_cex = reset_after_cex
self.num_walks_done = 0
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> list | None:
+ """
+ Performs random walks starting from cached prefixes until a counterexample is found.
+ :param Automaton hypothesis: Current hypothesis.
+ :return list | None: Counterexample inputs, None if no counterexample is found.
+ """
assert isinstance(self.sul, CacheSUL)
self.cache_tree = self.sul.cache
@@ -75,16 +80,14 @@ def find_cex(self, hypothesis):
return None
- def get_paths(self, t, paths=None, current_path=None):
+ def get_paths(self, t: Any, paths: list | None = None, current_path: list | None = None) -> list:
"""
+ Recursively collects the paths (sequences of inputs) from the root of a cache tree node to all its leaves.
- Args:
- t:
- paths: (Default value = None)
- current_path: (Default value = None)
-
- Returns:
-
+ :param Any t: Cache tree node to collect paths from.
+ :param list | None paths: Accumulator of completed paths, created if None.
+ :param list | None current_path: Path accumulated so far to reach node t, created if None.
+ :return list: List of paths (each a list of inputs) from t to its leaves.
"""
if paths is None:
paths = []
diff --git a/aalpy/oracles/KWayStateCoverageEqOracle.py b/aalpy/oracles/KWayStateCoverageEqOracle.py
index 250f2a75355..910fa50ec89 100644
--- a/aalpy/oracles/KWayStateCoverageEqOracle.py
+++ b/aalpy/oracles/KWayStateCoverageEqOracle.py
@@ -1,6 +1,8 @@
+# Equivalence oracle that covers k-wise combinations/permutations of states with a trailing random walk.
from random import choices, shuffle
from aalpy.base import Oracle, SUL
+from aalpy.base.Automaton import Automaton
from itertools import combinations, permutations
@@ -10,22 +12,22 @@ class KWayStateCoverageEqOracle(Oracle):
random walk at the end.
"""
- def __init__(self, alphabet: list, sul: SUL, k=2, random_walk_len=20,
- method='permutations',
- num_test_lower_bound=None,
- num_test_upper_bound=None):
+ def __init__(self, alphabet: list, sul: SUL, k: int = 2, random_walk_len: int = 20,
+ method: str = 'permutations',
+ num_test_lower_bound: int | None = None,
+ num_test_upper_bound: int | None = None) -> None:
"""
-
- Args:
-
- alphabet: input alphabet
- sul: system under learning
- k: k value used for k-wise combinations/permutations of states
- random_walk_len: length of random walk performed at the end of each combination/permutation
- method: either 'combinations' or 'permutations'
- num_test_lower_bound= either None or number a minimum number of test-cases to be performed in each testing round
- num_test_upper_bound= either None or number a maximum number of test-cases to be performed in each testing round
-
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int k: k value used for k-wise combinations/permutations of states.
+ :param int random_walk_len: Length of random walk performed at the end of each combination/permutation.
+ :param str method: Either 'combinations' or 'permutations'.
+ :param int | None num_test_lower_bound: Either None or a minimum number of test-cases to be performed in
+ each testing round.
+ :param int | None num_test_upper_bound: Either None or a maximum number of test-cases to be performed in
+ each testing round.
"""
super().__init__(alphabet, sul)
assert k > 1 and method in ['combinations', 'permutations']
@@ -37,8 +39,14 @@ def __init__(self, alphabet: list, sul: SUL, k=2, random_walk_len=20,
self.num_test_lower_bound = num_test_lower_bound
self.num_test_upper_bound = num_test_upper_bound
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | None:
+ """
+ Generates and executes test cases covering k-wise state combinations/permutations until a counterexample
+ is found.
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
shuffle(hypothesis.states)
test_cases = []
diff --git a/aalpy/oracles/KWayTransitionCoverageEqOracle.py b/aalpy/oracles/KWayTransitionCoverageEqOracle.py
index 8a6814c9019..5d6004af396 100644
--- a/aalpy/oracles/KWayTransitionCoverageEqOracle.py
+++ b/aalpy/oracles/KWayTransitionCoverageEqOracle.py
@@ -1,4 +1,6 @@
+# Equivalence oracle that selects test cases based on k-way transition coverage.
from collections import namedtuple
+from collections.abc import Iterator
from itertools import product
from random import choices, randint, random
@@ -20,25 +22,27 @@ def __init__(self, alphabet: list, sul: SUL, k: int = 2, method='random',
max_path_len: int = 50,
max_number_of_steps: int = 0,
optimize: str = 'steps',
- random_walk_len=10,
- num_test_lower_bound=None,
- num_test_upper_bound=None,
- ):
- """
- Args:
-
- alphabet: input alphabet
- sul: system under learning
- k: k value used for K-Way transitions, i.e the number of steps between the start and the end of a transition
- method: defines how the queries are generated 'random' or 'prefix'
- num_generate_paths: number of random queries used to find the optimal subset
- max_path_len: the maximum step size of a generated path
- max_number_of_steps: maximum number of steps that will be executed on the SUL (0 = no limit)
- optimize: minimize either the number of 'steps' or 'queries' that are executed
- random_walk_len: the number of steps that are added by 'prefix' generated paths
- num_test_lower_bound= either None or number a minimum number of test-cases to be performed in each testing round
- num_test_upper_bound= either None or number a maximum number of test-cases to be performed in each testing round
-
+ random_walk_len: int = 10,
+ num_test_lower_bound: int | None = None,
+ num_test_upper_bound: int | None = None,
+ ) -> None:
+ """
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int k: k value used for K-Way transitions, i.e the number of steps between the start and the end of
+ a transition.
+ :param str method: Defines how the queries are generated, 'random' or 'prefix'.
+ :param int num_generate_paths: Number of random queries used to find the optimal subset.
+ :param int max_path_len: The maximum step size of a generated path.
+ :param int max_number_of_steps: Maximum number of steps that will be executed on the SUL (0 = no limit).
+ :param str optimize: Minimize either the number of 'steps' or 'queries' that are executed.
+ :param int random_walk_len: The number of steps that are added by 'prefix' generated paths.
+ :param int | None num_test_lower_bound: Either None or a minimum number of test-cases to be performed in
+ each testing round.
+ :param int | None num_test_upper_bound: Either None or a maximum number of test-cases to be performed in
+ each testing round.
"""
super().__init__(alphabet, sul)
assert k >= 2
@@ -61,7 +65,13 @@ def __init__(self, alphabet: list, sul: SUL, k: int = 2, method='random',
self.cached_paths = list()
- def find_cex(self, hypothesis: Automaton):
+ def find_cex(self, hypothesis: Automaton) -> tuple | list | None:
+ """
+ Generates paths covering k-way transitions and executes them until a counterexample is found.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | list | None: Counterexample inputs, None if no counterexample is found.
+ """
if self.method == 'random':
paths = self.generate_random_paths(hypothesis) + self.cached_paths
self.cached_paths = self.greedy_set_cover(hypothesis, paths)
@@ -111,7 +121,14 @@ def find_cex(self, hypothesis: Automaton):
return None
- def greedy_set_cover(self, hypothesis: Automaton, paths: list):
+ def greedy_set_cover(self, hypothesis: Automaton, paths: list) -> list:
+ """
+ Greedily selects a subset of paths that covers as many k-way transitions as possible.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :param list paths: Candidate paths to choose from.
+ :return list: Selected subset of paths achieving (close to) full k-way transition coverage.
+ """
result = list()
covered = set()
step_count = 0
@@ -135,7 +152,14 @@ def greedy_set_cover(self, hypothesis: Automaton, paths: list):
return result
- def select_optimal_path(self, covered: set, paths: list) -> Path:
+ def select_optimal_path(self, covered: set, paths: list) -> Path | None:
+ """
+ Selects the path that contributes the most new coverage among the candidates.
+
+ :param set covered: Set of k-way transitions already covered.
+ :param list paths: Candidate paths to choose from.
+ :return Path | None: The path that adds the most new coverage, None if no path adds any.
+ """
result = None
if self.optimize == 'steps':
@@ -148,6 +172,12 @@ def select_optimal_path(self, covered: set, paths: list) -> Path:
return result if len(result.kWayTransitions - covered) != 0 else None
def generate_random_paths(self, hypothesis: Automaton) -> list:
+ """
+ Generates a batch of random-length paths of random inputs.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return list: List of generated Path instances.
+ """
result = list()
for _ in range(self.num_generate_paths):
@@ -158,13 +188,26 @@ def generate_random_paths(self, hypothesis: Automaton) -> list:
return result
- def generate_prefix_steps(self, hypothesis: Automaton):
+ def generate_prefix_steps(self, hypothesis: Automaton) -> Iterator[tuple]:
+ """
+ Yields paths built from each state's prefix, followed by all k-length continuations, and a random walk.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return Iterator[tuple]: Iterator of generated input sequences.
+ """
for state in reversed(hypothesis.states):
prefix = state.prefix
for steps in sorted(product(self.alphabet, repeat=self.k), key=lambda k: random()):
yield prefix + steps + tuple(choices(self.alphabet, k=self.random_walk_len))
def create_path(self, hypothesis: Automaton, steps: tuple) -> Path:
+ """
+ Executes the given steps on the hypothesis and records the k-way transitions it covers.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :param tuple steps: Input sequence to execute on the hypothesis.
+ :return Path: The path with its start/end states, steps, and covered k-way transitions.
+ """
transitions = set()
transitions_log = list()
@@ -190,7 +233,14 @@ def create_path(self, hypothesis: Automaton, steps: tuple) -> Path:
return Path(hypothesis.initial_state, end_states[-1], steps, transitions, transitions_log)
- def check_path(self, hypothesis: Automaton, steps: tuple):
+ def check_path(self, hypothesis: Automaton, steps: tuple) -> tuple | None:
+ """
+ Executes an input sequence step by step on both SUL and hypothesis, checking for output divergence.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :param tuple steps: Input sequence to execute.
+ :return tuple | None: Counterexample inputs (prefix of steps), None if no counterexample is found.
+ """
self.reset_hyp_and_sul(hypothesis)
for i, s in enumerate(steps):
diff --git a/aalpy/oracles/PacOracle.py b/aalpy/oracles/PacOracle.py
index 9519ccfc4e7..f84cd431549 100644
--- a/aalpy/oracles/PacOracle.py
+++ b/aalpy/oracles/PacOracle.py
@@ -1,7 +1,9 @@
+# Probably approximately correct (PAC) equivalence oracle.
from math import ceil, log
from random import choice, randint
from aalpy.base import Oracle, SUL
+from aalpy.base.Automaton import Automaton
class PacOracle(Oracle):
@@ -13,8 +15,18 @@ class PacOracle(Oracle):
Queries are of random length in a predefined range.
"""
- def __init__(self, alphabet: list, sul: SUL, epsilon=0.01, delta=0.01, min_walk_len=10, max_walk_len=25):
+ def __init__(self, alphabet: list, sul: SUL, epsilon: float = 0.01, delta: float = 0.01,
+ min_walk_len: int = 10, max_walk_len: int = 25) -> None:
+ """
+ Constructs the oracle.
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param float epsilon: Generalization error.
+ :param float delta: Confidence.
+ :param int min_walk_len: Minimum length of each random query.
+ :param int max_walk_len: Maximum length of each random query.
+ """
super().__init__(alphabet, sul)
self.min_walk_len = min_walk_len
self.max_walk_len = max_walk_len
@@ -22,7 +34,13 @@ def __init__(self, alphabet: list, sul: SUL, epsilon=0.01, delta=0.01, min_walk_
self.delta = delta
self.round = 0
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> list | None:
+ """
+ Performs a number of random-length queries, growing per round, until a counterexample is found.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return list | None: Counterexample inputs, None if no counterexample is found.
+ """
self.round += 1
num_test_cases = 1 / self.epsilon * (log(1 / self.delta) + self.round * log(2))
diff --git a/aalpy/oracles/PerfectKnowledgeEqOracle.py b/aalpy/oracles/PerfectKnowledgeEqOracle.py
index b627a75bcfe..4252cd4e7f5 100644
--- a/aalpy/oracles/PerfectKnowledgeEqOracle.py
+++ b/aalpy/oracles/PerfectKnowledgeEqOracle.py
@@ -1,3 +1,4 @@
+# Equivalence oracle that computes exact counterexamples via bisimilarity checking against the true model.
from aalpy.base import Oracle, SUL, DeterministicAutomaton
from aalpy.utils import bisimilar
@@ -7,9 +8,22 @@ class PerfectKnowledgeEqOracle(Oracle):
Oracle that can be used when developing and testing deterministic learning algorithms,
so that the focus is put off equivalence query.
"""
- def __init__(self, alphabet: list, sul: SUL, model_under_learning: DeterministicAutomaton):
+ def __init__(self, alphabet: list, sul: SUL, model_under_learning: DeterministicAutomaton) -> None:
+ """
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param DeterministicAutomaton model_under_learning: The ground-truth model to compare hypotheses against.
+ """
super().__init__(alphabet, sul, )
self.model_under_learning = model_under_learning
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: DeterministicAutomaton) -> tuple | None:
+ """
+ Checks bisimilarity between the hypothesis and the ground-truth model.
+
+ :param DeterministicAutomaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
return bisimilar(hypothesis, self.model_under_learning, return_cex=True)
diff --git a/aalpy/oracles/ProvidedSequencesOracleWrapper.py b/aalpy/oracles/ProvidedSequencesOracleWrapper.py
index e724f7f47f8..518e02acb84 100644
--- a/aalpy/oracles/ProvidedSequencesOracleWrapper.py
+++ b/aalpy/oracles/ProvidedSequencesOracleWrapper.py
@@ -1,24 +1,35 @@
+# Equivalence oracle wrapper that first replays user-provided candidate counterexamples before delegating.
from aalpy.base import Oracle, SUL
+from aalpy.base.Automaton import Automaton
class ProvidedSequencesOracleWrapper(Oracle):
- def __init__(self, alphabet: list, sul: SUL, oracle: Oracle, provided_counterexamples: list):
+ """
+ Oracle wrapper which first executes provided sequences (possible counterexamples) and then switches to another
+ oracle instance.
+ """
+
+ def __init__(self, alphabet: list, sul: SUL, oracle: Oracle, provided_counterexamples: list) -> None:
"""
- Oracle wrapper which first executes provided sequences (possible counterexamples) and then switches to another
- oracle instance.
-
- Args:
- alphabet: input alphabet
- sul: system under learning
- oracle: oracle which will be used once all provided counterexamples are used
- provided_counterexamples: list of input sequance lists. eg [[1,2,3], [2,3,1], ...] where 1,2,3 are elements
- of input alphabet
+ Constructs the oracle wrapper.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param Oracle oracle: Oracle which will be used once all provided counterexamples are used.
+ :param list provided_counterexamples: List of input sequence lists, e.g. [[1,2,3], [2,3,1], ...] where
+ 1,2,3 are elements of the input alphabet.
"""
super().__init__(alphabet, sul)
self.provided_counterexamples = provided_counterexamples
self.oracle = oracle
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | list | None:
+ """
+ Replays the remaining provided counterexamples, then delegates to the wrapped oracle.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | list | None: Counterexample inputs, None if no counterexample is found.
+ """
for provided_cex in self.provided_counterexamples.copy():
inputs = []
self.reset_hyp_and_sul(hypothesis)
diff --git a/aalpy/oracles/RandomWalkEqOracle.py b/aalpy/oracles/RandomWalkEqOracle.py
index a190e10aa9a..3c2b0c28ea6 100644
--- a/aalpy/oracles/RandomWalkEqOracle.py
+++ b/aalpy/oracles/RandomWalkEqOracle.py
@@ -1,7 +1,9 @@
+# Equivalence oracle that performs random walks with a per-step reset probability.
import random
from aalpy.automata import Onfsm, Mdp, StochasticMealyMachine
from aalpy.base import Oracle, SUL
+from aalpy.base.Automaton import Automaton
automaton_dict = {Onfsm: 'onfsm', Mdp: 'mdp', StochasticMealyMachine: 'smm'}
@@ -12,20 +14,17 @@ class RandomWalkEqOracle(Oracle):
that the system will reset and a new query asked.
"""
- def __init__(self, alphabet: list, sul: SUL, num_steps=5000, reset_after_cex=True, reset_prob=0.09):
+ def __init__(self, alphabet: list, sul: SUL, num_steps: int = 5000, reset_after_cex: bool = True,
+ reset_prob: float = 0.09) -> None:
"""
-
- Args:
- alphabet: input alphabet
-
- sul: system under learning
-
- num_steps: number of steps to be preformed
-
- reset_after_cex: if true, num_steps will be preformed after every counter example, else the total number
- or steps will equal to num_steps
-
- reset_prob: probability that the new query will be asked
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int num_steps: Number of steps to be performed.
+ :param bool reset_after_cex: If true, num_steps will be performed after every counterexample, else the
+ total number of steps will equal num_steps.
+ :param float reset_prob: Probability that a new query will be asked after each step.
"""
super().__init__(alphabet, sul)
@@ -35,7 +34,14 @@ def __init__(self, alphabet: list, sul: SUL, num_steps=5000, reset_after_cex=Tru
self.random_steps_done = 0
self.automata_type = None
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | list | None:
+ """
+ Performs a random walk, resetting probabilistically, until a counterexample is found or the step limit
+ is reached.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | list | None: Counterexample inputs, None if no counterexample is found.
+ """
if not self.automata_type:
self.automata_type = automaton_dict.get(type(hypothesis), 'det')
@@ -85,6 +91,9 @@ def find_cex(self, hypothesis):
return None
- def reset_counter(self):
+ def reset_counter(self) -> None:
+ """
+ Resets the count of random steps performed since the last reset/counterexample.
+ """
if self.reset_after_cex:
self.random_steps_done = 0
\ No newline at end of file
diff --git a/aalpy/oracles/RandomWordEqOracle.py b/aalpy/oracles/RandomWordEqOracle.py
index f401d88db8e..0e3610b3fa4 100644
--- a/aalpy/oracles/RandomWordEqOracle.py
+++ b/aalpy/oracles/RandomWordEqOracle.py
@@ -1,7 +1,9 @@
+# Equivalence oracle that performs full-reset random walks of random length in a predefined range.
from statistics import mean
from aalpy.automata import Onfsm, Mdp, StochasticMealyMachine
from aalpy.base import Oracle, SUL
+from aalpy.base.Automaton import Automaton
from random import randint, choice
automaton_dict = {Onfsm: 'onfsm', Mdp: 'mdp', StochasticMealyMachine: 'smm'}
@@ -12,22 +14,18 @@ class RandomWordEqOracle(Oracle):
Equivalence oracle where queries are of random length in a predefined range.
"""
- def __init__(self, alphabet: list, sul: SUL, num_walks=500, min_walk_len=10, max_walk_len=30,
- reset_after_cex=True):
+ def __init__(self, alphabet: list, sul: SUL, num_walks: int = 500, min_walk_len: int = 10,
+ max_walk_len: int = 30, reset_after_cex: bool = True) -> None:
"""
- Args:
- alphabet: input alphabet
-
- sul: system under learning
-
- num_walks: number of walks to perform during search for cex
-
- min_walk_len: minimum length of each walk
-
- max_walk_len: maximum length of each walk
-
- reset_after_cex: if True, num_walks will be preformed after every counter example, else the total number
- or walks will equal to num_walks
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int num_walks: Number of walks to perform during search for a counterexample.
+ :param int min_walk_len: Minimum length of each walk.
+ :param int max_walk_len: Maximum length of each walk.
+ :param bool reset_after_cex: If True, num_walks will be performed after every counterexample, else the
+ total number of walks will equal num_walks.
"""
super().__init__(alphabet, sul)
@@ -40,7 +38,14 @@ def __init__(self, alphabet: list, sul: SUL, num_walks=500, min_walk_len=10, max
self.walk_lengths = [randint(min_walk_len, max_walk_len) for _ in range(num_walks)]
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | list | None:
+ """
+ Performs random-length walks from the initial state until a counterexample is found or num_walks is
+ reached.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | list | None: Counterexample inputs, None if no counterexample is found.
+ """
if not self.automata_type:
self.automata_type = automaton_dict.get(type(hypothesis), 'det')
@@ -94,6 +99,9 @@ def find_cex(self, hypothesis):
return None
- def reset_counter(self):
+ def reset_counter(self) -> None:
+ """
+ Resets the count of walks performed since the last reset/counterexample.
+ """
if self.reset_after_cex:
self.num_walks_done = 0
diff --git a/aalpy/oracles/StatePrefixEqOracle.py b/aalpy/oracles/StatePrefixEqOracle.py
index bb78794dd83..4fb088eceee 100644
--- a/aalpy/oracles/StatePrefixEqOracle.py
+++ b/aalpy/oracles/StatePrefixEqOracle.py
@@ -1,7 +1,9 @@
+# Equivalence oracle that starts guided random walks from every state of the hypothesis.
import random
from aalpy.base.Oracle import Oracle
from aalpy.base.SUL import SUL
+from aalpy.base.Automaton import Automaton
class StatePrefixEqOracle(Oracle):
@@ -12,21 +14,17 @@ class StatePrefixEqOracle(Oracle):
rand_walk_len exactly walk_per_state times during learning. Therefore excessive testing of initial states is
avoided.
"""
- def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12, max_tests=None, depth_first=True):
+ def __init__(self, alphabet: list, sul: SUL, walks_per_state: int = 25, walk_len: int = 12,
+ max_tests: int | None = None, depth_first: bool = True) -> None:
"""
- Args:
-
- alphabet: input alphabet
-
- sul: system under learning
-
- walks_per_state:individual walks per state of the automaton over the whole learning process
-
- walk_len:length of random walk
-
- max_tests:number of maximum tests. If set to None, this parameter will be ignored.
-
- depth_first:first explore the newest states
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int walks_per_state: Individual walks per state of the automaton over the whole learning process.
+ :param int walk_len: Length of random walk.
+ :param int | None max_tests: Number of maximum tests. If set to None, this parameter will be ignored.
+ :param bool depth_first: First explore the newest states.
"""
super().__init__(alphabet, sul)
@@ -37,7 +35,13 @@ def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12, ma
self.freq_dict = dict()
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | None:
+ """
+ Starts random walks from every state that still needs coverage until a counterexample is found.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
states_to_cover = []
for state in hypothesis.states:
if state.prefix is None:
diff --git a/aalpy/oracles/TransitionFocusOracle.py b/aalpy/oracles/TransitionFocusOracle.py
index 6fca855aebc..c1f2f4e4939 100644
--- a/aalpy/oracles/TransitionFocusOracle.py
+++ b/aalpy/oracles/TransitionFocusOracle.py
@@ -1,7 +1,9 @@
+# Equivalence oracle that biases random walks towards same-state or different-state transitions.
import random
from aalpy.base.Oracle import Oracle
from aalpy.base.SUL import SUL
+from aalpy.base.Automaton import Automaton
class TransitionFocusOracle(Oracle):
@@ -11,14 +13,16 @@ class TransitionFocusOracle(Oracle):
all interesting behavior occurs on the transitions between states and potential bugs can be found only by
focusing on transitions.
"""
- def __init__(self, alphabet, sul: SUL, num_random_walks=500, walk_len=20, same_state_prob=0.2):
+ def __init__(self, alphabet: list, sul: SUL, num_random_walks: int = 500, walk_len: int = 20,
+ same_state_prob: float = 0.2) -> None:
"""
- Args:
- alphabet: input alphabet
- sul: system under learning
- num_random_walks: number of walks
- walk_len: length of each walk
- same_state_prob: probability that the next input will lead to same state transition
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int num_random_walks: Number of walks.
+ :param int walk_len: Length of each walk.
+ :param float same_state_prob: Probability that the next input will lead to a same-state transition.
"""
super().__init__(alphabet, sul)
@@ -26,8 +30,14 @@ def __init__(self, alphabet, sul: SUL, num_random_walks=500, walk_len=20, same_s
self.steps_per_walk = walk_len
self.same_state_prob = same_state_prob
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> list | None:
+ """
+ Performs random walks biased towards same-state or different-state transitions until a counterexample is
+ found.
+ :param Automaton hypothesis: Current hypothesis.
+ :return list | None: Counterexample inputs, None if no counterexample is found.
+ """
for _ in range(self.num_walks):
self.reset_hyp_and_sul(hypothesis)
diff --git a/aalpy/oracles/UserInputEqOracle.py b/aalpy/oracles/UserInputEqOracle.py
index df2826d5db5..e8a471c3072 100644
--- a/aalpy/oracles/UserInputEqOracle.py
+++ b/aalpy/oracles/UserInputEqOracle.py
@@ -1,4 +1,6 @@
+# Interactive equivalence oracle driven by user input at the command line.
from aalpy.base import Oracle, SUL
+from aalpy.base.Automaton import Automaton
from aalpy.utils.FileHandler import visualize_automaton
@@ -22,12 +24,24 @@ class UserInputEqOracle(Oracle):
reset - resets the current state of the hypothesis and clears inputs
"""
- def __init__(self, alphabet: list, sul: SUL):
+ def __init__(self, alphabet: list, sul: SUL) -> None:
+ """
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ """
super().__init__(alphabet, sul)
self.curr_hypothesis = 0
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> list | None:
+ """
+ Visualizes the current hypothesis and lets the user interactively step through it to build or reject a
+ counterexample.
+ :param Automaton hypothesis: Current hypothesis.
+ :return list | None: Counterexample inputs, None if the user indicates no counterexample exists.
+ """
self.reset_hyp_and_sul(hypothesis)
self.curr_hypothesis += 1
diff --git a/aalpy/oracles/WMethodEqOracle.py b/aalpy/oracles/WMethodEqOracle.py
index b629d637bc6..fac756c38a1 100644
--- a/aalpy/oracles/WMethodEqOracle.py
+++ b/aalpy/oracles/WMethodEqOracle.py
@@ -1,7 +1,10 @@
+# Equivalence oracles based on the W-method and its randomized variant.
+from collections.abc import Iterator
from random import shuffle, choice, randint
from aalpy.base.Oracle import Oracle
from aalpy.base.SUL import SUL
+from aalpy.base.Automaton import Automaton
from itertools import product
@@ -11,29 +14,28 @@ class WMethodEqOracle(Oracle):
finite-state machines'.
"""
- def __init__(self, alphabet: list, sul: SUL, max_number_of_states):
+ def __init__(self, alphabet: list, sul: SUL, max_number_of_states: int) -> None:
"""
- Args:
+ Constructs the oracle.
- alphabet: input alphabet
- sul: system under learning
- max_number_of_states: maximum number of states in the automaton
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int max_number_of_states: Maximum number of states in the automaton.
"""
super().__init__(alphabet, sul)
self.m = max_number_of_states
self.cache = set()
- def test_suite(self, cover, depth, char_set):
+ def test_suite(self, cover: list, depth: int, char_set: list) -> Iterator[tuple]:
"""
- Construct the test suite for the W Method using
- the provided state cover and characterization set,
+ Constructs the test suite for the W Method using the provided state cover and characterization set,
exploring up to a given depth.
- Args:
- cover: list of states to cover
- depth: maximum length of middle part
- char_set: characterization set
+ :param list cover: List of states to cover.
+ :param int depth: Maximum length of middle part.
+ :param list char_set: Characterization set.
+ :return Iterator[tuple]: Iterator of generated test sequences.
"""
# fix the length of the middle part per loop
# to avoid generating large sequences early on
@@ -44,8 +46,13 @@ def test_suite(self, cover, depth, char_set):
for (s, c) in product(cover, char_set):
yield s + m + c
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | None:
+ """
+ Runs the W-method test suite against the SUL until a counterexample is found.
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
if not hypothesis.characterization_set:
hypothesis.characterization_set = hypothesis.compute_characterization_set()
@@ -85,17 +92,14 @@ class RandomWMethodEqOracle(Oracle):
walk an element from the characterization set is added to the test case.
"""
- def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12):
+ def __init__(self, alphabet: list, sul: SUL, walks_per_state: int = 25, walk_len: int = 12) -> None:
"""
- Args:
-
- alphabet: input alphabet
-
- sul: system under learning
+ Constructs the oracle.
- walks_per_state: number of random walks that should start from each state
-
- walk_len: length of random walk
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int walks_per_state: Number of random walks that should start from each state.
+ :param int walk_len: Length of random walk.
"""
super().__init__(alphabet, sul)
@@ -103,8 +107,14 @@ def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12):
self.random_walk_len = walk_len
self.freq_dict = dict()
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | None:
+ """
+ Performs random walks from each state, ending with a characterizing suffix, until a counterexample is
+ found.
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
if not hypothesis.characterization_set:
hypothesis.characterization_set = hypothesis.compute_characterization_set()
# fix for non-minimal intermediate hypothesis that can occur in KV
diff --git a/aalpy/oracles/WpMethodEqOracle.py b/aalpy/oracles/WpMethodEqOracle.py
index b3c0ac453a3..89e921bec41 100644
--- a/aalpy/oracles/WpMethodEqOracle.py
+++ b/aalpy/oracles/WpMethodEqOracle.py
@@ -1,16 +1,21 @@
+# Equivalence oracles based on the Wp-method and its randomized variant.
import random
+from collections.abc import Iterator
+
from aalpy.base.Oracle import Oracle
from aalpy.base.SUL import SUL
+from aalpy.base.Automaton import Automaton, AutomatonState
from itertools import chain, product
-def state_characterization_set(hypothesis, alphabet, state):
+def state_characterization_set(hypothesis: Automaton, alphabet: list, state: AutomatonState) -> list[tuple]:
"""
- Return a list of sequences that distinguish the given state from all other states in the hypothesis.
- Args:
- hypothesis: hypothesis automaton
- alphabet: input alphabet
- state: state for which to find distinguishing sequences
+ Returns a list of sequences that distinguish the given state from all other states in the hypothesis.
+
+ :param Automaton hypothesis: Hypothesis automaton.
+ :param list alphabet: Input alphabet.
+ :param AutomatonState state: State for which to find distinguishing sequences.
+ :return list[tuple]: List of distinguishing sequences.
"""
result = []
for i in range(len(hypothesis.states)):
@@ -22,14 +27,15 @@ def state_characterization_set(hypothesis, alphabet, state):
return result
-def first_phase_it(alphabet, state_cover, depth, char_set):
+def first_phase_it(alphabet: list, state_cover: set, depth: int, char_set: list) -> Iterator[tuple]:
"""
- Return an iterator that generates all possible sequences for the first phase of the Wp-method.
- Args:
- alphabet: input alphabet
- state_cover: list of states to cover
- depth: maximum length of middle part
- char_set: characterization set
+ Returns an iterator that generates all possible sequences for the first phase of the Wp-method.
+
+ :param list alphabet: Input alphabet.
+ :param set state_cover: Set of state prefixes to cover.
+ :param int depth: Maximum length of middle part.
+ :param list char_set: Characterization set.
+ :return Iterator[tuple]: Iterator of generated test sequences.
"""
char_set = char_set or [()]
for d in range(depth):
@@ -40,14 +46,15 @@ def first_phase_it(alphabet, state_cover, depth, char_set):
yield s + m + c
-def second_phase_it(hyp, alphabet, difference, depth):
+def second_phase_it(hyp: Automaton, alphabet: list, difference: set, depth: int) -> Iterator[tuple]:
"""
- Return an iterator that generates all possible sequences for the second phase of the Wp-method.
- Args:
- hyp: hypothesis automaton
- alphabet: input alphabet
- difference: set of sequences that are in the transition cover but not in the state cover
- depth: maximum length of middle part
+ Returns an iterator that generates all possible sequences for the second phase of the Wp-method.
+
+ :param Automaton hyp: Hypothesis automaton.
+ :param list alphabet: Input alphabet.
+ :param set difference: Set of sequences that are in the transition cover but not in the state cover.
+ :param int depth: Maximum length of middle part.
+ :return Iterator[tuple]: Iterator of generated test sequences.
"""
state_mapping = {}
for d in range(depth):
@@ -68,12 +75,25 @@ class WpMethodEqOracle(Oracle):
Implements the Wp-method equivalence oracle.
"""
- def __init__(self, alphabet: list, sul: SUL, max_number_of_states=4):
+ def __init__(self, alphabet: list, sul: SUL, max_number_of_states: int = 4) -> None:
+ """
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int max_number_of_states: Maximum number of states in the automaton.
+ """
super().__init__(alphabet, sul)
self.m = max_number_of_states
self.cache = set()
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | None:
+ """
+ Runs the Wp-method test suite (first and second phase) against the SUL until a counterexample is found.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
if not hypothesis.characterization_set:
hypothesis.characterization_set = hypothesis.compute_characterization_set()
@@ -124,13 +144,29 @@ class RandomWpMethodEqOracle(Oracle):
"""
def __init__(
- self, alphabet: list, sul: SUL, min_length=1, expected_length=10, num_tests=1000,):
+ self, alphabet: list, sul: SUL, min_length: int = 1, expected_length: int = 10,
+ num_tests: int = 1000,) -> None:
+ """
+ Constructs the oracle.
+
+ :param list alphabet: Input alphabet.
+ :param SUL sul: System under learning.
+ :param int min_length: Minimum length of the random middle part.
+ :param int expected_length: Expected length of the random middle part (geometric distribution parameter).
+ :param int num_tests: Number of random tests to perform.
+ """
super().__init__(alphabet, sul)
self.min_length = min_length
self.expected_length = expected_length
self.bound = num_tests
- def find_cex(self, hypothesis):
+ def find_cex(self, hypothesis: Automaton) -> tuple | None:
+ """
+ Samples random tests (prefix + random word + suffix) until a counterexample is found.
+
+ :param Automaton hypothesis: Current hypothesis.
+ :return tuple | None: Counterexample inputs, None if no counterexample is found.
+ """
# fix for non-minimal intermediate hypothesis that can occur in KV
hypothesis.characterization_set = hypothesis.compute_characterization_set()
if not hypothesis.characterization_set:
diff --git a/aalpy/oracles/__init__.py b/aalpy/oracles/__init__.py
index fc2bbe6b7ea..4d167a6d91b 100644
--- a/aalpy/oracles/__init__.py
+++ b/aalpy/oracles/__init__.py
@@ -1,3 +1,4 @@
+# Exposes all built-in equivalence oracle implementations.
from .BreadthFirstExplorationEqOracle import BreadthFirstExplorationEqOracle
from .CacheBasedEqOracle import CacheBasedEqOracle
from .KWayStateCoverageEqOracle import KWayStateCoverageEqOracle
diff --git a/aalpy/utils/AutomatonGenerators.py b/aalpy/utils/AutomatonGenerators.py
index 2d975b69e98..3fc91192bcd 100644
--- a/aalpy/utils/AutomatonGenerators.py
+++ b/aalpy/utils/AutomatonGenerators.py
@@ -1,36 +1,34 @@
+# Random generators for deterministic, stochastic, non-deterministic and visibly pushdown automata.
import random
import warnings
+from typing import Any
from aalpy.automata import Dfa, DfaState, MdpState, Mdp, MealyMachine, MealyState, \
MooreMachine, MooreState, OnfsmState, Onfsm, MarkovChain, McState, StochasticMealyState, StochasticMealyMachine, \
Sevpa, SevpaState, SevpaAlphabet, SevpaTransition
-def generate_random_deterministic_automata(automaton_type,
- num_states,
- input_alphabet_size,
- output_alphabet_size=None,
- ensure_minimality=True,
- **kwargs
- ):
+def generate_random_deterministic_automata(automaton_type: str,
+ num_states: int,
+ input_alphabet_size: int,
+ output_alphabet_size: int | None = None,
+ ensure_minimality: bool = True,
+ **kwargs: Any
+ ) -> Dfa | MealyMachine | MooreMachine:
"""
Generates a random deterministic automata of 'automaton_type'.
- Args:
- automaton_type: type of automaton, either 'dfa', 'mealy', or 'moore'
- num_states: number of states
- input_alphabet_size: size of input alphabet
- output_alphabet_size: size of output alphabet. (ignored for DFAs)
- ensure_minimality: ensure that the automaton is minimal
- **kwargs:
- : 'num_accepting_states' number of accepting states for DFA generation. If not defined, half of states will
- be accepting
-
- Returns:
-
- Random deterministic automaton of user defined type, size. If ensure_minimality is set to False returned
- automaton is not necessarily minimal. If minimality is reacquired and random automaton cannot be produced in
- multiple interactions, non-minimal automaton will be returned and a warning message printed.
+ :param str automaton_type: type of automaton, either 'dfa', 'mealy', or 'moore'
+ :param int num_states: number of states
+ :param int input_alphabet_size: size of input alphabet
+ :param int | None output_alphabet_size: size of output alphabet (ignored for DFAs)
+ :param bool ensure_minimality: ensure that the automaton is minimal
+ :param Any kwargs: 'num_accepting_states' number of accepting states for DFA generation (if not defined, half
+ of states will be accepting), 'custom_input_alphabet', 'custom_output_alphabet'
+ :return Dfa | MealyMachine | MooreMachine: Random deterministic automaton of user defined type, size. If
+ ensure_minimality is set to False returned automaton is not necessarily minimal. If minimality is reacquired
+ and random automaton cannot be produced in multiple interactions, non-minimal automaton will be returned and
+ a warning message printed.
"""
assert automaton_type in {'dfa', 'mealy', 'moore'}
@@ -143,22 +141,17 @@ def generate_random_deterministic_automata(automaton_type,
return random_automaton
-def generate_random_mealy_machine(num_states, input_alphabet, output_alphabet,
- compute_prefixes=False, ensure_minimality=True) -> MealyMachine:
+def generate_random_mealy_machine(num_states: int, input_alphabet: list, output_alphabet: list,
+ compute_prefixes: bool = False, ensure_minimality: bool = True) -> MealyMachine:
"""
Generates a random Mealy machine. Kept for backwards compatibility.
- Args:
-
- num_states: number of states
- input_alphabet: input alphabet
- output_alphabet: output alphabet
- compute_prefixes: if true, shortest path to reach each state will be computed (Default value = False)
- ensure_minimality: returned automaton will be minimal
-
- Returns:
-
- Mealy machine with num_states states
+ :param int num_states: number of states
+ :param list input_alphabet: input alphabet
+ :param list output_alphabet: output alphabet
+ :param bool compute_prefixes: if true, shortest path to reach each state will be computed
+ :param bool ensure_minimality: returned automaton will be minimal
+ :return MealyMachine: Mealy machine with num_states states
"""
random_mealy_machine = generate_random_deterministic_automata('mealy', num_states,
@@ -172,23 +165,17 @@ def generate_random_mealy_machine(num_states, input_alphabet, output_alphabet,
return random_mealy_machine
-def generate_random_moore_machine(num_states, input_alphabet, output_alphabet,
- compute_prefixes=False, ensure_minimality=True) -> MooreMachine:
+def generate_random_moore_machine(num_states: int, input_alphabet: list, output_alphabet: list,
+ compute_prefixes: bool = False, ensure_minimality: bool = True) -> MooreMachine:
"""
Generates a random Moore machine.
- Args:
-
- num_states: number of states
- input_alphabet: input alphabet
- output_alphabet: output alphabet
- compute_prefixes: if true, shortest path to reach each state will be computed (Default value = False)
- ensure_minimality: returned automaton will be minimal
-
- Returns:
-
- Random Moore machine with num_states states
-
+ :param int num_states: number of states
+ :param list input_alphabet: input alphabet
+ :param list output_alphabet: output alphabet
+ :param bool compute_prefixes: if true, shortest path to reach each state will be computed
+ :param bool ensure_minimality: returned automaton will be minimal
+ :return MooreMachine: Random Moore machine with num_states states
"""
random_moore_machine = generate_random_deterministic_automata('moore', num_states,
input_alphabet_size=len(input_alphabet),
@@ -201,23 +188,17 @@ def generate_random_moore_machine(num_states, input_alphabet, output_alphabet,
return random_moore_machine
-def generate_random_dfa(num_states, alphabet, num_accepting_states=1,
- compute_prefixes=False, ensure_minimality=True) -> Dfa:
+def generate_random_dfa(num_states: int, alphabet: list, num_accepting_states: int = 1,
+ compute_prefixes: bool = False, ensure_minimality: bool = True) -> Dfa:
"""
Generates a random DFA.
- Args:
-
- num_states: number of states
- alphabet: input alphabet
- num_accepting_states: number of accepting states (Default value = 1)
- compute_prefixes: if true, shortest path to reach each state will be computed (Default value = False)
- ensure_minimality: returned automaton will be minimal
-
- Returns:
-
- Randomly generated DFA
-
+ :param int num_states: number of states
+ :param list alphabet: input alphabet
+ :param int num_accepting_states: number of accepting states
+ :param bool compute_prefixes: if true, shortest path to reach each state will be computed
+ :param bool ensure_minimality: returned automaton will be minimal
+ :return Dfa: Randomly generated DFA
"""
if num_states <= num_accepting_states:
num_accepting_states = num_states // 2
@@ -233,21 +214,16 @@ def generate_random_dfa(num_states, alphabet, num_accepting_states=1,
return random_dfa
-def generate_random_mdp(num_states, input_size, output_size, possible_probabilities=None):
+def generate_random_mdp(num_states: int, input_size: int, output_size: int,
+ possible_probabilities: list[tuple] | None = None) -> Mdp:
"""
Generates random MDP.
- Args:
-
- num_states: number of states
- input_size: number of inputs
- output_size: user predefined outputs
- possible_probabilities: list of possible probability pairs to choose from
-
- Returns:
-
- random MDP
-
+ :param int num_states: number of states
+ :param int input_size: number of inputs
+ :param int output_size: user predefined outputs
+ :param list[tuple] | None possible_probabilities: list of possible probability pairs to choose from
+ :return Mdp: random MDP
"""
deterministic_model = generate_random_deterministic_automata('moore', num_states, input_size, output_size)
@@ -296,21 +272,16 @@ def generate_random_mdp(num_states, input_size, output_size, possible_probabilit
return Mdp(mdp_states[0], mdp_states)
-def generate_random_smm(num_states, input_size, output_size, possible_probabilities=None):
+def generate_random_smm(num_states: int, input_size: int, output_size: int,
+ possible_probabilities: list[tuple] | None = None) -> StochasticMealyMachine:
"""
Generates random SMM.
- Args:
-
- num_states: number of states
- input_size: number of inputs
- output_size: number of outputs
- possible_probabilities: list of possible probability pairs to choose from
-
- Returns:
-
- random SMM
-
+ :param int num_states: number of states
+ :param int input_size: number of inputs
+ :param int output_size: number of outputs
+ :param list[tuple] | None possible_probabilities: list of possible probability pairs to choose from
+ :return StochasticMealyMachine: random SMM
"""
deterministic_model = generate_random_deterministic_automata('mealy', num_states, input_size, output_size)
@@ -357,21 +328,16 @@ def generate_random_smm(num_states, input_size, output_size, possible_probabilit
return StochasticMealyMachine(smm_states[0], smm_states)
-def generate_random_ONFSM(num_states, num_inputs, num_outputs, multiple_out_prob=0.33):
+def generate_random_ONFSM(num_states: int, num_inputs: int, num_outputs: int,
+ multiple_out_prob: float = 0.33) -> Onfsm:
"""
Randomly generate an observable non-deterministic finite-state machine.
- Args:
-
- num_states: number of states
- num_inputs: number of inputs
- num_outputs: number of outputs
- multiple_out_prob: probability that state will have multiple outputs (Default value = 0.5)
-
- Returns:
-
- randomly generated ONFSM
-
+ :param int num_states: number of states
+ :param int num_inputs: number of inputs
+ :param int num_outputs: number of outputs
+ :param float multiple_out_prob: probability that state will have multiple outputs
+ :return Onfsm: randomly generated ONFSM
"""
inputs = [f'i{i + 1}' for i in range(num_inputs)]
outputs = [f'o{i + 1}' for i in range(num_outputs)]
@@ -401,7 +367,13 @@ def generate_random_ONFSM(num_states, num_inputs, num_outputs, multiple_out_prob
return Onfsm(states[0], states)
-def generate_random_markov_chain(num_states):
+def generate_random_markov_chain(num_states: int) -> MarkovChain:
+ """
+ Generates a random Markov chain.
+
+ :param int num_states: number of states
+ :return MarkovChain: randomly generated Markov chain
+ """
assert num_states >= 3
possible_probabilities = [1.0, 1.0, 0.8, 0.5, 0.9]
states = []
@@ -426,7 +398,15 @@ def generate_random_markov_chain(num_states):
return MarkovChain(states[0], states)
-def _has_transition(state: SevpaState, transition_letter, stack_guard) -> bool:
+def _has_transition(state: SevpaState, transition_letter: Any, stack_guard: tuple | None) -> bool:
+ """
+ Checks whether a SEVPA state already has a transition matching the given letter (and stack guard).
+
+ :param SevpaState state: state to check
+ :param Any transition_letter: input letter of the transition
+ :param tuple | None stack_guard: stack guard of the transition, None for internal transitions
+ :return bool: True if a matching transition already exists, False otherwise
+ """
transitions = state.transitions[transition_letter]
if transitions is not None:
if stack_guard is None: # internal transition
@@ -441,21 +421,19 @@ def _has_transition(state: SevpaState, transition_letter, stack_guard) -> bool:
return False
-def generate_random_sevpa(num_states, internal_alphabet_size, call_alphabet_size, return_alphabet_size
- , acceptance_prob, return_transition_prob):
+def generate_random_sevpa(num_states: int, internal_alphabet_size: int, call_alphabet_size: int,
+ return_alphabet_size: int, acceptance_prob: float,
+ return_transition_prob: float) -> Sevpa:
"""
Generate a random Single Entry Visibly Pushdown Automaton (SEVPA).
- Args:
- num_states (int): The number of states in the SEVPA.
- internal_alphabet_size (int): The size of the internal alphabet.
- call_alphabet_size (int): The size of the call alphabet.
- return_alphabet_size (int): The size of the return alphabet.
- acceptance_prob (float): The probability of a state being an accepting state.
- return_transition_prob (float): The probability of generating a return transition.
-
- Returns:
- Sevpa: A randomly generated SEVPA.
+ :param int num_states: The number of states in the SEVPA.
+ :param int internal_alphabet_size: The size of the internal alphabet.
+ :param int call_alphabet_size: The size of the call alphabet.
+ :param int return_alphabet_size: The size of the return alphabet.
+ :param float acceptance_prob: The probability of a state being an accepting state.
+ :param float return_transition_prob: The probability of generating a return transition.
+ :return Sevpa: A randomly generated SEVPA.
"""
internal_alphabet = [f'i{i}' for i in range(internal_alphabet_size)]
diff --git a/aalpy/utils/BenchmarkSULs.py b/aalpy/utils/BenchmarkSULs.py
index 6a812b8784a..393b5bc318b 100644
--- a/aalpy/utils/BenchmarkSULs.py
+++ b/aalpy/utils/BenchmarkSULs.py
@@ -1,4 +1,11 @@
-def get_Angluin_dfa():
+# Hand-crafted example automata and systems under learning used throughout benchmarks, examples and tests.
+from typing import Any
+
+
+def get_Angluin_dfa() -> 'Dfa':
+ """
+ :return Dfa: The classical DFA example used by Angluin to illustrate L*.
+ """
from aalpy.automata import Dfa
angluin_dfa = {
@@ -11,10 +18,12 @@ def get_Angluin_dfa():
return Dfa.from_state_setup(angluin_dfa)
-def get_benchmark_ONFSM():
+def get_benchmark_ONFSM() -> 'Onfsm':
"""
Returns ONFSM presented in 'Learning Finite State Models of Observable Nondeterministic Systems in a Testing
Context'.
+
+ :return Onfsm: The example ONFSM.
"""
from aalpy.automata import Onfsm, OnfsmState
@@ -40,9 +49,11 @@ def get_benchmark_ONFSM():
return Onfsm(a, [a, b, c, d])
-def get_ONFSM():
+def get_ONFSM() -> 'Onfsm':
"""
Returns example of an ONFSM.
+
+ :return Onfsm: The example ONFSM.
"""
from aalpy.automata import Onfsm, OnfsmState
@@ -86,7 +97,10 @@ def get_ONFSM():
return Onfsm(q0, [q0, q1, q2, q3, q4, q5, q6, q7, q8])
-def get_faulty_coffee_machine_MDP():
+def get_faulty_coffee_machine_MDP() -> 'Mdp':
+ """
+ :return Mdp: An MDP modeling a coffee machine that occasionally serves coffee without the beep step.
+ """
from aalpy.automata import Mdp, MdpState
q0 = MdpState("q0", "init")
@@ -106,7 +120,10 @@ def get_faulty_coffee_machine_MDP():
return mdp
-def get_weird_coffee_machine_MDP():
+def get_weird_coffee_machine_MDP() -> 'Mdp':
+ """
+ :return Mdp: An MDP modeling a coffee machine with an alternate 'koin' input path that can crash the machine.
+ """
from aalpy.automata import Mdp, MdpState
q0 = MdpState("q0", "init")
@@ -154,7 +171,11 @@ def get_weird_coffee_machine_MDP():
return mdp
-def get_faulty_coffee_machine_SMM():
+def get_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine':
+ """
+ :return StochasticMealyMachine: A stochastic Mealy machine modeling a coffee machine that occasionally serves
+ coffee without the beep step.
+ """
from aalpy.automata import StochasticMealyMachine, StochasticMealyState
s0 = StochasticMealyState('q0')
@@ -174,7 +195,10 @@ def get_faulty_coffee_machine_SMM():
return smm
-def get_minimal_faulty_coffee_machine_SMM():
+def get_minimal_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine':
+ """
+ :return StochasticMealyMachine: A minimal 2-state stochastic Mealy machine modeling the faulty coffee machine.
+ """
from aalpy.automata import StochasticMealyMachine, StochasticMealyState
s0 = StochasticMealyState('q0')
@@ -191,7 +215,10 @@ def get_minimal_faulty_coffee_machine_SMM():
return smm
-def get_faulty_mqtt_SMM():
+def get_faulty_mqtt_SMM() -> 'StochasticMealyMachine':
+ """
+ :return StochasticMealyMachine: A stochastic Mealy machine modeling a faulty MQTT broker.
+ """
from aalpy.automata import StochasticMealyMachine, StochasticMealyState
s0 = StochasticMealyState('q0')
@@ -223,7 +250,11 @@ def get_faulty_mqtt_SMM():
return smm
-def get_small_gridworld():
+def get_small_gridworld() -> 'StochasticMealyMachine':
+ """
+ :return StochasticMealyMachine: A stochastic Mealy machine modeling a small 2x2 gridworld with mud and grass
+ tiles.
+ """
from aalpy.automata import StochasticMealyMachine, StochasticMealyState
s0 = StochasticMealyState('q0')
@@ -274,12 +305,24 @@ def get_small_gridworld():
class MockMqttExample:
+ """
+ Mock implementation of an MQTT broker's connect/publish/subscribe API, used as a system under learning.
+ """
- def __init__(self):
+ def __init__(self) -> None:
+ """
+ Creates the mock broker in its initial, disconnected state.
+ """
self.state = 'CONCLOSED'
self.topics = set()
- def subscribe(self, topic: str):
+ def subscribe(self, topic: str) -> str:
+ """
+ Subscribes to a topic, unless it contains a newline or a null character.
+
+ :param str topic: Topic to subscribe to.
+ :return str: The resulting broker state.
+ """
if '\n' in topic or '\u0000' in topic:
self.state = 'CONCLOSED'
self.topics.clear()
@@ -289,7 +332,13 @@ def subscribe(self, topic: str):
return self.state
- def unsubscribe(self, topic):
+ def unsubscribe(self, topic: str) -> str:
+ """
+ Unsubscribes from a topic, unless it contains a newline or a null character.
+
+ :param str topic: Topic to unsubscribe from.
+ :return str: The resulting broker state.
+ """
if '\n' in topic or '\u0000' in topic:
self.state = 'CONCLOSED'
self.topics.clear()
@@ -300,7 +349,12 @@ def unsubscribe(self, topic):
return self.state
- def connect(self):
+ def connect(self) -> str:
+ """
+ Connects the mock broker, or resets it if already connected.
+
+ :return str: The resulting broker state.
+ """
if self.state == 'CONCLOSED':
self.state = 'CONNACK'
else:
@@ -308,12 +362,23 @@ def connect(self):
self.state = 'CONCLOSED'
return self.state
- def disconnect(self):
+ def disconnect(self) -> str:
+ """
+ Disconnects the mock broker and clears its subscribed topics.
+
+ :return str: The resulting broker state.
+ """
self.state = 'CONCLOSED'
self.topics.clear()
return self.state
- def publish(self, topic):
+ def publish(self, topic: str) -> str:
+ """
+ Publishes to a topic, unless it contains a newline or a null character.
+
+ :param str topic: Topic to publish to.
+ :return str: The resulting broker state.
+ """
if '\n' in topic or '\u0000' in topic:
self.state = 'CONCLOSED'
self.topics.clear()
@@ -332,7 +397,13 @@ class DateValidator:
The format of the dates is %d/%m/%Y'
"""
- def is_date_accepted(self, date_string: str):
+ def is_date_accepted(self, date_string: str) -> bool:
+ """
+ Checks whether a date string is accepted by the validator.
+
+ :param str date_string: Date string in %d/%m/%Y format.
+ :return bool: True if the date is accepted, False otherwise.
+ """
values = date_string.split('/')
if len(values) != 3:
return False
@@ -358,7 +429,10 @@ def is_date_accepted(self, date_string: str):
return True
-def get_small_pomdp():
+def get_small_pomdp() -> 'Mdp':
+ """
+ :return Mdp: An MDP with partially observable states (a small POMDP-like example).
+ """
from aalpy.automata import Mdp, MdpState
q0 = MdpState("q0", "init")
@@ -387,7 +461,15 @@ def get_small_pomdp():
return Mdp(q0, [q0, q1, q2, q3, q4])
-def is_balanced(test_string, call_return_map, allow_empty_string):
+def is_balanced(test_string: str, call_return_map: dict, allow_empty_string: bool) -> bool:
+ """
+ Checks whether a string of call/return symbols is balanced with respect to a call-return mapping.
+
+ :param str test_string: The string to check.
+ :param dict call_return_map: Map from call (opening) symbols to their matching return (closing) symbols.
+ :param bool allow_empty_string: Whether an empty string counts as balanced.
+ :return bool: True if the string is balanced, False otherwise.
+ """
stack = []
# Create a set of open and close characters for faster lookup
open_chars = set(call_return_map.keys())
@@ -408,23 +490,52 @@ def is_balanced(test_string, call_return_map, allow_empty_string):
return not stack if allow_empty_string else not stack and len(test_string) > 0
-def get_balanced_string_sul(call_return_map, allow_empty_string):
+def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) -> 'SUL':
+ """
+ Creates a SUL that accepts balanced strings of call/return symbols.
+
+ :param dict call_return_map: Map from call (opening) symbols to their matching return (closing) symbols.
+ :param bool allow_empty_string: Whether an empty string counts as balanced.
+ :return SUL: The constructed SUL.
+ """
from aalpy.base import SUL
class BalancedStringSUL(SUL):
- def __init__(self, call_return_map, allow_empty_string):
+ """
+ System under learning that checks whether the sequence of inputs seen so far is a balanced string.
+ """
+
+ def __init__(self, call_return_map: dict, allow_empty_string: bool) -> None:
+ """
+ Creates a balanced-string SUL.
+
+ :param dict call_return_map: Map from call (opening) symbols to their matching return (closing) symbols.
+ :param bool allow_empty_string: Whether an empty string counts as balanced.
+ """
super(BalancedStringSUL, self).__init__()
self.call_return_map = call_return_map
self.allow_empty_string = allow_empty_string
self.sting_under_test = []
- def pre(self):
+ def pre(self) -> None:
+ """
+ Resets the accumulated string under test.
+ """
self.sting_under_test = []
- def post(self):
+ def post(self) -> None:
+ """
+ Performs no additional cleanup.
+ """
pass
- def step(self, letter):
+ def step(self, letter: Any) -> bool:
+ """
+ Appends the letter to the string under test and checks whether it is balanced.
+
+ :param Any letter: Single element of the input alphabet.
+ :return bool: True if the accumulated string is balanced, False otherwise.
+ """
if letter:
self.sting_under_test += letter
return is_balanced(self.sting_under_test, self.call_return_map, self.allow_empty_string)
diff --git a/aalpy/utils/BenchmarkSevpaModels.py b/aalpy/utils/BenchmarkSevpaModels.py
index 8d2714e88fc..94630d3383d 100644
--- a/aalpy/utils/BenchmarkSevpaModels.py
+++ b/aalpy/utils/BenchmarkSevpaModels.py
@@ -1,8 +1,14 @@
+# Collection of example SEVPAs (Single-Entry Visibly Pushdown Automata) used for benchmarking learning algorithms.
from aalpy.automata.Sevpa import Sevpa
from aalpy.utils import load_automaton_from_file
-def sevpa_for_L1():
+def sevpa_for_L1() -> Sevpa:
+ """
+ Builds an example SEVPA for language L1.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'b': [('q1', 'pop', ('q0', 'a'))]
}),
@@ -12,7 +18,12 @@ def sevpa_for_L1():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L2():
+def sevpa_for_L2() -> Sevpa:
+ """
+ Builds an example SEVPA for language L2.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'d': [('q1', 'pop', ('q0', 'a')), ('q1', 'pop', ('q0', 'b'))],
'c': [('q1', 'pop', ('q0', 'a')), ('q1', 'pop', ('q0', 'b'))]
@@ -25,7 +36,12 @@ def sevpa_for_L2():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L3():
+def sevpa_for_L3() -> Sevpa:
+ """
+ Builds an example SEVPA for language L3.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'g': [('q6', 'pop', ('q0', 'd')),
('q4', 'pop', ('q0', 'b'))],
@@ -50,7 +66,12 @@ def sevpa_for_L3():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L4():
+def sevpa_for_L4() -> Sevpa:
+ """
+ Builds an example SEVPA for language L4.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'c': [('q2', 'pop', ('q0', 'b'))]
}),
@@ -62,7 +83,12 @@ def sevpa_for_L4():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L5():
+def sevpa_for_L5() -> Sevpa:
+ """
+ Builds an example SEVPA for language L5.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'d': [('q2', 'pop', ('q0', 'c'))]
}),
@@ -77,7 +103,12 @@ def sevpa_for_L5():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L7():
+def sevpa_for_L7() -> Sevpa:
+ """
+ Builds an example SEVPA for language L7 (balanced parentheses and brackets).
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {')': [('q1', 'pop', ('q0', '(')),
('q1', 'pop', ('q1', '('))],
@@ -95,7 +126,12 @@ def sevpa_for_L7():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L8():
+def sevpa_for_L8() -> Sevpa:
+ """
+ Builds an example SEVPA for language L8 (balanced parentheses, braces and brackets).
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {')': [('q1', 'pop', ('q0', '(')),
('q1', 'pop', ('q1', '('))],
@@ -115,7 +151,12 @@ def sevpa_for_L8():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L9():
+def sevpa_for_L9() -> Sevpa:
+ """
+ Builds an example SEVPA for language L9 (balanced brackets, braces, parentheses and angle brackets).
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {']': [('q1', 'pop', ('q0', '[')),
('q1', 'pop', ('q1', '['))],
@@ -140,7 +181,12 @@ def sevpa_for_L9():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L10():
+def sevpa_for_L10() -> Sevpa:
+ """
+ Builds an example SEVPA for language L10.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
"q0": (False, {"b": [("qb", None, None)],
}),
@@ -171,7 +217,12 @@ def sevpa_for_L10():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L11():
+def sevpa_for_L11() -> Sevpa:
+ """
+ Builds an example SEVPA for language L11.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'i1': [('q2', None, None)],
'r1': [('q3', 'pop', ('q0', 'c2')),
@@ -204,7 +255,12 @@ def sevpa_for_L11():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L12():
+def sevpa_for_L12() -> Sevpa:
+ """
+ Builds an example SEVPA for language L12.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {']': [('q1', 'pop', ('q0', '['))],
')': [('q1', 'pop', ('q0', '('))]
@@ -217,7 +273,12 @@ def sevpa_for_L12():
return Sevpa.from_state_setup(state_setup, init_state_id="q0", )
-def sevpa_for_L13():
+def sevpa_for_L13() -> Sevpa:
+ """
+ Builds an example SEVPA for language L13.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'c': [('q1', None, None)],
'b': [('q1', None, None)],
@@ -236,7 +297,12 @@ def sevpa_for_L13():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L14():
+def sevpa_for_L14() -> Sevpa:
+ """
+ Builds an example SEVPA for language L14.
+
+ :return Sevpa: The constructed SEVPA.
+ """
state_setup = {
'q0': (False, {'a': [('q1', None, None)],
'b': [('q1', None, None)],
@@ -259,7 +325,12 @@ def sevpa_for_L14():
return Sevpa.from_state_setup(state_setup, init_state_id="q0")
-def sevpa_for_L15():
+def sevpa_for_L15() -> Sevpa:
+ """
+ Builds an example SEVPA for language L15 (Dyck order 1).
+
+ :return Sevpa: The constructed SEVPA.
+ """
# Dyck order 1
state_setup = {
@@ -290,4 +361,3 @@ def sevpa_for_L15():
m = load_automaton_from_file('test.dot', automaton_type='vpa')
print('Loaded')
print(m)
-
diff --git a/aalpy/utils/BenchmarkVpaModels.py b/aalpy/utils/BenchmarkVpaModels.py
index 62b8d701ed7..455d815a20a 100644
--- a/aalpy/utils/BenchmarkVpaModels.py
+++ b/aalpy/utils/BenchmarkVpaModels.py
@@ -1,9 +1,15 @@
+# Collection of example VPAs (Visibly Pushdown Automata) used for benchmarking learning algorithms.
import random
from aalpy.automata.Vpa import Vpa, VpaAlphabet
-def vpa_L1():
+def vpa_L1() -> Vpa:
+ """
+ Builds an example VPA for language L1.
+
+ :return Vpa: The constructed VPA.
+ """
# we always ensure that n >= 1
call_set = ['a']
@@ -21,7 +27,12 @@ def vpa_L1():
return vpa
-def vpa_L2():
+def vpa_L2() -> Vpa:
+ """
+ Builds an example VPA for language L2.
+
+ :return Vpa: The constructed VPA.
+ """
call_set = ['a', 'b']
return_set = ['c', 'd']
internal_set = []
@@ -40,7 +51,12 @@ def vpa_L2():
return vpa
-def vpa_for_L3():
+def vpa_for_L3() -> Vpa:
+ """
+ Builds an example VPA for language L3.
+
+ :return Vpa: The constructed VPA.
+ """
call_set = ['a', 'c', 'b', 'd']
return_set = ['e', 'g', 'f', 'h']
internal_set = []
@@ -69,7 +85,12 @@ def vpa_for_L3():
return vpa
-def vpa_L3():
+def vpa_L3() -> Vpa:
+ """
+ Builds an example VPA for a variant of language L3.
+
+ :return Vpa: The constructed VPA.
+ """
call_set = ['a', 'b']
return_set = ['c', 'd']
internal_set = []
@@ -90,7 +111,12 @@ def vpa_L3():
return vpa
-def vpa_L4():
+def vpa_L4() -> Vpa:
+ """
+ Builds an example VPA for language L4 (Dyck order 2).
+
+ :return Vpa: The constructed VPA.
+ """
# Dyck order 2
call_set = ['(', '[']
@@ -113,7 +139,12 @@ def vpa_L4():
return vpa
-def vpa_L6():
+def vpa_L6() -> Vpa:
+ """
+ Builds an example VPA for language L6 (Dyck order 3).
+
+ :return Vpa: The constructed VPA.
+ """
# Dyck order 3
call_set = ['(', '[', '{']
@@ -139,7 +170,12 @@ def vpa_L6():
return vpa
-def vpa_L8():
+def vpa_L8() -> Vpa:
+ """
+ Builds an example VPA for language L8 (Dyck order 4).
+
+ :return Vpa: The constructed VPA.
+ """
# Dyck order 4
call_set = ['(', '[', '{', '<']
@@ -168,7 +204,12 @@ def vpa_L8():
return vpa
-def vpa_L9():
+def vpa_L9() -> Vpa:
+ """
+ Builds an example VPA for language L9 (Dyck order 2, single-nested).
+
+ :return Vpa: The constructed VPA.
+ """
# Dyck order 2 (single-nested)
call_set = ['(', '[']
@@ -194,7 +235,12 @@ def vpa_L9():
return vpa
-def vpa_L10():
+def vpa_L10() -> Vpa:
+ """
+ Builds an example VPA for language L10 (Dyck order 1 with internal symbols).
+
+ :return Vpa: The constructed VPA.
+ """
# Dyck order 1
call_set = ['(']
@@ -220,7 +266,12 @@ def vpa_L10():
return vpa
-def vpa_L11():
+def vpa_L11() -> Vpa:
+ """
+ Builds an example VPA for language L11 (Dyck order 2 with internal symbols).
+
+ :return Vpa: The constructed VPA.
+ """
# Dyck order 2
call_set = ['(', '[']
@@ -249,7 +300,12 @@ def vpa_L11():
return vpa
-def vpa_L12():
+def vpa_L12() -> Vpa:
+ """
+ Builds an example VPA for language L12 (Dyck order 1 with a multi-letter internal sequence).
+
+ :return Vpa: The constructed VPA.
+ """
# Dyck order 1
call_set = ['(']
@@ -277,7 +333,12 @@ def vpa_L12():
return vpa
-def vpa_for_L16():
+def vpa_for_L16() -> Vpa:
+ """
+ Builds an example VPA for a small testing language L16.
+
+ :return Vpa: The constructed VPA.
+ """
# just a testing language
call_set = ['a']
@@ -297,7 +358,13 @@ def vpa_for_L16():
return vpa
-def vpa_for_odd_parentheses():
+def vpa_for_odd_parentheses() -> Vpa:
+ """
+ Builds a VPA accepting only an odd number of fully balanced parentheses, e.g. () and ((()))), but
+ rejecting odd pairs or multiple groups.
+
+ :return Vpa: The constructed VPA.
+ """
# VPA for accepting only odd number of fully balanced parentheses
# It accepts patterns like () and ((()))), but rejects odd pairs or multiple groups
@@ -324,7 +391,12 @@ def vpa_for_odd_parentheses():
return vpa
-def vpa_for_even_parentheses():
+def vpa_for_even_parentheses() -> Vpa:
+ """
+ Builds a VPA accepting only an even number of fully balanced parentheses.
+
+ :return Vpa: The constructed VPA.
+ """
# VPA for accepting only even number of fully balanced parentheses
call_set = ['(']
@@ -350,23 +422,53 @@ def vpa_for_even_parentheses():
return vpa
-def gen_arithmetic_data(num_sequences=3000, min_seq_len=2, max_seq_len=8):
+def gen_arithmetic_data(num_sequences: int = 3000, min_seq_len: int = 2, max_seq_len: int = 8) \
+ -> tuple[list, VpaAlphabet]:
+ """
+ Generates random arithmetic-expression traces (using a validating SUL based on Python's ast module) in the
+ format expected by RPNI.
+
+ :param int num_sequences: Number of traces to generate.
+ :param int min_seq_len: Minimum length of a generated sequence.
+ :param int max_seq_len: Maximum length of a generated sequence.
+ :return tuple[list, VpaAlphabet]: The generated traces in RPNI input/output format, and the used VPA alphabet.
+ """
import ast
from aalpy.base import SUL
from aalpy.utils import convert_i_o_traces_for_RPNI
class ArithmeticSUL(SUL):
- def __init__(self):
+ """
+ SUL that validates whether the string under test is a syntactically valid arithmetic expression.
+ """
+
+ def __init__(self) -> None:
+ """
+ Creates the arithmetic-expression SUL with an empty string under test.
+ """
super().__init__()
self.string_under_test = ''
- def pre(self):
+ def pre(self) -> None:
+ """
+ Resets the string under test.
+ """
self.string_under_test = ''
- def post(self):
+ def post(self) -> None:
+ """
+ No cleanup necessary.
+ """
pass
- def step(self, letter):
+ def step(self, letter: str | None) -> bool:
+ """
+ Appends the letter to the string under test and checks whether it still parses as a valid
+ arithmetic expression.
+
+ :param str | None letter: Next token to append, or None.
+ :return bool: True if the string under test is a valid arithmetic expression.
+ """
if letter:
self.string_under_test += ' ' + letter if len(self.string_under_test) > 0 else letter
@@ -401,7 +503,12 @@ def step(self, letter):
return rpni_format, alphabet
-def vpa_json():
+def vpa_json() -> Vpa:
+ """
+ Builds an example VPA that recognizes a simplified JSON-like structure.
+
+ :return Vpa: The constructed VPA.
+ """
# Define call, return, and internal symbols for JSON
call_set = ['{', '[']
return_set = ['}', ']']
@@ -457,7 +564,12 @@ def vpa_json():
return vpa
-def get_all_VPAs():
+def get_all_VPAs() -> list[Vpa]:
+ """
+ Loads and builds all example VPAs, including one loaded from a DOT file.
+
+ :return list[Vpa]: All example VPAs.
+ """
from aalpy import load_automaton_from_file
arithmetics_vpa = load_automaton_from_file('../DotModels/arithmetics.dot', 'vpa')
return [vpa_L1(), vpa_L2(), vpa_L3(), vpa_L4(),
diff --git a/aalpy/utils/DataHandler.py b/aalpy/utils/DataHandler.py
index 71a94e8ecce..250251b7dc2 100644
--- a/aalpy/utils/DataHandler.py
+++ b/aalpy/utils/DataHandler.py
@@ -1,3 +1,4 @@
+# Data handlers/tokenizers for loading sequence data used by the Alergia algorithm.
from abc import ABC, abstractmethod
@@ -8,7 +9,13 @@ class DataHandler(ABC):
"""
@abstractmethod
- def tokenize_data(self, path):
+ def tokenize_data(self, path: str) -> list:
+ """
+ Tokenizes data found at the given path.
+
+ :param str path: path to the data file.
+ :return list: list of tokenized sequences.
+ """
pass
@@ -19,7 +26,13 @@ class CharacterTokenizer(DataHandler):
Each input sequence is in the separate line.
"""
- def tokenize_data(self, path):
+ def tokenize_data(self, path: str) -> list[list[str]]:
+ """
+ Tokenizes each line of the file into a list of single characters.
+
+ :param str path: path to the data file.
+ :return list[list[str]]: list of tokenized sequences, one per line.
+ """
data = []
lines = open(path).read().splitlines()
for l in lines:
@@ -34,7 +47,14 @@ class DelimiterTokenizer(DataHandler):
Each input sequence is in the separate line.
"""
- def tokenize_data(self, path, delimiter=','):
+ def tokenize_data(self, path: str, delimiter: str = ',') -> list[list[str]]:
+ """
+ Tokenizes each line of the file by splitting on the given delimiter.
+
+ :param str path: path to the data file.
+ :param str delimiter: delimiter separating inputs in a line.
+ :return list[list[str]]: list of tokenized sequences, one per line.
+ """
data = []
lines = open(path).read().splitlines()
for l in lines:
@@ -50,7 +70,15 @@ class IODelimiterTokenizer(DataHandler):
Each [output, tuple(input,output)*] sequence is in the separate line.
"""
- def tokenize_data(self, path, io_delimiter='/', word_delimiter=','):
+ def tokenize_data(self, path: str, io_delimiter: str = '/', word_delimiter: str = ',') -> list[list]:
+ """
+ Tokenizes each line of the file into an initial output followed by (input, output) tuples.
+
+ :param str path: path to the data file.
+ :param str io_delimiter: delimiter separating an input from its output within a word.
+ :param str word_delimiter: delimiter separating words (initial output and input/output pairs) in a line.
+ :return list[list]: list of tokenized sequences, one per line.
+ """
data = []
lines = open(path).read().splitlines()
for l in lines:
@@ -67,7 +95,13 @@ def tokenize_data(self, path, io_delimiter='/', word_delimiter=','):
return data
-def try_int(x):
+def try_int(x: str) -> int | str:
+ """
+ Converts a string to an int if it represents a digit, otherwise returns it unchanged.
+
+ :param str x: string to convert.
+ :return int | str: the converted integer, or the original string if not convertible.
+ """
if str.isdigit(x):
return int(x)
return x
diff --git a/aalpy/utils/FileHandler.py b/aalpy/utils/FileHandler.py
index e3f652e6001..a92bf9551a6 100644
--- a/aalpy/utils/FileHandler.py
+++ b/aalpy/utils/FileHandler.py
@@ -1,7 +1,9 @@
+# Serialization, deserialization and Graphviz-based visualization of automata to/from .dot/.png/.svg/.pdf files.
import re
import sys
import traceback
from pathlib import Path
+from typing import Any
from pydot import Dot, Node, Edge
@@ -15,16 +17,26 @@
Sevpa: 'sevpa', Vpa: 'vpa'}
-def _wrap_label(label):
+def _wrap_label(label: str) -> str:
"""
Adds a " " around a label if not already present on both ends.
+
+ :param str label: label to wrap.
+ :return str: the wrapped label.
"""
if label[0] == '\"' and label[-1] == '\"':
return label
return f'\"{label}\"'
-def _get_node(state, automaton_type):
+def _get_node(state: Any, automaton_type: str) -> Node | None:
+ """
+ Creates a pydot Node for a given automaton state, styled according to the automaton type.
+
+ :param Any state: automaton state for which the node is created.
+ :param str automaton_type: type tag of the automaton (e.g. 'dfa', 'mealy', 'moore').
+ :return Node | None: the created node, or None if automaton_type is not recognized.
+ """
if automaton_type == 'dfa':
if state.is_accepting:
return Node(state.state_id, label=_wrap_label(state.state_id), shape='doublecircle')
@@ -48,7 +60,17 @@ def _get_node(state, automaton_type):
return Node(state.state_id, label=_wrap_label(state.state_id))
-def _add_transition_to_graph(graph, state, automaton_type, display_same_state_trans, round_floats):
+def _add_transition_to_graph(graph: Dot, state: Any, automaton_type: str, display_same_state_trans: bool,
+ round_floats: int | None) -> None:
+ """
+ Adds all outgoing transitions of a state as edges to the graph.
+
+ :param Dot graph: pydot graph to which edges are added.
+ :param Any state: automaton state whose transitions are added.
+ :param str automaton_type: type tag of the automaton.
+ :param bool display_same_state_trans: if False, self-loops are skipped.
+ :param int | None round_floats: number of decimal places to round probabilities to, or None to not round.
+ """
if automaton_type == 'dfa' or automaton_type == 'moore':
for i in state.transitions.keys():
new_state = state.transitions[i]
@@ -126,22 +148,17 @@ def _add_transition_to_graph(graph, state, automaton_type, display_same_state_tr
graph.add_edge(edge)
-def visualize_automaton(automaton, path="LearnedModel", file_type="pdf", display_same_state_trans=True):
+def visualize_automaton(automaton: Any, path: str | Path = "LearnedModel", file_type: str = "pdf",
+ display_same_state_trans: bool = True) -> None:
"""
Create a graphical representation of the automaton.
Function is round in the separate thread in the background.
If possible, it will be opened by systems default program.
- Args:
-
- automaton: automaton to be visualized
-
- path: pathlike or str, file in which visualization will be saved (Default value = "LearnedModel.pdf")
-
- file_type: type of file/visualization. Can be ['png', 'svg', 'pdf'] (Default value = "pdf")
-
- display_same_state_trans: if True, same state transitions will be displayed (Default value = True)
-
+ :param Any automaton: automaton to be visualized
+ :param str | Path path: file in which visualization will be saved
+ :param str file_type: type of file/visualization. Can be ['png', 'svg', 'pdf']
+ :param bool display_same_state_trans: if True, same state transitions will be displayed
"""
print('Visualization started in the background thread.')
@@ -154,29 +171,21 @@ def visualize_automaton(automaton, path="LearnedModel", file_type="pdf", display
visualization_thread.start()
-def save_automaton_to_file(automaton, path="LearnedModel", file_type="dot",
- display_same_state_trans=True, visualize=False, round_floats=None):
+def save_automaton_to_file(automaton: Any, path: str | Path = "LearnedModel", file_type: str = "dot",
+ display_same_state_trans: bool = True, visualize: bool = False,
+ round_floats: int | None = None) -> str | None:
"""
The Standard of the automata strictly follows the syntax found at: https://automata.cs.ru.nl/Syntax/Overview.
For non-deterministic and stochastic systems syntax can be found on AALpy's Wiki.
- Args:
-
- automaton: automaton to be saved to file
-
- path: pathlike or str, file in which visualization will be saved (Default value = "LearnedModel")
-
- file_type: type of file/visualization. Can be ['dot', 'png', 'svg', 'pdf'] (Default value = "dot)
-
- display_same_state_trans: True, should not be set to false except from the visualization method
- (Default value = True)
-
- visualize: visualize the automaton
-
- round_floats: for stochastic automata, round the floating point numbers to defined number of decimal places
-
- Returns:
-
+ :param Any automaton: automaton to be saved to file
+ :param str | Path path: file in which visualization will be saved
+ :param str file_type: type of file/visualization. Can be ['dot', 'png', 'svg', 'pdf']
+ :param bool display_same_state_trans: True, should not be set to false except from the visualization method
+ :param bool visualize: visualize the automaton
+ :param int | None round_floats: for stochastic automata, round the floating point numbers to defined number of
+ decimal places
+ :return str | None: the dot representation as a string if file_type is 'string', otherwise None
"""
path = Path(path)
file_type = file_type.lower()
@@ -224,7 +233,16 @@ def save_automaton_to_file(automaton, path="LearnedModel", file_type="dot",
vpa_pop_pattern = r"(\S+)\s*/\s*pop\(\s*(.*?)\s*\)"
-def _process_label(label, source, destination, automaton_type):
+def _process_label(label: str, source: Any, destination: Any, automaton_type: str) -> None:
+ """
+ Parses a single transition label from a loaded dot file and adds the corresponding transition to the source
+ state.
+
+ :param str label: the (already unwrapped) transition label.
+ :param Any source: the source state of the transition.
+ :param Any destination: the destination state of the transition.
+ :param str automaton_type: type tag of the automaton.
+ """
if automaton_type == 'dfa' or automaton_type == 'moore':
source.transitions[int(label) if label.isdigit() else label] = destination
if automaton_type == 'mealy':
@@ -288,7 +306,16 @@ def _process_label(label, source, destination, automaton_type):
source.transitions[input_symbol].append(transition)
-def _process_node_label(node, label, node_label_dict, node_type, automaton_type):
+def _process_node_label(node: Node, label: str, node_label_dict: dict, node_type: type, automaton_type: str) -> None:
+ """
+ Parses a node's label from a loaded dot file and creates the corresponding automaton state.
+
+ :param Node node: pydot node being processed.
+ :param str label: the (already unwrapped) node label.
+ :param dict node_label_dict: map from node name to created state, updated in place.
+ :param type node_type: state class to instantiate (e.g. DfaState).
+ :param str automaton_type: type tag of the automaton.
+ """
node_name = node.get_name()
if automaton_type == 'mdp' or automaton_type == 'mc':
node_label_dict[node_name] = node_type(node_name, label)
@@ -315,7 +342,18 @@ def _strip_label(label: str) -> str:
return label
-def _process_node_label_prime(node_name, label, line, node_label_dict, node_type, automaton_type):
+def _process_node_label_prime(node_name: str, label: str, line: str, node_label_dict: dict, node_type: type,
+ automaton_type: str) -> None:
+ """
+ Parses a node's label and its source dot line to create the corresponding automaton state.
+
+ :param str node_name: name of the node/state.
+ :param str label: the (already unwrapped) node label.
+ :param str line: the raw dot file line the node was parsed from.
+ :param dict node_label_dict: map from node name to created state, updated in place.
+ :param type node_type: state class to instantiate (e.g. DfaState).
+ :param str automaton_type: type tag of the automaton.
+ """
if automaton_type == 'mdp' or automaton_type == 'mc':
node_label_dict[node_name] = node_type(node_name, label)
else:
@@ -341,26 +379,18 @@ def _process_node_label_prime(node_name, label, line, node_label_dict, node_type
transition_pattern = r'(\w+)\s*->\s*(\w+)\s*(.*)?;?'
-def load_automaton_from_file(path, automaton_type, compute_prefixes=False):
+def load_automaton_from_file(path: str | Path, automaton_type: str, compute_prefixes: bool = False) -> Any:
"""
Loads the automaton from the file.
Standard of the automatas strictly follows syntax found at: https://automata.cs.ru.nl/Syntax/Overview.
For non-deterministic and stochastic systems syntax can be found on AALpy's Wiki.
- Args:
-
- path: pathlike or str to the file
-
- automaton_type: type of the automaton, one of ['dfa', 'mealy', 'moore', 'mdp', 'smm',
- 'onfsm', 'ndmoore', 'mc', 'sevpa', 'vpa']
-
- compute_prefixes: it True, shortest path to reach every state will be computed and saved in the prefix of
- the state. Useful when loading the model to use them as a equivalence oracle. (Default value = False)
-
- Returns:
-
- loaded automaton
-
+ :param str | Path path: path to the file
+ :param str automaton_type: type of the automaton, one of ['dfa', 'mealy', 'moore', 'mdp', 'smm',
+ 'onfsm', 'ndmoore', 'mc', 'sevpa', 'vpa']
+ :param bool compute_prefixes: it True, shortest path to reach every state will be computed and saved in the
+ prefix of the state. Useful when loading the model to use them as a equivalence oracle.
+ :return Any: loaded automaton
"""
assert automaton_type in automaton_types.values()
diff --git a/aalpy/utils/HelperFunctions.py b/aalpy/utils/HelperFunctions.py
index d11ea789110..3a22e3d81de 100644
--- a/aalpy/utils/HelperFunctions.py
+++ b/aalpy/utils/HelperFunctions.py
@@ -1,7 +1,9 @@
+# Miscellaneous helper functions used across learning algorithms, oracles and utilities.
import random
import string
from itertools import product
from collections import defaultdict
+from typing import Any
from aalpy import Mdp, MarkovChain, McState, MooreMachine, Dfa, DfaState
@@ -10,12 +12,10 @@ def extend_set(list_to_extend: list, new_elements: list) -> list:
"""
Helper function to extend a list while maintaining set property.
They are stored as lists, so with this function set property is maintained.
- :return
-
- Returns:
-
- list of elements that were added to the set
+ :param list list_to_extend: List that is extended in place.
+ :param list new_elements: Elements to add if not already present.
+ :return list: List of elements that were added to the set.
"""
set_repr = set(list_to_extend)
added_elements = [s for s in new_elements if s not in set_repr]
@@ -23,43 +23,32 @@ def extend_set(list_to_extend: list, new_elements: list) -> list:
return added_elements
-def all_prefixes(li):
+def all_prefixes(li: list) -> list[tuple]:
"""
Returns all prefixes of a list.
- Args:
- li: list from which to compute all prefixes
-
- Returns:
- list of all prefixes
-
+ :param list li: List from which to compute all prefixes.
+ :return list[tuple]: List of all prefixes.
"""
return [tuple(li[:i + 1]) for i in range(len(li))]
-def all_suffixes(li):
+def all_suffixes(li: list) -> list[tuple]:
"""
Returns all suffixes of a list.
- Args:
- li: list from which to compute all suffixes
-
- Returns:
- list of all suffixes
-
+ :param list li: List from which to compute all suffixes.
+ :return list[tuple]: List of all suffixes.
"""
return [tuple(li[len(li) - i - 1:]) for i in range(len(li))]
-def profile_function(function: callable, sort_key='cumtime'):
+def profile_function(function: callable, sort_key: str = 'cumtime') -> None:
"""
+ Profiles a callable and prints the profiling results.
- Args:
- function: callable:
- sort_key: (Default value = 'cumtime')
-
- Returns:
- prints the profiling results
+ :param callable function: Callable to profile.
+ :param str sort_key: Key used to sort the profiling statistics (Default value = 'cumtime').
"""
import cProfile
pr = cProfile.Profile()
@@ -69,25 +58,23 @@ def profile_function(function: callable, sort_key='cumtime'):
pr.print_stats(sort=sort_key)
-def random_string_generator(size=10, chars=string.ascii_lowercase + string.digits):
+def random_string_generator(size: int = 10, chars: str = string.ascii_lowercase + string.digits) -> str:
"""
+ Generates a random string.
- Args:
-
- size: (Default value = 10)
- chars: (Default value = string.ascii_lowercase + string.digits)
-
- Returns:
-
- a random string of length size
+ :param int size: Length of the generated string (Default value = 10).
+ :param str chars: Pool of characters to choose from (Default value = string.ascii_lowercase + string.digits).
+ :return str: A random string of length size.
"""
import random
return ''.join(random.choice(chars) for _ in range(size))
-def print_learning_info(info: dict):
+def print_learning_info(info: dict[str, Any]) -> None:
"""
Print learning statistics.
+
+ :param dict[str, Any] info: Dictionary of learning statistics as produced by a learning algorithm's info dict.
"""
print('-----------------------------------')
print('Learning Finished.')
@@ -112,15 +99,12 @@ def print_learning_info(info: dict):
print('-----------------------------------')
-def print_observation_table(ot, table_type):
+def print_observation_table(ot: Any, table_type: str) -> None:
"""
Prints the whole observation table.
- Args:
-
- ot: observation table
- table_type: 'det', 'non-det', or 'stoc'
-
+ :param Any ot: Observation table.
+ :param str table_type: 'det', 'non-det', 'abstracted-non-det', or 'stoc'.
"""
if table_type == 'det':
s_set, extended_s, e_set, table = ot.S, ot.s_dot_a(), ot.E, ot.T
@@ -171,16 +155,13 @@ def print_observation_table(ot, table_type):
print('-' * row_len)
-def is_suffix_of(suffix, trace) -> bool:
+def is_suffix_of(suffix: tuple, trace: tuple) -> bool:
"""
+ Checks whether a sequence is a suffix of another sequence.
- Args:
- suffix: target suffix
- trace: trace in question
-
- Returns:
-
- True if suffix is the suffix of trace.
+ :param tuple suffix: Target suffix.
+ :param tuple trace: Trace in question.
+ :return bool: True if suffix is the suffix of trace.
"""
if len(trace) < len(suffix):
return False
@@ -188,24 +169,25 @@ def is_suffix_of(suffix, trace) -> bool:
return trace[-len(suffix):] == suffix
-def get_cex_prefixes(cex, automaton_type):
+def get_cex_prefixes(cex: tuple, automaton_type: str) -> list[tuple]:
"""
Returns all prefixes of the stochastic automaton.
- Args:
- cex: counterexample
- automaton_type: `mdp` or `smm`
-
- Returns:
-
- all prefixes of the counterexample based on the `automaton_type`
+ :param tuple cex: Counterexample.
+ :param str automaton_type: `mdp` or `smm`.
+ :return list[tuple]: All prefixes of the counterexample based on the `automaton_type`.
"""
if automaton_type == 'mdp':
return [tuple(cex[:i + 1]) for i in range(0, len(cex), 2)]
return [tuple(cex[:i]) for i in range(0, len(cex) + 1, 2)]
-def get_available_oracles_and_err_msg():
+def get_available_oracles_and_err_msg() -> tuple[set, str]:
+ """
+ Looks up the equivalence oracles that are supported for non-deterministic and stochastic learning.
+
+ :return tuple[set, str]: Set of available oracle classes and a warning message describing the restriction.
+ """
from aalpy.oracles import RandomWalkEqOracle
from aalpy.oracles import RandomWordEqOracle
available_oracles = {RandomWalkEqOracle, RandomWordEqOracle}
@@ -217,7 +199,7 @@ def get_available_oracles_and_err_msg():
return available_oracles, available_oracles_msg
-def make_input_complete(automaton, missing_transition_go_to='self_loop'):
+def make_input_complete(automaton: Any, missing_transition_go_to: str = 'self_loop') -> Any:
"""
Makes the automaton input complete/enabled. If a input is not defined in a state, it will lead to the self loop.
In case of Mealy Machines, Stochastic Mealy machines and ONFSM 'epsilon' is used as output.
@@ -226,14 +208,9 @@ def make_input_complete(automaton, missing_transition_go_to='self_loop'):
(Mealy machines and their derivatives), 'epsilon' is used as an output value. If a state has an output value,
it is either False (in case of DFA) or 'sink_state' in case of Moore machines and its derivatives.
- Args:
-
- automaton: automaton that is potentially not input complete
- missing_transition_go_to: either 'self_loop' or 'sink_state'.
-
- Returns:
-
- an input complete automaton
+ :param Any automaton: Automaton that is potentially not input complete.
+ :param str missing_transition_go_to: Either 'self_loop' or 'sink_state'.
+ :return Any: An input complete automaton.
"""
from aalpy.base import DeterministicAutomaton
from aalpy.automata import Dfa, MooreState, MealyMachine, Mdp, StochasticMealyMachine, Onfsm, \
@@ -279,11 +256,15 @@ def make_input_complete(automaton, missing_transition_go_to='self_loop'):
return automaton
-def convert_i_o_traces_for_RPNI(sequences, automaton_type="mealy"):
+def convert_i_o_traces_for_RPNI(sequences: list, automaton_type: str = "mealy") -> list[tuple]:
"""
Converts a list of input-output sequences to RPNI format.
Eg. [[(1,'a'), (2,'b'), (3,'c')], [(6,'7'), (4,'e'), (3,'c')]] to
[((1,), 'a'), ((1, 2), 'b'), ((1, 2, 3), 'c'), ((6,), '7'), ((6, 4), 'e'), ((6, 4, 3), 'c')]
+
+ :param list sequences: List of input-output traces.
+ :param str automaton_type: Either "mealy", "moore" or "dfa".
+ :return list[tuple]: List of (input_sequence, output) pairs in RPNI format.
"""
rpni_sequences = []
seen = set()
@@ -309,7 +290,12 @@ def convert_i_o_traces_for_RPNI(sequences, automaton_type="mealy"):
return rpni_sequences
-def visualize_classification_tree(root_node):
+def visualize_classification_tree(root_node: Any) -> None:
+ """
+ Visualizes a classification tree and writes it to a PDF file.
+
+ :param Any root_node: Root node of the classification tree.
+ """
from pydot import Dot, Node, Edge
graph = Dot('classification_tree', graph_type='digraph')
@@ -336,7 +322,14 @@ def visualize_classification_tree(root_node):
graph.write(path='classification_tree.pdf', format='pdf')
-def is_balanced(input_seq, vpa_alphabet):
+def is_balanced(input_seq: list, vpa_alphabet: Any) -> bool:
+ """
+ Checks whether an input sequence is balanced with respect to a VPA alphabet's call/return symbols.
+
+ :param list input_seq: Input sequence to check.
+ :param Any vpa_alphabet: VPA alphabet, exposing call_alphabet and return_alphabet.
+ :return bool: True if the sequence is balanced, False otherwise.
+ """
counter = 0
for i in input_seq:
if i in vpa_alphabet.call_alphabet:
@@ -348,8 +341,18 @@ def is_balanced(input_seq, vpa_alphabet):
return counter == 0
-def generate_input_output_data_from_automata(model, num_sequences=4000, min_seq_len=1, max_seq_len=16,
- sequance_type='io_traces'):
+def generate_input_output_data_from_automata(model: Any, num_sequences: int = 4000, min_seq_len: int = 1,
+ max_seq_len: int = 16, sequance_type: str = 'io_traces') -> list:
+ """
+ Generates random input-output data by executing random input sequences on an automaton.
+
+ :param Any model: Automaton from which the data is generated.
+ :param int num_sequences: Number of sequences to generate.
+ :param int min_seq_len: Minimum sequence length.
+ :param int max_seq_len: Maximum sequence length.
+ :param str sequance_type: Either 'io_traces' or 'labeled_sequences'.
+ :return list: The generated dataset.
+ """
assert sequance_type in {'io_traces', 'labeled_sequences'}
alphabet = model.get_input_alphabet()
@@ -371,7 +374,18 @@ def generate_input_output_data_from_automata(model, num_sequences=4000, min_seq_
return dataset
-def generate_input_output_data_from_vpa(vpa, num_sequences=1000, max_seq_len=16, max_attempts=None):
+def generate_input_output_data_from_vpa(vpa: Any, num_sequences: int = 1000, max_seq_len: int = 16,
+ max_attempts: int | None = None) -> list[tuple]:
+ """
+ Generates random input-output data by executing random (mostly balanced) input sequences on a VPA.
+
+ :param Any vpa: Visibly pushdown automaton from which the data is generated.
+ :param int num_sequences: Number of sequences to generate.
+ :param int max_seq_len: Maximum sequence length.
+ :param int | None max_attempts: Maximum number of generation attempts before giving up (Default value = None,
+ meaning num_sequences * 50).
+ :return list[tuple]: List of (input_sequence, output) pairs.
+ """
alphabet = vpa.input_alphabet.get_merged_alphabet()
data_set, in_set = [], set()
@@ -411,15 +425,25 @@ def generate_input_output_data_from_vpa(vpa, num_sequences=1000, max_seq_len=16,
return data_set
-def product_with_possible_empty_iterable(*iterables, repeat=1):
+def product_with_possible_empty_iterable(*iterables: Any, repeat: int = 1) -> product:
"""
Words like regular product, but if one of the iterables is empty it will just ignore it, instead of returning [].
+
+ :param Any iterables: Iterables to compute the product of.
+ :param int repeat: Number of times to repeat the product computation.
+ :return product: Cartesian product of the non-empty iterables.
"""
non_empty_iterables = [it for it in iterables if it]
return product(*non_empty_iterables, repeat=repeat)
def dfa_from_moore(moore_model: MooreMachine) -> Dfa:
+ """
+ Converts a Moore machine with a Boolean (or None) output domain to a DFA.
+
+ :param MooreMachine moore_model: Moore machine to convert.
+ :return Dfa: The equivalent DFA.
+ """
dfa_state_map = dict()
# define states
for moore_state in moore_model.states:
@@ -439,7 +463,15 @@ def dfa_from_moore(moore_model: MooreMachine) -> Dfa:
initial_state = dfa_state_map[moore_model.initial_state.state_id]
return Dfa(initial_state, list(dfa_state_map.values()))
-def mc_from_mdp(mdp: Mdp, input_symbol=None) -> MarkovChain:
+def mc_from_mdp(mdp: Mdp, input_symbol: Any = None) -> MarkovChain:
+ """
+ Converts an MDP with a single (or explicitly chosen) input symbol to a Markov chain.
+
+ :param Mdp mdp: MDP to convert.
+ :param Any input_symbol: Input symbol to use for the conversion (Default value = None, meaning the MDP's only
+ input symbol is used).
+ :return MarkovChain: The equivalent Markov chain.
+ """
alphabet = mdp.get_input_alphabet()
if len(alphabet) != 1 and input_symbol is None:
raise ValueError('Cannot convert MDP with several inputs to Markov chain.')
@@ -456,12 +488,18 @@ def mc_from_mdp(mdp: Mdp, input_symbol=None) -> MarkovChain:
initial_state = state_map[mdp.initial_state.state_id]
return MarkovChain(initial_state, list(state_map.values()))
-def mc_format_to_mdp(data):
- # a hack to learn MC's with GSM -> treat them as MDP with a single input
+def mc_format_to_mdp(data: list) -> list:
+ """
+ Converts Markov chain formatted data to MDP format by treating it as an MDP with a single input.
+ a hack to learn MC's with GSM -> treat them as MDP with a single input
+
+ :param list data: List of Markov chain sequences.
+ :return list: List of sequences reformatted as MDP input-output sequences.
+ """
augmented_data = []
for sequence in data:
new_sequence = [sequence[0]]
for item in sequence[1:]:
new_sequence.append(('Input', item))
augmented_data.append(new_sequence)
- return augmented_data
\ No newline at end of file
+ return augmented_data
diff --git a/aalpy/utils/ModelChecking.py b/aalpy/utils/ModelChecking.py
index 3a5105fb9ce..f0731a0daa1 100644
--- a/aalpy/utils/ModelChecking.py
+++ b/aalpy/utils/ModelChecking.py
@@ -1,10 +1,11 @@
+# Model checking utilities: PRISM export, property evaluation, bisimilarity and test-case generation.
import itertools as it
import os
import re
from collections import defaultdict
from queue import Queue
from random import choices
-from typing import Tuple, Union
+from typing import Any
import aalpy.paths
from aalpy.SULs import AutomatonSUL
@@ -15,7 +16,13 @@
prism_prob_output_regex = re.compile("Result: (\d+\.\d+)")
-def get_properties_file(exp_name):
+def get_properties_file(exp_name: str) -> str:
+ """
+ Looks up the path to the properties file of a predefined stochastic experiment.
+
+ :param str exp_name: Name of the experiment.
+ :return str: Path to the corresponding properties file.
+ """
property_files = {
'first_grid': aalpy.paths.path_to_properties + 'first_eval.props',
'second_grid': aalpy.paths.path_to_properties + 'second_eval.props',
@@ -28,7 +35,13 @@ def get_properties_file(exp_name):
return property_files[exp_name]
-def get_correct_prop_values(exp_name):
+def get_correct_prop_values(exp_name: str) -> list:
+ """
+ Looks up the reference (ground truth) property values of a predefined stochastic experiment.
+
+ :param str exp_name: Name of the experiment.
+ :return list: List of correct property values.
+ """
correct_model_properties = {
'first_grid': {'prob1': 0.96217534, 'prob2': 0.6499274956800001, 'prob3': 0.6911765746880001},
'second_grid': {'prob1': 0.93480795088125, 'prob2': 0.6711947700000002, 'prob3': 0.9742903305241055,
@@ -50,32 +63,41 @@ def get_correct_prop_values(exp_name):
return list(correct_model_properties[exp_name].values())
-def _target_string(target, orig_id_to_int_id):
+def _target_string(target: tuple, orig_id_to_int_id: dict) -> str:
+ """
+ Formats a single MDP transition target as a PRISM update expression.
+
+ :param tuple target: Tuple of (target_state, probability).
+ :param dict orig_id_to_int_id: Map from original state ids to PRISM integer location ids.
+ :return str: The PRISM update expression string.
+ """
target_state = target[0]
target_prob = target[1]
target_id = orig_id_to_int_id[target_state.state_id]
return f"{target_prob} : (loc'={target_id})"
-def _sanitize_for_prism(symbol):
+def _sanitize_for_prism(symbol: str) -> str:
+ """
+ Sanitizes a symbol that clashes with a PRISM keyword.
+
+ :param str symbol: Symbol to sanitize.
+ :return str: The original symbol, or a mangled version if it is a PRISM keyword.
+ """
if symbol in ["mdp", "init", "module", "endmodule", "label"]:
return "___" + symbol + "___"
else:
return symbol
-def mdp_2_prism_format(mdp: Mdp, name: str, output_path=None):
+def mdp_2_prism_format(mdp: Mdp, name: str, output_path: str | None = None) -> str:
"""
- Translates MDP to Prims modelling language.
-
- Args:
-
- mdp: markov decision process
-
- name: name of the mdp/experiment
-
- output_path: output file (Default value = None)
+ Translates MDP to Prism modelling language.
+ :param Mdp mdp: Markov decision process.
+ :param str name: Name of the mdp/experiment.
+ :param str | None output_path: Output file to which the model is written (Default value = None).
+ :return str: The Prism model as a string.
"""
module_string = "mdp"
module_string += os.linesep
@@ -123,7 +145,14 @@ def mdp_2_prism_format(mdp: Mdp, name: str, output_path=None):
return module_string
-def evaluate_all_properties(prism_file_name, properties_file_name):
+def evaluate_all_properties(prism_file_name: str, properties_file_name: str) -> dict[str, float]:
+ """
+ Runs PRISM on a model file against a properties file and collects the resulting probabilities.
+
+ :param str prism_file_name: Path to the PRISM model file.
+ :param str properties_file_name: Path to the properties file.
+ :return dict[str, float]: Map from property name (e.g. 'prop1') to its computed probability.
+ """
import subprocess
import io
from os import path
@@ -148,16 +177,13 @@ def evaluate_all_properties(prism_file_name, properties_file_name):
return results
-def model_check_properties(model: Mdp, properties: str):
+def model_check_properties(model: Mdp, properties: str) -> dict[str, float]:
"""
+ Model checks a set of properties against an MDP using PRISM.
- Args:
- model: Markov Decision Process that serves as a basis for model checking.
- properties: Properties file. It should point to a file under the path_to_properties folder.
-
- Returns:
-
- results of model checking
+ :param Mdp model: Markov Decision Process that serves as a basis for model checking.
+ :param str properties: Properties file. It should point to a file under the path_to_properties folder.
+ :return dict[str, float]: Results of model checking.
"""
from os import remove
from aalpy.utils import mdp_2_prism_format
@@ -172,21 +198,17 @@ def model_check_properties(model: Mdp, properties: str):
return data
-def model_check_experiment(path_to_properties, correct_prop_values, mdp, precision=4):
+def model_check_experiment(path_to_properties: str, correct_prop_values: list, mdp: Mdp, precision: int = 4) -> tuple[dict, dict]:
"""
For our stochastic experiments you can use this function.
For example, check learn_stochastic_system_and_do_model_checking in Examples.py
- Args:
- path_to_properties: path to the properties file
- correct_prop_values: correct values of all properties. In list, where property at index i corresponds to the
- i-th element of the list.
- mdp: MDP
- precision: precision to which round up results
-
- Returns:
-
- results of model checking and absolute differance to the correct results
+ :param str path_to_properties: Path to the properties file.
+ :param list correct_prop_values: Correct values of all properties. In list, where property at index i corresponds
+ to the i-th element of the list.
+ :param Mdp mdp: MDP.
+ :param int precision: Precision to which round up results.
+ :return tuple[dict, dict]: Results of model checking and absolute difference to the correct results.
"""
model_checking_results = model_check_properties(mdp, path_to_properties)
@@ -198,18 +220,15 @@ def model_check_experiment(path_to_properties, correct_prop_values, mdp, precisi
return results, diff_2_correct
-def stop_based_on_confidence(hypothesis, property_based_stopping, print_level=2):
+def stop_based_on_confidence(hypothesis: Any, property_based_stopping: tuple, print_level: int = 2) -> bool:
"""
+ Determines whether learning should stop based on model checking confidence.
- Args:
-
- hypothesis: Markov decision process
- property_based_stopping: a tuple (path to properties file, list of correct property values, max allowed error)
- print_level: 2 or 3 if output of model checking is to be printed during learning
-
- Returns:
-
- True if absolute error for all properties is smaller then property_based_stopping[2]
+ :param Any hypothesis: Markov decision process (or StochasticMealyMachine, converted internally to an MDP).
+ :param tuple property_based_stopping: A tuple (path to properties file, list of correct property values,
+ max allowed error).
+ :param int print_level: 2 or 3 if output of model checking is to be printed during learning.
+ :return bool: True if absolute error for all properties is smaller then property_based_stopping[2].
"""
from aalpy.automata.StochasticMealyMachine import smm_to_mdp_conversion
@@ -235,13 +254,16 @@ def stop_based_on_confidence(hypothesis, property_based_stopping, print_level=2)
return True
-def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex=False) -> Union[bool, None, tuple]:
+def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex: bool = False) -> bool | None | tuple:
"""
Checks whether the provided automata are bisimilar.
If return_cex the function returns a counter example or None, otherwise a Boolean is returned.
- Returns:
- object: true or false if return_cex is set to False, otherwise None (no counterexample) or a counterexample
+ :param DeterministicAutomaton a1: First automaton.
+ :param DeterministicAutomaton a2: Second automaton.
+ :param bool return_cex: Whether to return a counterexample instead of a boolean.
+ :return bool | None | tuple: True or false if return_cex is set to False, otherwise None (no counterexample) or
+ a counterexample.
"""
# TODO allow states as inputs instead of automata
@@ -256,7 +278,7 @@ def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex
raise NotImplementedError(
f"bisimilarity is not implemented for {a1.__class__.__name__}. Supported: {', '.join(t.__name__ for t in supported_automaton_types)}")
- to_check: Queue[Tuple[AutomatonState, AutomatonState]] = Queue()
+ to_check: Queue[tuple[AutomatonState, AutomatonState]] = Queue()
to_check.put((a1.initial_state, a2.initial_state))
requirements = dict()
requirements[(a1.initial_state, a2.initial_state)] = ()
@@ -284,23 +306,17 @@ def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex
return None if return_cex else True
-def compare_automata(aut_1: DeterministicAutomaton, aut_2: DeterministicAutomaton, num_cex=10):
+def compare_automata(aut_1: DeterministicAutomaton, aut_2: DeterministicAutomaton, num_cex: int = 10) -> list:
"""
Finds cases of non-conformance between first and second automaton. This is done by performing RandomW equivalence
check. It is possible that number of found counterexamples is smaller than num_cex, as no counterexample will be a
suffix of a previously found counterexample.
- Args:
-
- aut_1: first automaton
-
- aut_2: second automaton
-
- num_cex: max. number of searches for counterexamples
-
- Returns:
-
- A list of input sequences that revel different behaviour on both automata. Counterexamples are sorted by length.
+ :param DeterministicAutomaton aut_1: First automaton.
+ :param DeterministicAutomaton aut_2: Second automaton.
+ :param int num_cex: Max. number of searches for counterexamples.
+ :return list: A list of input sequences that revel different behaviour on both automata. Counterexamples are
+ sorted by length.
"""
#
from aalpy.oracles import RandomWMethodEqOracle
@@ -346,43 +362,60 @@ def compare_automata(aut_1: DeterministicAutomaton, aut_2: DeterministicAutomato
class TestCaseWrapperSUL(SUL):
- def __init__(self, sul):
+ """
+ SUL wrapper that records every membership query as a test case (input sequence, output sequence).
+ """
+
+ def __init__(self, sul: SUL) -> None:
+ """
+ Creates a test-case-recording wrapper around a SUL.
+
+ :param SUL sul: The wrapped system under learning.
+ """
super().__init__()
self.sul = sul
self.test_cases = []
self.test_case_inputs = None
self.test_case_outputs = None
- def pre(self):
+ def pre(self) -> None:
+ """
+ Resets the recorded input/output buffers and the wrapped SUL.
+ """
self.test_case_inputs = []
self.test_case_outputs = []
return self.sul.pre()
- def post(self):
+ def post(self) -> None:
+ """
+ Stores the recorded query as a test case and performs cleanup on the wrapped SUL.
+ """
if self.test_case_inputs and self.test_case_outputs:
self.test_cases.append((tuple(self.test_case_inputs), tuple(self.test_case_outputs)))
return self.sul.post()
- def step(self, letter):
+ def step(self, letter: Any) -> Any:
+ """
+ Executes an action on the wrapped SUL and records the input/output pair.
+
+ :param Any letter: Single input that is executed on the SUL.
+ :return Any: Output received after executing the input.
+ """
output = self.sul.step(letter)
self.test_case_inputs.append(letter)
self.test_case_outputs.append(output)
return output
-def generate_test_cases(automaton: DeterministicAutomaton, oracle):
+def generate_test_cases(automaton: DeterministicAutomaton, oracle: Any) -> list:
"""
Uses parametrized eq. oracle to construct test cases on the automaton.
If automaton are big (200+ states), increase recursion depth if necessary (eg. sys.setrecursionlimit(10000)).
- Args:
-
- automaton: deterministic automaton that serves as a basis for test case generation
- oracle: oracle that will construct test-cases and record inputs and outputs
-
- Returns:
-
- List of test cases, where each testcase is a tuple containing two elements, and input and an output sequance.
+ :param DeterministicAutomaton automaton: Deterministic automaton that serves as a basis for test case generation.
+ :param Any oracle: Oracle that will construct test-cases and record inputs and outputs.
+ :return list: List of test cases, where each testcase is a tuple containing two elements, and input and an
+ output sequance.
"""
from copy import deepcopy
@@ -397,22 +430,25 @@ def generate_test_cases(automaton: DeterministicAutomaton, oracle):
return wrapped_sul.test_cases
-def statistical_model_checking(model, goals, max_num_steps, num_tests=105967):
+def statistical_model_checking(model: Any, goals: set, max_num_steps: int, num_tests: int = 105967) -> float:
"""
+ Estimates the probability of reaching a goal output within a bounded number of steps by sampling random tests.
-
- Args:
- model: model on which model checking is performed
- goals: set of goal outputs
- max_num_steps: bounded length of tests
- num_tests: num of tests that will be performed
-
- Returns:
-
- num of tests containing element of goals set / num_tests
+ :param Any model: Model on which model checking is performed.
+ :param set goals: Set of goal outputs.
+ :param int max_num_steps: Bounded length of tests.
+ :param int num_tests: Num of tests that will be performed.
+ :return float: Num of tests containing element of goals set / num_tests.
"""
- def compute_output_sequence(model, seq):
+ def compute_output_sequence(model: Any, seq: list) -> set:
+ """
+ Executes an input sequence on the model from its initial state and collects the observed outputs.
+
+ :param Any model: Model on which the sequence is executed.
+ :param list seq: Input sequence to execute.
+ :return set: Set of observed outputs.
+ """
model.reset_to_initial()
observed_outputs = {model.step(i) for i in seq}
return observed_outputs
diff --git a/aalpy/utils/Sampling.py b/aalpy/utils/Sampling.py
index 3e6d4540e6b..8745b3e5df7 100644
--- a/aalpy/utils/Sampling.py
+++ b/aalpy/utils/Sampling.py
@@ -1,10 +1,22 @@
+# Utilities for sampling and building input/output datasets from automata.
+from collections.abc import Callable
from functools import wraps
from random import randint, choices, random
+from typing import Any
from aalpy import MooreMachine, Dfa, NDMooreMachine, Mdp, MarkovChain
from aalpy.base import Automaton, DeterministicAutomaton
+
def get_io_traces(automaton: Automaton, input_traces: list) -> list:
+ """
+ Computes input/output traces for a list of input sequences executed on an automaton.
+
+ :param Automaton automaton: Automaton on which the input traces are executed.
+ :param list input_traces: List of input sequences.
+ :return list: List of traces, where each trace is a list of (input, output) pairs, prefixed with the initial
+ output for Moore-like automata.
+ """
moore_automata = (MooreMachine, Dfa, NDMooreMachine, Mdp, MarkovChain)
is_moore = isinstance(automaton, moore_automata)
@@ -19,6 +31,13 @@ def get_io_traces(automaton: Automaton, input_traces: list) -> list:
def get_labeled_sequences(automaton: Automaton, input_traces: list) -> list:
+ """
+ Computes the final output label for a list of input sequences executed on an automaton.
+
+ :param Automaton automaton: Automaton on which the input traces are executed.
+ :param list input_traces: List of input sequences.
+ :return list: List of (input_sequence, output) pairs, where output is the label reached after the sequence.
+ """
moore_automata = (MooreMachine, Dfa, NDMooreMachine, Mdp, MarkovChain)
is_moore = isinstance(automaton, moore_automata)
@@ -34,7 +53,15 @@ def get_labeled_sequences(automaton: Automaton, input_traces: list) -> list:
return data
-def get_data_from_input_sequence(automaton: Automaton, input_sequence: list, data_format: str = "io_sequences"):
+def get_data_from_input_sequence(automaton: Automaton, input_sequence: list, data_format: str = "io_sequences") -> list:
+ """
+ Converts a list of input sequences to the requested data format.
+
+ :param Automaton automaton: Automaton on which the input sequences are executed.
+ :param list input_sequence: List of input sequences.
+ :param str data_format: Either 'io_sequences' or 'labeled_sequences'.
+ :return list: The dataset in the requested format.
+ """
if data_format == "io_sequences":
return get_io_traces(automaton, input_sequence)
elif data_format == "labeled_sequences":
@@ -43,10 +70,33 @@ def get_data_from_input_sequence(automaton: Automaton, input_sequence: list, dat
raise ValueError(f"invalid data_format {data_format}. must be 'io_sequences' or 'labeled_sequences'")
-def support_automaton_arg(require_transform):
- def decorator(f):
+def support_automaton_arg(require_transform: bool) -> Callable:
+ """
+ Creates a decorator that allows a sampling function's first argument to be either an alphabet or an automaton,
+ and adds an `include_outputs` keyword argument that returns input/output traces instead of bare input sequences.
+
+ :param bool require_transform: If true, an automaton passed as first argument is transformed into its input
+ alphabet before being passed to the wrapped function.
+ :return Callable: The decorator.
+ """
+ def decorator(f: Callable) -> Callable:
+ """
+ Wraps a sampling function to support an automaton as its first argument.
+
+ :param Callable f: Sampling function to wrap.
+ :return Callable: The wrapped function.
+ """
@wraps(f)
- def inner(alphabet, *args, include_outputs=False, **kwargs):
+ def inner(alphabet: Any, *args: Any, include_outputs: bool = False, **kwargs: Any) -> Any:
+ """
+ Calls the wrapped sampling function, optionally converting the result to input/output traces.
+
+ :param Any alphabet: Input alphabet, or an automaton from which the alphabet is derived.
+ :param Any args: Positional arguments forwarded to the wrapped function.
+ :param bool include_outputs: If true, returns input/output traces computed on the given automaton.
+ :param Any kwargs: Keyword arguments forwarded to the wrapped function.
+ :return Any: The sampled data, optionally converted to input/output traces.
+ """
automaton = None
if isinstance(alphabet, Automaton):
automaton = alphabet
@@ -63,12 +113,29 @@ def inner(alphabet, *args, include_outputs=False, **kwargs):
@support_automaton_arg(True)
-def sample_with_length_limits(alphabet, nr_samples, min_len, max_len):
+def sample_with_length_limits(alphabet: list, nr_samples: int, min_len: int, max_len: int) -> list:
+ """
+ Samples random input sequences with lengths uniformly chosen between given limits.
+
+ :param list alphabet: Input alphabet to sample from.
+ :param int nr_samples: Number of sequences to sample.
+ :param int min_len: Minimum sequence length.
+ :param int max_len: Maximum sequence length.
+ :return list: List of sampled input sequences.
+ """
return [choices(alphabet, k = randint(min_len, max_len)) for _ in range(nr_samples)]
@support_automaton_arg(True)
-def sample_with_term_prob(alphabet, nr_samples, term_prob):
+def sample_with_term_prob(alphabet: list, nr_samples: int, term_prob: float) -> list:
+ """
+ Samples random input sequences whose length is determined by a per-step termination probability.
+
+ :param list alphabet: Input alphabet to sample from.
+ :param int nr_samples: Number of sequences to sample.
+ :param float term_prob: Probability of terminating the sequence at each step.
+ :return list: List of sampled input sequences.
+ """
ret = []
for _ in range(nr_samples):
k = 0
@@ -79,7 +146,14 @@ def sample_with_term_prob(alphabet, nr_samples, term_prob):
@support_automaton_arg(False)
-def get_complete_sample(automaton: DeterministicAutomaton):
+def get_complete_sample(automaton: DeterministicAutomaton) -> list:
+ """
+ Generates a complete sample of an automaton, combining state prefixes, single-input infixes and the
+ characterization set suffixes.
+
+ :param DeterministicAutomaton automaton: Automaton for which the complete sample is generated.
+ :return list: List of input sequences forming the complete sample.
+ """
alphabet = automaton.get_input_alphabet()
automaton.compute_prefixes()
char_set = automaton.compute_characterization_set()
diff --git a/aalpy/utils/__init__.py b/aalpy/utils/__init__.py
index 409d5e9eebd..1b7f74deee9 100644
--- a/aalpy/utils/__init__.py
+++ b/aalpy/utils/__init__.py
@@ -1,3 +1,4 @@
+# Public API re-exports for aalpy.utils.
from .AutomatonGenerators import (
generate_random_dfa,
generate_random_mealy_machine,
diff --git a/pyproject.toml b/pyproject.toml
index 0daf1694326..6b02c94dcbf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "aalpy"
version = "1.6.2"
description = "An active automata learning library"
readme = "README.md"
-requires-python = ">=3.6"
+requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "Edi Muskardin" }
@@ -30,3 +30,7 @@ license-files = ["LICENSE.txt"]
[tool.setuptools.packages.find]
include = ["aalpy*"]
+
+# pip install build twine
+# python -m build
+# python -m twine upload dist/*
\ No newline at end of file
From e74a2bba6a0ab024c7a1ff89e2738981962c9881 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:07:54 +0200
Subject: [PATCH 02/25] consistent return type in cache query
---
aalpy/base/SUL.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/aalpy/base/SUL.py b/aalpy/base/SUL.py
index dd1e991900f..8ebf11e7ca5 100644
--- a/aalpy/base/SUL.py
+++ b/aalpy/base/SUL.py
@@ -146,7 +146,7 @@ def query(self, word: tuple) -> list:
cached_query = self.cache.in_cache(word)
if cached_query:
self.num_cached_queries += 1
- return cached_query
+ return list(cached_query)
# get outputs using default query method
out = self.sul.query(word)
From 0deb020159db371e831e9b5ea3e112e7e8f3ef0a Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:08:32 +0200
Subject: [PATCH 03/25] return constructed NDMooreMachine in to_state_setup
---
aalpy/automata/NonDeterministicMooreMachine.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/aalpy/automata/NonDeterministicMooreMachine.py b/aalpy/automata/NonDeterministicMooreMachine.py
index 1d14492407a..de90cc9b54c 100644
--- a/aalpy/automata/NonDeterministicMooreMachine.py
+++ b/aalpy/automata/NonDeterministicMooreMachine.py
@@ -31,9 +31,11 @@ class NDMooreMachine(Automaton[NDMooreState[InputType, OutputType]]):
non-deterministically.
"""
- def to_state_setup(self) -> None:
+ def to_state_setup(self) -> dict:
"""
- Builds a state setup dictionary for the non-deterministic Moore machine.
+ Converts the non-deterministic Moore machine to a state setup dictionary.
+
+ :return dict: Map from state_id to tuple(output, transitions_dict).
"""
state_setup = dict()
@@ -53,6 +55,8 @@ def set_dict_entry(state: NDMooreState) -> None:
continue
set_dict_entry(state)
+ return state_setup
+
@staticmethod
def from_state_setup(state_setup: dict, **kwargs) -> 'NDMooreMachine':
"""
From 6b45d8d48ea032a0f810c9172d1804e926840c6e Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:09:01 +0200
Subject: [PATCH 04/25] Fix VPA execute_sequance and to_state_setup
---
aalpy/automata/Vpa.py | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/aalpy/automata/Vpa.py b/aalpy/automata/Vpa.py
index 0168165289b..f6b8ed74db1 100644
--- a/aalpy/automata/Vpa.py
+++ b/aalpy/automata/Vpa.py
@@ -186,6 +186,25 @@ def step(self, letter: str | None) -> bool:
return self.current_state.is_accepting and self.stack == []
+ def execute_sequence(self, origin_state: VpaState, seq: list[str], stack: list) -> list[bool]:
+ """
+ Executes an input sequence on the VPA starting from a given state and stack configuration.
+
+ A VPA's actual configuration is the pair (state, stack), not just the state, since the stack is
+ what makes call/return symbols visibly balanced. The generic Automaton.execute_sequence only
+ resets current_state, so it cannot start from a well-defined configuration on its own; stack is
+ therefore a required parameter here rather than defaulted, so callers always state explicitly
+ which configuration they mean to start from (pass [] to start fresh from origin_state).
+
+ :param VpaState origin_state: State from which the sequence execution starts.
+ :param list[str] seq: Input sequence to execute.
+ :param list stack: Stack content to start from.
+ :return list[bool]: The output response for the executed sequence.
+ """
+ self.current_state = origin_state
+ self.stack = list(stack)
+ return [self.step(s) for s in seq]
+
def to_state_setup(self) -> dict:
"""
Converts the VPA to a state setup dictionary.
@@ -200,7 +219,8 @@ def to_state_setup(self) -> dict:
sorted_states = sorted(self.states, key=lambda x: len(x.prefix) if x.prefix is not None else len(self.states))
for s in sorted_states:
state_setup_dict[s.state_id] = (
- s.is_accepting, {k: (v.target_state.state_id, v.action) for k, v in s.transitions.items()})
+ s.is_accepting,
+ {k: [(t.target_state.state_id, t.action, t.stack_guard) for t in v] for k, v in s.transitions.items()})
return state_setup_dict
From 885001a05370ed3698a7fe6529607306a4b3df42 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:10:23 +0200
Subject: [PATCH 05/25] minor bug fixes
---
aalpy/SULs/TomitaSUL.py | 2 +-
aalpy/learning_algs/stochastic_passive/ActiveAleriga.py | 7 +++++--
aalpy/utils/AutomatonGenerators.py | 2 +-
3 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/aalpy/SULs/TomitaSUL.py b/aalpy/SULs/TomitaSUL.py
index a319c012cf6..0b8ce970905 100644
--- a/aalpy/SULs/TomitaSUL.py
+++ b/aalpy/SULs/TomitaSUL.py
@@ -44,7 +44,7 @@ def step(self, letter: str) -> bool:
:param str letter: Single element of the input alphabet.
:return bool: Whether the current string (previous string + letter) is accepted by the grammar.
"""
- if input:
+ if letter is not None:
self.string += str(letter)
return self.tomita_level(self.string)
diff --git a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
index ee338a9e9db..acf4d14d7eb 100644
--- a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
+++ b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
@@ -57,9 +57,12 @@ def sample(self, sul: SUL, model: 'Mdp') -> list:
walk_len = randint(self.min_walk_len, self.max_walk_len)
random_walk = tuple(choice(input_al) for _ in range(walk_len))
+ # the SUL's initial output (before any input) must be queried separately: sul.query(random_walk)
+ # returns exactly one output per input in random_walk, none of which is the initial output
+ initial_output = sul.query(())[0]
outputs = sul.query(random_walk)
- sample = [outputs.pop(0)]
+ sample = [initial_output]
for i in range(len(outputs)):
sample.append((random_walk[i], outputs[i]))
@@ -90,7 +93,7 @@ def run_active_Alergia(data: list, sul: SUL, sampler: Sampler, n_iter: int, eps:
for i in range(n_iter):
if print_info:
print(f'Active Alergia Iteration: {i}')
- model = run_Alergia(data, automaton_type='mdp', eps=eps, compatibility_checker=compatibility_checker)
+ model = run_Alergia(data, automaton_type=automaton_type, eps=eps, compatibility_checker=compatibility_checker)
new_samples = sampler.sample(sul, model)
data.extend(new_samples)
diff --git a/aalpy/utils/AutomatonGenerators.py b/aalpy/utils/AutomatonGenerators.py
index 3fc91192bcd..ee66f04a9cc 100644
--- a/aalpy/utils/AutomatonGenerators.py
+++ b/aalpy/utils/AutomatonGenerators.py
@@ -481,7 +481,7 @@ def generate_random_sevpa(num_states: int, internal_alphabet_size: int, call_alp
for state in states:
for internal_letter in internal_alphabet:
- if state.transitions[internal_letter] is None:
+ if not state.transitions[internal_letter]:
target_state = random.choice(states)
state.transitions[internal_letter].append(
SevpaTransition(target_state, internal_letter, None, None))
From 4fe08324351db5c07ebc30ebf4336a10ef65e313 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:11:32 +0200
Subject: [PATCH 06/25] minor bug fixes
---
.../AbstractedOnfsmObservationTable.py | 17 ++++++++++++++---
aalpy/utils/HelperFunctions.py | 2 ++
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py
index f353fd5903a..bd7c4f214c7 100644
--- a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py
+++ b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py
@@ -53,6 +53,9 @@ def update_obs_table(self, s_set: list[tuple[tuple, tuple]] | None = None,
:param list[tuple] | None e_set: Suffixes of E set on which to perform membership queries.
"""
+ if s_set is None:
+ s_set = self.S + self.S_dot_A
+
self.observation_table.query_missing_observations(s_set, e_set)
self.abstract_obs_table()
self.clean_obs_table()
@@ -228,14 +231,21 @@ def get_distinctive_input_sequence(self, first_row: tuple[tuple, tuple], second_
return None
- def update_E(self, seq: tuple) -> None:
+ def update_E(self, seq: tuple) -> list[tuple]:
"""
Adds a suffix to the E set if not already present.
:param tuple seq: Suffix to add.
+ :return list[tuple]: The newly added suffix as a single-element list, or an empty list if it was
+ already present. The caller (run_abstracted_ONFSM_Lstar) passes this straight on as the
+ e_set of update_obs_table(), so it must not be None/omitted - without a return statement
+ here it was always None, silently making update_obs_table() query every column of E instead
+ of just the new one.
"""
if seq not in self.E:
self.E.append(seq)
+ return [seq]
+ return []
def clean_obs_table(self) -> None:
"""
@@ -254,16 +264,17 @@ def clean_obs_table(self) -> None:
for s in tmp_S:
hashed_s_row = self.row_to_hashable(s)
if hashed_s_row in hashed_rows_from_s:
+ # self.S is the very same list object as self.observation_table.S (aliased in
+ # abstract_obs_table), so removing from one already removes from the other -
+ # calling .remove() on both raised ValueError: x not in list on the second call.
if s in self.S:
self.S.remove(s)
- self.observation_table.S.remove(s)
size = len(s[0])
for row_prefix in tmp_both_S:
s_both_row = (row_prefix[0][:size], row_prefix[1][:size])
if s != row_prefix and s == s_both_row:
if row_prefix in self.S:
self.S.remove(row_prefix)
- self.observation_table.S.remove(s)
else:
hashed_rows_from_s.add(hashed_s_row)
diff --git a/aalpy/utils/HelperFunctions.py b/aalpy/utils/HelperFunctions.py
index 3a22e3d81de..4196ade91f5 100644
--- a/aalpy/utils/HelperFunctions.py
+++ b/aalpy/utils/HelperFunctions.py
@@ -163,6 +163,8 @@ def is_suffix_of(suffix: tuple, trace: tuple) -> bool:
:param tuple trace: Trace in question.
:return bool: True if suffix is the suffix of trace.
"""
+ if len(suffix) == 0:
+ return True
if len(trace) < len(suffix):
return False
else:
From 17ea01682f808126103a35b2a88f0b4b342a4dfe Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:12:07 +0200
Subject: [PATCH 07/25] remove hardcoded direction from linear_cex_processing
---
aalpy/learning_algs/deterministic/CounterExampleProcessing.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/aalpy/learning_algs/deterministic/CounterExampleProcessing.py b/aalpy/learning_algs/deterministic/CounterExampleProcessing.py
index 190694f0149..bae44ec8ced 100644
--- a/aalpy/learning_algs/deterministic/CounterExampleProcessing.py
+++ b/aalpy/learning_algs/deterministic/CounterExampleProcessing.py
@@ -121,8 +121,6 @@ def linear_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness: b
"""
assert direction in {'fwd', 'bwd'}
- direction = 'fwd'
-
distinguishing_suffix = None
previous_output = None
From ed731ff7cb0911ef44b7e4aa15834f205c543fcd Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:12:36 +0200
Subject: [PATCH 08/25] Fix lower bound check in KWayStateCoverageEqOracle.py
---
aalpy/oracles/KWayStateCoverageEqOracle.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/aalpy/oracles/KWayStateCoverageEqOracle.py b/aalpy/oracles/KWayStateCoverageEqOracle.py
index 910fa50ec89..5a631579f06 100644
--- a/aalpy/oracles/KWayStateCoverageEqOracle.py
+++ b/aalpy/oracles/KWayStateCoverageEqOracle.py
@@ -76,9 +76,10 @@ def find_cex(self, hypothesis: Automaton) -> tuple | None:
path += tuple(choices(self.alphabet, k=self.random_walk_len))
test_cases.append(path)
- # lower bound (also accounts for single state hypothesis when a lower bound is not defined)
+ # lower bound (also accounts for hypotheses with fewer states than k, where no k-wise
+ # combination/permutation exists at all, so test_cases would otherwise stay empty)
lower_bound = self.num_test_lower_bound
- if len(hypothesis.states) == 1 and lower_bound is None:
+ if len(hypothesis.states) < self.k and lower_bound is None:
lower_bound = 50
while lower_bound is not None and len(test_cases) < lower_bound:
From 65a881097d91077111cacfebde17f181b2093048 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:13:18 +0200
Subject: [PATCH 09/25] Fix sampling for Mealy machines
---
.../deterministic/ObservationTree.py | 23 ++++++++++++++++++-
.../rpni_helper_functions.py | 3 ++-
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/aalpy/learning_algs/deterministic/ObservationTree.py b/aalpy/learning_algs/deterministic/ObservationTree.py
index 61f1dab4573..74db046c18b 100644
--- a/aalpy/learning_algs/deterministic/ObservationTree.py
+++ b/aalpy/learning_algs/deterministic/ObservationTree.py
@@ -232,6 +232,9 @@ def get_observation(self, inputs: list) -> list | None:
current_node = current_node.get_successor(input_val)
else:
current_node = current_node.get_successor(input_val)
+ if current_node is None:
+ return None
+
output = current_node.output
if output is None:
return None
@@ -252,12 +255,17 @@ def get_outputs(self, basis_state, inputs: list) -> list | None:
for input_val in inputs:
if self.automaton_type == 'mealy':
output = current_node.get_output(input_val)
+ current_node = current_node.get_successor(input_val)
else:
+ current_node = current_node.get_successor(input_val)
+ if current_node is None:
+ return None
+
output = current_node.output
+
if output is None:
return None
observation.append(output)
- current_node = current_node.get_successor(input_val)
return observation
@@ -525,6 +533,19 @@ def _answer_ads_from_tree(self, ads: Ads, from_node) -> tuple[list | None, list
next_input = ads.next_input(prev_output)
if next_input is None:
break
+
+ # For DFA/Moore, the root of an Ads is labelled with the tuple() sentinel (see
+ # Ads.construct_ads): it splits the block on the states' own output, without sending an
+ # actual input. Previously this branch was missing here (unlike in
+ # SUL.adaptive_query, which does handle it), so current_node.get_successor(tuple())
+ # always returned None and this method always reported "cannot answer from tree" for
+ # DFA/Moore -- even when the tree already had the answer -- forcing a real SUL query on
+ # every single ADS-based extension/separation step. Mirror adaptive_query's handling:
+ # just read the node's own output without moving to a successor or recording an input.
+ if next_input == tuple() and self.automaton_type != 'mealy':
+ prev_output = outputs_received[-1] if outputs_received else current_node.output
+ continue
+
inputs_sent.append(next_input)
if self.automaton_type == 'mealy':
diff --git a/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py b/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py
index fa75c86850f..53cdaf6aeee 100644
--- a/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py
+++ b/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py
@@ -192,10 +192,11 @@ def _get_leaf_nodes(node: RpniNode) -> None:
seq = [] if automaton_type == 'mealy' else [root_node.output]
curr_node = root_node
for i in node.prefix:
- curr_node = curr_node.children[i]
if automaton_type == 'mealy':
seq.append((i, curr_node.output.get(i)))
+ curr_node = curr_node.children[i]
else:
+ curr_node = curr_node.children[i]
seq.append((i, curr_node.output))
paths.append(seq)
From 0ae01cb27070fdf75353ab00ef1144b82e0663cf Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:14:13 +0200
Subject: [PATCH 10/25] fix sampling bug and infinite recursion in Score
Function
---
aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py | 3 ++-
aalpy/utils/Sampling.py | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py b/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py
index e385f9b0135..473573c991d 100644
--- a/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py
+++ b/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py
@@ -311,7 +311,8 @@ def transform_score(score: Any, transform: Callable) -> Any:
if isinstance(score, Callable):
return lambda *args: transform(score(*args))
if isinstance(score, ScoreCalculation):
- score.score_function = lambda *args: transform(score.score_function(*args))
+ original_score_function = score.score_function
+ score.score_function = lambda *args: transform(original_score_function(*args))
return score
return transform(score)
diff --git a/aalpy/utils/Sampling.py b/aalpy/utils/Sampling.py
index 8745b3e5df7..040c5539907 100644
--- a/aalpy/utils/Sampling.py
+++ b/aalpy/utils/Sampling.py
@@ -22,7 +22,8 @@ def get_io_traces(automaton: Automaton, input_traces: list) -> list:
traces = []
for input_trace in input_traces:
- output_trace = automaton.execute_sequence(automaton.initial_state, input_trace)
+ output_trace = automaton.execute_sequence(automaton.initial_state, input_trace) if input_trace else []
+
trace = list(zip(input_trace, output_trace))
if is_moore:
trace = [automaton.initial_state.output] + trace
From 3e7ecc938ba718eb1e91d085fba7c555fe002e86 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:14:50 +0200
Subject: [PATCH 11/25] Add automata tests
---
tests/automata/test_automaton_base.py | 68 +++++
tests/automata/test_dfa.py | 279 ++++++++++++++++++
tests/automata/test_markov_chain.py | 128 ++++++++
tests/automata/test_mdp.py | 151 ++++++++++
tests/automata/test_mealy_machine.py | 146 +++++++++
tests/automata/test_moore_machine.py | 150 ++++++++++
.../test_non_deterministic_moore_machine.py | 150 ++++++++++
tests/automata/test_sevpa.py | 158 ++++++++++
.../automata/test_stochastic_mealy_machine.py | 160 ++++++++++
tests/automata/test_vpa.py | 177 +++++++++++
tests/conftest.py | 15 +
tests/oracles/test_baseOracle.py | 2 +-
.../test_kWayTransitionCoverageEqOracle.py | 15 +-
tests/tests_imports.py | 47 +++
14 files changed, 1640 insertions(+), 6 deletions(-)
create mode 100644 tests/automata/test_automaton_base.py
create mode 100644 tests/automata/test_dfa.py
create mode 100644 tests/automata/test_markov_chain.py
create mode 100644 tests/automata/test_mdp.py
create mode 100644 tests/automata/test_mealy_machine.py
create mode 100644 tests/automata/test_moore_machine.py
create mode 100644 tests/automata/test_non_deterministic_moore_machine.py
create mode 100644 tests/automata/test_sevpa.py
create mode 100644 tests/automata/test_stochastic_mealy_machine.py
create mode 100644 tests/automata/test_vpa.py
create mode 100644 tests/conftest.py
diff --git a/tests/automata/test_automaton_base.py b/tests/automata/test_automaton_base.py
new file mode 100644
index 00000000000..6b647a22ebc
--- /dev/null
+++ b/tests/automata/test_automaton_base.py
@@ -0,0 +1,68 @@
+import unittest
+
+from aalpy.automata import Dfa, DfaState
+from aalpy.base import AutomatonState
+
+
+class TestAutomatonState(unittest.TestCase):
+ def test_get_diff_and_same_state_transitions(self):
+ s0 = AutomatonState('s0')
+ s1 = AutomatonState('s1')
+ s0.transitions = {'a': s1, 'b': s0, 'c': s0}
+
+ self.assertEqual(sorted(s0.get_diff_state_transitions()), ['a'])
+ self.assertEqual(sorted(s0.get_same_state_transitions()), ['b', 'c'])
+
+ def test_all_self_loops(self):
+ s0 = AutomatonState('s0')
+ s0.transitions = {'a': s0, 'b': s0}
+
+ self.assertEqual(s0.get_diff_state_transitions(), [])
+ self.assertEqual(sorted(s0.get_same_state_transitions()), ['a', 'b'])
+
+ def test_all_different_states(self):
+ s0 = AutomatonState('s0')
+ s1 = AutomatonState('s1')
+ s2 = AutomatonState('s2')
+ s0.transitions = {'a': s1, 'b': s2}
+
+ self.assertEqual(sorted(s0.get_diff_state_transitions()), ['a', 'b'])
+ self.assertEqual(s0.get_same_state_transitions(), [])
+
+ def test_no_transitions(self):
+ s0 = AutomatonState('s0')
+ s0.transitions = {}
+
+ self.assertEqual(s0.get_diff_state_transitions(), [])
+ self.assertEqual(s0.get_same_state_transitions(), [])
+
+ def test_prefix_defaults_to_none(self):
+ s0 = AutomatonState('s0')
+ self.assertIsNone(s0.prefix)
+
+
+class TestAutomatonGeneric(unittest.TestCase):
+ def test_size_property(self):
+ q0 = DfaState('q0', True)
+ q1 = DfaState('q1', False)
+ q0.transitions = {'a': q1}
+ q1.transitions = {'a': q0}
+ dfa = Dfa(q0, [q0, q1])
+ self.assertEqual(dfa.size, 2)
+
+ def test_current_state_initialized_to_initial_state(self):
+ q0 = DfaState('q0', True)
+ q0.transitions = {'a': q0}
+ dfa = Dfa(q0, [q0])
+ self.assertIs(dfa.current_state, dfa.initial_state)
+
+ def test_str_returns_string_representation(self):
+ q0 = DfaState('q0', True)
+ q0.transitions = {'a': q0}
+ dfa = Dfa(q0, [q0])
+ self.assertIsInstance(str(dfa), str)
+ self.assertIn('q0', str(dfa))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_dfa.py b/tests/automata/test_dfa.py
new file mode 100644
index 00000000000..126a8d7550c
--- /dev/null
+++ b/tests/automata/test_dfa.py
@@ -0,0 +1,279 @@
+import pickle
+import unittest
+
+from aalpy.automata import Dfa, DfaState
+
+
+def parity_dfa():
+ """2-state complete, minimal DFA accepting words with an even number of 'a's."""
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ dfa = Dfa(q0, [q0, q1])
+ dfa.compute_prefixes()
+ return dfa, q0, q1
+
+
+def contains_a_dfa_non_minimal():
+ """3-state DFA accepting words containing at least one 'a'; q1 and q2 are equivalent."""
+ q0 = DfaState('q0', is_accepting=False)
+ q1 = DfaState('q1', is_accepting=True)
+ q2 = DfaState('q2', is_accepting=True)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q2, 'b': q2}
+ q2.transitions = {'a': q2, 'b': q2}
+ dfa = Dfa(q0, [q0, q1, q2])
+ dfa.compute_prefixes()
+ return dfa, q0, q1, q2
+
+
+class TestDfaState(unittest.TestCase):
+ def test_output_property_mirrors_is_accepting(self):
+ state = DfaState('s', is_accepting=True)
+ self.assertTrue(state.output)
+ state.is_accepting = False
+ self.assertFalse(state.output)
+
+ def test_default_not_accepting(self):
+ state = DfaState('s')
+ self.assertFalse(state.is_accepting)
+
+
+class TestDfaStep(unittest.TestCase):
+ def test_step_transitions_and_reports_acceptance(self):
+ dfa, q0, q1 = parity_dfa()
+ self.assertTrue(dfa.step('a') is False) # moved to q1 (non-accepting)
+ self.assertIs(dfa.current_state, q1)
+ self.assertTrue(dfa.step('a') is True) # back to q0 (accepting)
+ self.assertIs(dfa.current_state, q0)
+
+ def test_step_none_does_not_move_but_reports_current_acceptance(self):
+ dfa, q0, q1 = parity_dfa()
+ dfa.step('a')
+ self.assertIs(dfa.current_state, q1)
+ result = dfa.step(None)
+ self.assertFalse(result)
+ self.assertIs(dfa.current_state, q1)
+
+ def test_step_with_unknown_letter_raises(self):
+ dfa, _, _ = parity_dfa()
+ with self.assertRaises(KeyError):
+ dfa.step('unknown_letter')
+
+ def test_reset_to_initial(self):
+ dfa, q0, _ = parity_dfa()
+ dfa.step('a')
+ dfa.step('a')
+ dfa.step('a')
+ self.assertIsNot(dfa.current_state, q0)
+ dfa.reset_to_initial()
+ self.assertIs(dfa.current_state, q0)
+
+
+class TestDfaExecuteAndOutputSeq(unittest.TestCase):
+ def test_execute_sequence_matches_stepwise(self):
+ dfa, q0, q1 = parity_dfa()
+ result = dfa.execute_sequence(q0, ['a', 'a', 'b', 'a'])
+ self.assertEqual(result, [False, True, True, False])
+
+ def test_execute_sequence_empty_returns_state_output(self):
+ dfa, q0, q1 = parity_dfa()
+ result = dfa.execute_sequence(q1, [])
+ self.assertFalse(result)
+ self.assertIs(dfa.current_state, q1)
+
+ def test_compute_output_seq_empty(self):
+ dfa, q0, q1 = parity_dfa()
+ self.assertEqual(dfa.compute_output_seq(q1, []), [False])
+
+ def test_compute_output_seq_does_not_mutate_current_state(self):
+ dfa, q0, q1 = parity_dfa()
+ dfa.reset_to_initial()
+ dfa.compute_output_seq(q1, ['a', 'a', 'a'])
+ self.assertIs(dfa.current_state, q0)
+
+
+class TestDfaStructuralQueries(unittest.TestCase):
+ def test_size(self):
+ dfa, *_ = parity_dfa()
+ self.assertEqual(dfa.size, 2)
+
+ def test_get_input_alphabet(self):
+ dfa, *_ = parity_dfa()
+ self.assertEqual(set(dfa.get_input_alphabet()), {'a', 'b'})
+
+ def test_get_state_by_id(self):
+ dfa, q0, q1 = parity_dfa()
+ self.assertIs(dfa.get_state_by_id('q1'), q1)
+ self.assertIsNone(dfa.get_state_by_id('does_not_exist'))
+
+ def test_is_input_complete_true(self):
+ dfa, *_ = parity_dfa()
+ self.assertTrue(dfa.is_input_complete())
+
+ def test_is_input_complete_false(self):
+ q0 = DfaState('q0')
+ q1 = DfaState('q1')
+ q0.transitions = {'a': q1} # missing 'b'
+ q1.transitions = {'a': q1, 'b': q1}
+ dfa = Dfa(q0, [q0, q1])
+ self.assertFalse(dfa.is_input_complete())
+
+ def test_get_shortest_path_same_state(self):
+ dfa, q0, _ = parity_dfa()
+ self.assertEqual(dfa.get_shortest_path(q0, q0), ())
+
+ def test_get_shortest_path_reachable(self):
+ dfa, q0, q1 = parity_dfa()
+ path = dfa.get_shortest_path(q0, q1)
+ self.assertEqual(dfa.execute_sequence(q0, list(path)), [False] * (len(path) - 1) + [False])
+ # following the path from q0 must land exactly on q1
+ self.assertIs(dfa.current_state, q1)
+
+ def test_get_shortest_path_unreachable_returns_none(self):
+ dfa, q0, q1 = parity_dfa()
+ unreachable = DfaState('isolated')
+ unreachable.transitions = {'a': unreachable, 'b': unreachable}
+ dfa.states.append(unreachable)
+ self.assertIsNone(dfa.get_shortest_path(q0, unreachable))
+
+ def test_get_shortest_path_state_not_in_automaton_warns_and_returns_none(self):
+ dfa, q0, _ = parity_dfa()
+ foreign = DfaState('foreign')
+ with self.assertWarns(UserWarning):
+ result = dfa.get_shortest_path(q0, foreign)
+ self.assertIsNone(result)
+
+ def test_is_strongly_connected_true(self):
+ dfa, *_ = parity_dfa()
+ self.assertTrue(dfa.is_strongly_connected())
+
+ def test_is_strongly_connected_false(self):
+ dfa, q0, q1, q2 = contains_a_dfa_non_minimal()
+ # once we leave q0 we can never return to it
+ self.assertFalse(dfa.is_strongly_connected())
+
+ def test_is_strongly_connected_single_state(self):
+ q0 = DfaState('q0', is_accepting=True)
+ q0.transitions = {'a': q0}
+ dfa = Dfa(q0, [q0])
+ self.assertTrue(dfa.is_strongly_connected())
+
+
+class TestDfaCharacterizationSet(unittest.TestCase):
+ def test_is_minimal_true_for_minimal_dfa(self):
+ dfa, *_ = parity_dfa()
+ self.assertTrue(dfa.is_minimal())
+
+ def test_is_minimal_false_for_redundant_states(self):
+ dfa, *_ = contains_a_dfa_non_minimal()
+ self.assertFalse(dfa.is_minimal())
+
+ def test_compute_characterization_set_distinguishes_all_states(self):
+ dfa, q0, q1 = parity_dfa()
+ char_set = dfa.compute_characterization_set()
+ self.assertIsNotNone(char_set)
+ outputs = {tuple(tuple(dfa.compute_output_seq(s, list(seq))) for seq in char_set) for s in (q0, q1)}
+ self.assertEqual(len(outputs), 2)
+
+ def test_compute_characterization_set_return_same_states_for_non_minimal(self):
+ dfa, q0, q1, q2 = contains_a_dfa_non_minimal()
+ s1, s2 = dfa.compute_characterization_set(return_same_states=True)
+ self.assertEqual({s1, s2}, {q1, q2})
+
+ def test_compute_characterization_set_return_same_states_none_for_minimal(self):
+ dfa, *_ = parity_dfa()
+ s1, s2 = dfa.compute_characterization_set(return_same_states=True)
+ self.assertIsNone(s1)
+ self.assertIsNone(s2)
+
+
+class TestDfaMinimize(unittest.TestCase):
+ def test_minimize_reduces_redundant_states(self):
+ dfa, *_ = contains_a_dfa_non_minimal()
+ dfa.minimize()
+ self.assertEqual(dfa.size, 2)
+
+ def test_minimize_preserves_language(self):
+ dfa, q0, q1, q2 = contains_a_dfa_non_minimal()
+ words = [[], ['b'], ['a'], ['b', 'b'], ['a', 'b', 'a'], ['b', 'a', 'a']]
+ expected = {tuple(w): dfa.execute_sequence(q0, w) for w in words}
+ dfa.minimize()
+ for w, exp in expected.items():
+ self.assertEqual(dfa.execute_sequence(dfa.initial_state, list(w)), exp)
+
+ def test_minimize_noop_on_already_minimal_dfa(self):
+ dfa, *_ = parity_dfa()
+ dfa.minimize()
+ self.assertEqual(dfa.size, 2)
+
+ def test_minimize_warns_on_incomplete_automaton(self):
+ q0 = DfaState('q0')
+ q1 = DfaState('q1')
+ q0.transitions = {'a': q1}
+ dfa = Dfa(q0, [q0, q1])
+ with self.assertWarns(UserWarning):
+ dfa.minimize()
+ # nothing should have been merged
+ self.assertEqual(dfa.size, 2)
+
+
+class TestDfaStateSetupRoundtrip(unittest.TestCase):
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ dfa, q0, q1 = parity_dfa()
+ setup = dfa.to_state_setup()
+ rebuilt = Dfa.from_state_setup(setup)
+
+ for w in [[], ['a'], ['b'], ['a', 'a'], ['a', 'b', 'a', 'a']]:
+ self.assertEqual(rebuilt.execute_sequence(rebuilt.initial_state, w),
+ dfa.execute_sequence(dfa.initial_state, w))
+
+ def test_from_state_setup_first_key_is_initial_state(self):
+ setup = {
+ 'a': (True, {'x': 'b', 'y': 'a'}),
+ 'b': (False, {'x': 'a', 'y': 'b'}),
+ }
+ dfa = Dfa.from_state_setup(setup)
+ self.assertEqual(dfa.initial_state.state_id, 'a')
+ self.assertTrue(dfa.initial_state.is_accepting)
+
+ def test_copy_produces_independent_deep_copy(self):
+ dfa, q0, q1 = parity_dfa()
+ dfa_copy = dfa.copy()
+ self.assertEqual(dfa.size, dfa_copy.size)
+ # mutating the copy must not affect the original
+ dfa_copy.get_state_by_id('q0').is_accepting = False
+ self.assertTrue(dfa.get_state_by_id('q0').is_accepting)
+
+ def test_pickle_roundtrip(self):
+ dfa, q0, q1 = parity_dfa()
+ restored = pickle.loads(pickle.dumps(dfa))
+ for w in [[], ['a'], ['b', 'a']]:
+ self.assertEqual(restored.execute_sequence(restored.initial_state, w),
+ dfa.execute_sequence(dfa.initial_state, w))
+
+
+class TestDfaEquality(unittest.TestCase):
+ def test_eq_true_for_bisimilar_automata_with_different_structure(self):
+ minimal, *_ = parity_dfa()
+ non_minimal, *_ = contains_a_dfa_non_minimal()
+
+ # build a differently-labeled but equivalent 2-state DFA for the 'contains a' language
+ r0 = DfaState('r0', is_accepting=False)
+ r1 = DfaState('r1', is_accepting=True)
+ r0.transitions = {'a': r1, 'b': r0}
+ r1.transitions = {'a': r1, 'b': r1}
+ relabeled = Dfa(r0, [r0, r1])
+
+ # non_minimal has states named differently but same language as `relabeled`
+ self.assertEqual(non_minimal, relabeled)
+
+ def test_eq_false_for_different_languages(self):
+ parity, *_ = parity_dfa()
+ contains_a, *_ = contains_a_dfa_non_minimal()
+ self.assertNotEqual(parity, contains_a)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_markov_chain.py b/tests/automata/test_markov_chain.py
new file mode 100644
index 00000000000..39be0689184
--- /dev/null
+++ b/tests/automata/test_markov_chain.py
@@ -0,0 +1,128 @@
+import random
+import unittest
+
+from aalpy.automata import MarkovChain, McState
+
+
+def deterministic_chain():
+ """s0(A) -[1.0]-> s1(B) -[1.0]-> s2(C), s2 is terminal (no outgoing transitions)."""
+ s0 = McState('s0', output='A')
+ s1 = McState('s1', output='B')
+ s2 = McState('s2', output='C')
+ s0.transitions.append((s1, 1.0))
+ s1.transitions.append((s2, 1.0))
+ return MarkovChain(s0, [s0, s1, s2]), s0, s1, s2
+
+
+def branching_chain():
+ s0 = McState('s0', output='A')
+ s1 = McState('s1', output='B')
+ s2 = McState('s2', output='C')
+ s0.transitions.append((s1, 0.5))
+ s0.transitions.append((s2, 0.5))
+ return MarkovChain(s0, [s0, s1, s2]), s0, s1, s2
+
+
+class TestMcState(unittest.TestCase):
+ def test_default_transitions_is_empty_list(self):
+ state = McState('s', output='A')
+ self.assertEqual(state.transitions, [])
+
+
+class TestMarkovChainStep(unittest.TestCase):
+ def test_step_moves_and_returns_output(self):
+ mc, s0, s1, s2 = deterministic_chain()
+ self.assertEqual(mc.step(), 'B')
+ self.assertIs(mc.current_state, s1)
+
+ def test_step_on_terminal_state_returns_output_without_moving(self):
+ mc, s0, s1, s2 = deterministic_chain()
+ mc.step()
+ mc.step()
+ self.assertIs(mc.current_state, s2)
+ # s2 has no outgoing transitions, so stepping again is a no-op
+ self.assertEqual(mc.step(), 'C')
+ self.assertIs(mc.current_state, s2)
+
+ def test_reset_to_initial(self):
+ mc, s0, s1, s2 = deterministic_chain()
+ mc.step()
+ self.assertIsNot(mc.current_state, s0)
+ mc.reset_to_initial()
+ self.assertIs(mc.current_state, s0)
+
+ def test_step_respects_branching_distribution(self):
+ mc, s0, s1, s2 = branching_chain()
+ for seed in range(20):
+ mc.reset_to_initial()
+ random.seed(seed)
+ output = mc.step()
+ self.assertIn(output, ('B', 'C'))
+ self.assertIn(mc.current_state, (s1, s2))
+
+
+class TestMarkovChainExecuteSequence(unittest.TestCase):
+ def test_execute_sequence_matches_stepwise(self):
+ mc, s0, s1, s2 = deterministic_chain()
+ # MarkovChain.step() ignores its argument; only the sequence's length matters
+ result = mc.execute_sequence(s0, [None, None])
+ self.assertEqual(result, ['B', 'C'])
+ self.assertIs(mc.current_state, s2)
+
+ def test_execute_sequence_empty_returns_empty_list(self):
+ mc, s0, s1, s2 = deterministic_chain()
+ self.assertEqual(mc.execute_sequence(s0, []), [])
+
+ def test_execute_sequence_resets_to_origin_state_first(self):
+ mc, s0, s1, s2 = deterministic_chain()
+ mc.reset_to_initial()
+ mc.step() # move to s1
+ result = mc.execute_sequence(s0, [None])
+ self.assertEqual(result, ['B'])
+
+
+class TestMarkovChainStepTo(unittest.TestCase):
+ def test_step_to_moves_to_matching_output_state(self):
+ mc, s0, s1, s2 = branching_chain()
+ result = mc.step_to('C')
+ self.assertEqual(result, 'C')
+ self.assertIs(mc.current_state, s2)
+
+ def test_step_to_returns_none_for_unreachable_output(self):
+ mc, s0, s1, s2 = branching_chain()
+ result = mc.step_to('does_not_exist')
+ self.assertIsNone(result)
+ self.assertIs(mc.current_state, s0) # state unchanged on failed step_to
+
+
+class TestMarkovChainUnimplemented(unittest.TestCase):
+ def test_to_state_setup_not_implemented(self):
+ mc, *_ = deterministic_chain()
+ with self.assertRaises(NotImplementedError):
+ mc.to_state_setup()
+
+ def test_from_state_setup_not_implemented(self):
+ with self.assertRaises(NotImplementedError):
+ MarkovChain.from_state_setup({})
+
+ def test_copy_not_implemented(self):
+ mc, *_ = deterministic_chain()
+ with self.assertRaises(NotImplementedError):
+ mc.copy()
+
+ def test_get_input_alphabet_not_supported(self):
+ # McState.transitions is a plain list (not a dict), so the generic Automaton.get_input_alphabet
+ # (which does state.transitions.keys()) cannot work on a MarkovChain. Documents current behaviour.
+ mc, *_ = deterministic_chain()
+ with self.assertRaises(AttributeError):
+ mc.get_input_alphabet()
+
+
+class TestMarkovChainStructural(unittest.TestCase):
+ def test_size(self):
+ mc, *_ = deterministic_chain()
+ self.assertEqual(mc.size, 3)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_mdp.py b/tests/automata/test_mdp.py
new file mode 100644
index 00000000000..35b24c1feb1
--- /dev/null
+++ b/tests/automata/test_mdp.py
@@ -0,0 +1,151 @@
+import random
+import unittest
+
+from aalpy.automata import Mdp, MdpState
+
+
+def deterministic_mdp():
+ """
+ 2-state MDP over alphabet {a, b} with only probability-1.0 transitions, so behaviour is deterministic.
+ s0(A) --a[1.0]--> s1 s0 --b[1.0]--> s0
+ s1(B) --a[1.0]--> s0 s1 --b[1.0]--> s1
+ """
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s0.transitions['a'].append((s1, 1.0))
+ s0.transitions['b'].append((s0, 1.0))
+ s1.transitions['a'].append((s0, 1.0))
+ s1.transitions['b'].append((s1, 1.0))
+ return Mdp(s0, [s0, s1]), s0, s1
+
+
+def branching_mdp():
+ """s0 --a--> s1 (0.5) or s2 (0.5)."""
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s2 = MdpState('s2', output='C')
+ s0.transitions['a'].append((s1, 0.5))
+ s0.transitions['a'].append((s2, 0.5))
+ return Mdp(s0, [s0, s1, s2]), s0, s1, s2
+
+
+class TestMdpState(unittest.TestCase):
+ def test_default_transitions_is_empty_defaultdict(self):
+ state = MdpState('s')
+ self.assertEqual(state.transitions['unused_key'], [])
+
+ def test_default_output_is_none(self):
+ state = MdpState('s')
+ self.assertIsNone(state.output)
+
+
+class TestMdpStep(unittest.TestCase):
+ def test_step_moves_and_returns_output(self):
+ mdp, s0, s1 = deterministic_mdp()
+ self.assertEqual(mdp.step('a'), 'B')
+ self.assertIs(mdp.current_state, s1)
+
+ def test_step_none_returns_current_output_without_moving(self):
+ mdp, s0, s1 = deterministic_mdp()
+ mdp.step('a')
+ self.assertEqual(mdp.step(None), 'B')
+ self.assertIs(mdp.current_state, s1)
+
+ def test_step_unknown_letter_raises_on_empty_distribution(self):
+ mdp, *_ = deterministic_mdp()
+ with self.assertRaises(IndexError):
+ mdp.step('unknown_letter')
+
+ def test_reset_to_initial(self):
+ mdp, s0, s1 = deterministic_mdp()
+ mdp.step('a')
+ self.assertIsNot(mdp.current_state, s0)
+ mdp.reset_to_initial()
+ self.assertIs(mdp.current_state, s0)
+
+ def test_step_respects_branching_distribution(self):
+ mdp, s0, s1, s2 = branching_mdp()
+ for seed in range(20):
+ mdp.reset_to_initial()
+ random.seed(seed)
+ output = mdp.step('a')
+ self.assertIn(output, ('B', 'C'))
+ self.assertIn(mdp.current_state, (s1, s2))
+
+
+class TestMdpExecuteSequence(unittest.TestCase):
+ def test_execute_sequence_matches_stepwise(self):
+ mdp, s0, s1 = deterministic_mdp()
+ result = mdp.execute_sequence(s0, ['a', 'b', 'a', 'a'])
+ self.assertEqual(result, ['B', 'B', 'A', 'B'])
+ self.assertIs(mdp.current_state, s1)
+
+ def test_execute_sequence_empty_returns_empty_list(self):
+ mdp, s0, s1 = deterministic_mdp()
+ self.assertEqual(mdp.execute_sequence(s0, []), [])
+
+ def test_execute_sequence_resets_to_origin_state_first(self):
+ mdp, s0, s1 = deterministic_mdp()
+ mdp.reset_to_initial()
+ mdp.step('a') # move to s1
+ result = mdp.execute_sequence(s0, ['a'])
+ # regardless of where the mdp was, execute_sequence starts fresh from origin_state
+ self.assertEqual(result, ['B'])
+
+
+class TestMdpStepTo(unittest.TestCase):
+ def test_step_to_moves_to_matching_output_state(self):
+ mdp, s0, s1, s2 = branching_mdp()
+ result = mdp.step_to('a', 'C')
+ self.assertEqual(result, 'C')
+ self.assertIs(mdp.current_state, s2)
+
+ def test_step_to_returns_none_for_unreachable_output(self):
+ mdp, s0, s1, s2 = branching_mdp()
+ result = mdp.step_to('a', 'does_not_exist')
+ self.assertIsNone(result)
+ self.assertIs(mdp.current_state, s0) # state unchanged on failed step_to
+
+
+class TestMdpStateSetupRoundtrip(unittest.TestCase):
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ mdp, s0, s1 = deterministic_mdp()
+ setup = mdp.to_state_setup()
+ rebuilt = Mdp.from_state_setup(setup)
+
+ for w in [[], ['a'], ['b'], ['a', 'a', 'b']]:
+ rebuilt.reset_to_initial()
+ outputs = [rebuilt.step(letter) for letter in w]
+ mdp.reset_to_initial()
+ expected = [mdp.step(letter) for letter in w]
+ self.assertEqual(outputs, expected)
+
+ def test_from_state_setup_first_key_is_initial_state(self):
+ setup = {
+ 's0': ('A', {'a': [('s1', 1.0)]}),
+ 's1': ('B', {'a': [('s0', 1.0)]}),
+ }
+ mdp = Mdp.from_state_setup(setup)
+ self.assertEqual(mdp.initial_state.state_id, 's0')
+ self.assertEqual(mdp.initial_state.output, 'A')
+
+ def test_to_state_setup_puts_initial_state_first(self):
+ mdp, s0, s1 = deterministic_mdp()
+ # reorder states so initial_state is not first in the list
+ mdp.states = [s1, s0]
+ mdp.to_state_setup()
+ self.assertIs(mdp.states[0], s0)
+
+
+class TestMdpStructural(unittest.TestCase):
+ def test_get_input_alphabet(self):
+ mdp, *_ = deterministic_mdp()
+ self.assertEqual(set(mdp.get_input_alphabet()), {'a', 'b'})
+
+ def test_size(self):
+ mdp, *_ = deterministic_mdp()
+ self.assertEqual(mdp.size, 2)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_mealy_machine.py b/tests/automata/test_mealy_machine.py
new file mode 100644
index 00000000000..f92dbc460bc
--- /dev/null
+++ b/tests/automata/test_mealy_machine.py
@@ -0,0 +1,146 @@
+import pickle
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+
+
+def sample_mealy():
+ """
+ 2-state Mealy machine over alphabet {x, y}.
+ s0 --x/o1--> s1 s0 --y/o2--> s0
+ s1 --x/o3--> s0 s1 --y/o1--> s1
+ """
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'x': s1, 'y': s0}
+ s0.output_fun = {'x': 'o1', 'y': 'o2'}
+ s1.transitions = {'x': s0, 'y': s1}
+ s1.output_fun = {'x': 'o3', 'y': 'o1'}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm, s0, s1
+
+
+class TestMealyState(unittest.TestCase):
+ def test_defaults_are_empty(self):
+ state = MealyState('s')
+ self.assertEqual(state.transitions, {})
+ self.assertEqual(state.output_fun, {})
+
+
+class TestMealyStep(unittest.TestCase):
+ def test_step_returns_output_and_moves_state(self):
+ mm, s0, s1 = sample_mealy()
+ output = mm.step('x')
+ self.assertEqual(output, 'o1')
+ self.assertIs(mm.current_state, s1)
+
+ def test_step_sequence(self):
+ mm, s0, s1 = sample_mealy()
+ outputs = [mm.step(i) for i in ['x', 'x', 'y', 'x']]
+ self.assertEqual(outputs, ['o1', 'o3', 'o2', 'o1'])
+
+ def test_step_unknown_letter_raises(self):
+ mm, *_ = sample_mealy()
+ with self.assertRaises(KeyError):
+ mm.step('z')
+
+
+class TestMealyExecuteAndOutputSeq(unittest.TestCase):
+ def test_execute_sequence(self):
+ mm, s0, s1 = sample_mealy()
+ result = mm.execute_sequence(s0, ['x', 'y', 'x'])
+ self.assertEqual(result, ['o1', 'o1', 'o3'])
+
+ def test_execute_sequence_empty_returns_empty_list(self):
+ mm, s0, _ = sample_mealy()
+ self.assertEqual(mm.execute_sequence(s0, []), [])
+
+ def test_compute_output_seq_does_not_mutate_current_state(self):
+ mm, s0, s1 = sample_mealy()
+ mm.reset_to_initial()
+ mm.compute_output_seq(s1, ['x', 'x'])
+ self.assertIs(mm.current_state, s0)
+
+
+class TestMealyStructural(unittest.TestCase):
+ def test_get_input_alphabet(self):
+ mm, *_ = sample_mealy()
+ self.assertEqual(set(mm.get_input_alphabet()), {'x', 'y'})
+
+ def test_is_input_complete_true(self):
+ mm, *_ = sample_mealy()
+ self.assertTrue(mm.is_input_complete())
+
+ def test_is_input_complete_false(self):
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'x': s1}
+ s0.output_fun = {'x': 'o1'}
+ s1.transitions = {'x': s1, 'y': s1}
+ s1.output_fun = {'x': 'o1', 'y': 'o1'}
+ mm = MealyMachine(s0, [s0, s1])
+ self.assertFalse(mm.is_input_complete())
+
+ def test_find_distinguishing_seq(self):
+ mm, s0, s1 = sample_mealy()
+ seq = mm.find_distinguishing_seq(s0, s1, mm.get_input_alphabet())
+ self.assertIsNotNone(seq)
+ self.assertNotEqual(mm.compute_output_seq(s0, seq), mm.compute_output_seq(s1, seq))
+
+ def test_find_distinguishing_seq_same_state_is_none(self):
+ mm, s0, _ = sample_mealy()
+ self.assertIsNone(mm.find_distinguishing_seq(s0, s0, mm.get_input_alphabet()))
+
+ def test_is_minimal(self):
+ mm, *_ = sample_mealy()
+ self.assertTrue(mm.is_minimal())
+
+
+class TestMealyStateSetupRoundtrip(unittest.TestCase):
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ mm, s0, s1 = sample_mealy()
+ setup = mm.to_state_setup()
+ rebuilt = MealyMachine.from_state_setup(setup)
+
+ for w in [[], ['x'], ['y'], ['x', 'x', 'y'], ['y', 'x', 'x', 'y']]:
+ self.assertEqual(rebuilt.execute_sequence(rebuilt.initial_state, w),
+ mm.execute_sequence(mm.initial_state, w))
+
+ def test_copy_is_independent(self):
+ mm, s0, s1 = sample_mealy()
+ mm_copy = mm.copy()
+ mm_copy.get_state_by_id('s0').output_fun['x'] = 'CHANGED'
+ self.assertEqual(mm.get_state_by_id('s0').output_fun['x'], 'o1')
+
+ def test_pickle_roundtrip(self):
+ mm, *_ = sample_mealy()
+ restored = pickle.loads(pickle.dumps(mm))
+ for w in [[], ['x'], ['y', 'x']]:
+ self.assertEqual(restored.execute_sequence(restored.initial_state, w),
+ mm.execute_sequence(mm.initial_state, w))
+
+
+class TestMealyEquality(unittest.TestCase):
+ def test_eq_true_for_relabeled_equivalent_machine(self):
+ mm, *_ = sample_mealy()
+
+ t0 = MealyState('t0')
+ t1 = MealyState('t1')
+ t0.transitions = {'x': t1, 'y': t0}
+ t0.output_fun = {'x': 'o1', 'y': 'o2'}
+ t1.transitions = {'x': t0, 'y': t1}
+ t1.output_fun = {'x': 'o3', 'y': 'o1'}
+ relabeled = MealyMachine(t0, [t0, t1])
+
+ self.assertEqual(mm, relabeled)
+
+ def test_eq_false_for_different_outputs(self):
+ mm, *_ = sample_mealy()
+ other = mm.copy()
+ other.get_state_by_id('s0').output_fun['x'] = 'different_output'
+ self.assertNotEqual(mm, other)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_moore_machine.py b/tests/automata/test_moore_machine.py
new file mode 100644
index 00000000000..9777cb9d576
--- /dev/null
+++ b/tests/automata/test_moore_machine.py
@@ -0,0 +1,150 @@
+import pickle
+import unittest
+
+from aalpy.automata import Dfa, MooreMachine, MooreState
+
+
+def sample_moore():
+ """
+ 3-state Moore machine over alphabet {x, y}, outputs 'A', 'B', 'C' per state.
+ s0(A) --x--> s1(B) s0 --y--> s0
+ s1(B) --x--> s2(C) s1 --y--> s0
+ s2(C) --x--> s2 s2 --y--> s0
+ """
+ s0 = MooreState('s0', output='A')
+ s1 = MooreState('s1', output='B')
+ s2 = MooreState('s2', output='C')
+ s0.transitions = {'x': s1, 'y': s0}
+ s1.transitions = {'x': s2, 'y': s0}
+ s2.transitions = {'x': s2, 'y': s0}
+ mm = MooreMachine(s0, [s0, s1, s2])
+ mm.compute_prefixes()
+ return mm, s0, s1, s2
+
+
+def boolean_moore():
+ s0 = MooreState('s0', output=True)
+ s1 = MooreState('s1', output=False)
+ s0.transitions = {'a': s1, 'b': s0}
+ s1.transitions = {'a': s0, 'b': s1}
+ mm = MooreMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm, s0, s1
+
+
+class TestMooreStep(unittest.TestCase):
+ def test_step_returns_output_of_reached_state(self):
+ mm, s0, s1, s2 = sample_moore()
+ self.assertEqual(mm.step('x'), 'B')
+ self.assertIs(mm.current_state, s1)
+
+ def test_step_none_returns_current_output_without_moving(self):
+ mm, s0, s1, s2 = sample_moore()
+ mm.step('x')
+ self.assertEqual(mm.step(None), 'B')
+ self.assertIs(mm.current_state, s1)
+
+ def test_step_unknown_letter_raises(self):
+ mm, *_ = sample_moore()
+ with self.assertRaises(KeyError):
+ mm.step('z')
+
+
+class TestMooreExecuteAndOutputSeq(unittest.TestCase):
+ def test_execute_sequence(self):
+ mm, s0, s1, s2 = sample_moore()
+ result = mm.execute_sequence(s0, ['x', 'x', 'y', 'x'])
+ self.assertEqual(result, ['B', 'C', 'A', 'B'])
+
+ def test_execute_sequence_empty_returns_state_output(self):
+ mm, s0, s1, s2 = sample_moore()
+ self.assertEqual(mm.execute_sequence(s1, []), 'B')
+ self.assertIs(mm.current_state, s1)
+
+ def test_compute_output_seq_empty(self):
+ mm, s0, s1, s2 = sample_moore()
+ self.assertEqual(mm.compute_output_seq(s2, []), ['C'])
+
+ def test_compute_output_seq_does_not_mutate_current_state(self):
+ mm, s0, s1, s2 = sample_moore()
+ mm.reset_to_initial()
+ mm.compute_output_seq(s2, ['x', 'y'])
+ self.assertIs(mm.current_state, s0)
+
+
+class TestMooreCharacterizationSet(unittest.TestCase):
+ def test_is_minimal_true(self):
+ mm, *_ = sample_moore()
+ self.assertTrue(mm.is_minimal())
+
+ def test_is_minimal_false_for_redundant_states(self):
+ s0 = MooreState('s0', output='A')
+ s1 = MooreState('s1', output='B')
+ s2 = MooreState('s2', output='B') # equivalent to s1
+ s0.transitions = {'x': s1, 'y': s0}
+ s1.transitions = {'x': s2, 'y': s2}
+ s2.transitions = {'x': s2, 'y': s2}
+ mm = MooreMachine(s0, [s0, s1, s2])
+ self.assertFalse(mm.is_minimal())
+
+
+class TestMooreStateSetupRoundtrip(unittest.TestCase):
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ mm, s0, s1, s2 = sample_moore()
+ setup = mm.to_state_setup()
+ rebuilt = MooreMachine.from_state_setup(setup)
+
+ for w in [[], ['x'], ['x', 'x'], ['x', 'x', 'y', 'x']]:
+ self.assertEqual(rebuilt.execute_sequence(rebuilt.initial_state, w),
+ mm.execute_sequence(mm.initial_state, w))
+
+ def test_pickle_roundtrip(self):
+ mm, *_ = sample_moore()
+ restored = pickle.loads(pickle.dumps(mm))
+ for w in [[], ['x'], ['y', 'x']]:
+ self.assertEqual(restored.execute_sequence(restored.initial_state, w),
+ mm.execute_sequence(mm.initial_state, w))
+
+
+class TestMooreToDfa(unittest.TestCase):
+ def test_to_dfa_preserves_language(self):
+ mm, s0, s1 = boolean_moore()
+ dfa = MooreMachine.to_dfa(mm)
+ self.assertIsInstance(dfa, Dfa)
+ for w in [[], ['a'], ['b'], ['a', 'a'], ['a', 'b', 'a']]:
+ moore_outputs = mm.execute_sequence(mm.initial_state, w)
+ dfa_result = dfa.execute_sequence(dfa.initial_state, w)
+ self.assertEqual(dfa_result, moore_outputs)
+
+ def test_to_dfa_rejects_non_boolean_outputs(self):
+ mm, *_ = sample_moore() # outputs are strings, not bool
+ with self.assertRaises(ValueError):
+ MooreMachine.to_dfa(mm)
+
+ def test_to_dfa_preserves_state_count(self):
+ mm, *_ = boolean_moore()
+ dfa = MooreMachine.to_dfa(mm)
+ self.assertEqual(dfa.size, mm.size)
+
+
+class TestMooreEquality(unittest.TestCase):
+ def test_eq_true_for_relabeled_equivalent_machine(self):
+ mm, *_ = boolean_moore()
+
+ t0 = MooreState('t0', output=True)
+ t1 = MooreState('t1', output=False)
+ t0.transitions = {'a': t1, 'b': t0}
+ t1.transitions = {'a': t0, 'b': t1}
+ relabeled = MooreMachine(t0, [t0, t1])
+
+ self.assertEqual(mm, relabeled)
+
+ def test_eq_false_for_different_outputs(self):
+ mm, *_ = boolean_moore()
+ other = mm.copy()
+ other.get_state_by_id('s0').output = False
+ self.assertNotEqual(mm, other)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_non_deterministic_moore_machine.py b/tests/automata/test_non_deterministic_moore_machine.py
new file mode 100644
index 00000000000..2c6f24e4844
--- /dev/null
+++ b/tests/automata/test_non_deterministic_moore_machine.py
@@ -0,0 +1,150 @@
+import random
+import unittest
+
+from aalpy.automata import NDMooreMachine, NDMooreState
+
+
+def deterministic_ndmoore():
+ """
+ 2-state NDMoore machine over alphabet {a, b} where every input has a single successor.
+ s0(A) --a--> s1 s0 --b--> s0
+ s1(B) --a--> s0 s1 --b--> s1
+ """
+ s0 = NDMooreState('s0', output='A')
+ s1 = NDMooreState('s1', output='B')
+ s0.transitions['a'].append(s1)
+ s0.transitions['b'].append(s0)
+ s1.transitions['a'].append(s0)
+ s1.transitions['b'].append(s1)
+ return NDMooreMachine(s0, [s0, s1]), s0, s1
+
+
+def branching_ndmoore():
+ """s0 --a--> s1 or s2 (non-deterministic choice)."""
+ s0 = NDMooreState('s0', output='A')
+ s1 = NDMooreState('s1', output='B')
+ s2 = NDMooreState('s2', output='C')
+ s0.transitions['a'].append(s1)
+ s0.transitions['a'].append(s2)
+ return NDMooreMachine(s0, [s0, s1, s2]), s0, s1, s2
+
+
+class TestNDMooreState(unittest.TestCase):
+ def test_default_transitions_is_empty_list(self):
+ state = NDMooreState('s')
+ self.assertEqual(state.transitions['unused_key'], [])
+
+ def test_default_output_is_none(self):
+ state = NDMooreState('s')
+ self.assertIsNone(state.output)
+
+
+class TestNDMooreStep(unittest.TestCase):
+ def test_step_moves_and_returns_output(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ self.assertEqual(mm.step('a'), 'B')
+ self.assertIs(mm.current_state, s1)
+
+ def test_step_sequence(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ outputs = [mm.step(i) for i in ['a', 'b', 'a']]
+ self.assertEqual(outputs, ['B', 'B', 'A'])
+
+ def test_step_picks_among_non_deterministic_choices(self):
+ mm, s0, s1, s2 = branching_ndmoore()
+ seen = set()
+ for seed in range(30):
+ mm.reset_to_initial()
+ random.seed(seed)
+ output = mm.step('a')
+ self.assertIn(output, ('B', 'C'))
+ self.assertIn(mm.current_state, (s1, s2))
+ seen.add(output)
+ # with enough seeds both branches should be exercised at least once
+ self.assertEqual(seen, {'B', 'C'})
+
+ def test_step_unknown_letter_raises_on_empty_options(self):
+ mm, *_ = deterministic_ndmoore()
+ with self.assertRaises(IndexError):
+ mm.step('unknown_letter')
+
+ def test_reset_to_initial(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ mm.step('a')
+ self.assertIsNot(mm.current_state, s0)
+ mm.reset_to_initial()
+ self.assertIs(mm.current_state, s0)
+
+
+class TestNDMooreExecuteSequence(unittest.TestCase):
+ def test_execute_sequence_matches_stepwise(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ result = mm.execute_sequence(s0, ['a', 'b', 'a'])
+ self.assertEqual(result, ['B', 'B', 'A'])
+ self.assertIs(mm.current_state, s0)
+
+ def test_execute_sequence_empty_returns_empty_list(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ self.assertEqual(mm.execute_sequence(s0, []), [])
+
+ def test_execute_sequence_resets_to_origin_state_first(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ mm.reset_to_initial()
+ mm.step('a') # move to s1
+ result = mm.execute_sequence(s0, ['a'])
+ self.assertEqual(result, ['B'])
+
+
+class TestNDMooreStateSetup(unittest.TestCase):
+ def test_to_state_setup_returns_dict(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ setup = mm.to_state_setup()
+ self.assertIsInstance(setup, dict)
+ self.assertEqual(set(setup.keys()), {'s0', 's1'})
+
+ def test_to_state_setup_first_key_is_initial_state(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ setup = mm.to_state_setup()
+ self.assertEqual(next(iter(setup)), 's0')
+
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ setup = mm.to_state_setup()
+ rebuilt = NDMooreMachine.from_state_setup(setup)
+
+ for w in [['a'], ['b'], ['a', 'a', 'b']]:
+ rebuilt.reset_to_initial()
+ outputs = [rebuilt.step(letter) for letter in w]
+ mm.reset_to_initial()
+ expected = [mm.step(letter) for letter in w]
+ self.assertEqual(outputs, expected)
+
+ def test_from_state_setup_first_key_is_initial_state(self):
+ setup = {
+ 's0': ('A', {'a': ['s1']}),
+ 's1': ('B', {'a': ['s0']}),
+ }
+ mm = NDMooreMachine.from_state_setup(setup)
+ self.assertEqual(mm.initial_state.state_id, 's0')
+ self.assertEqual(mm.initial_state.output, 'A')
+
+ def test_copy_produces_independent_deep_copy(self):
+ mm, s0, s1 = deterministic_ndmoore()
+ mm_copy = mm.copy()
+ self.assertEqual(mm.size, mm_copy.size)
+ mm_copy.get_state_by_id('s0').output = 'CHANGED'
+ self.assertEqual(mm.get_state_by_id('s0').output, 'A')
+
+
+class TestNDMooreStructural(unittest.TestCase):
+ def test_get_input_alphabet(self):
+ mm, *_ = deterministic_ndmoore()
+ self.assertEqual(set(mm.get_input_alphabet()), {'a', 'b'})
+
+ def test_size(self):
+ mm, *_ = deterministic_ndmoore()
+ self.assertEqual(mm.size, 2)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_sevpa.py b/tests/automata/test_sevpa.py
new file mode 100644
index 00000000000..611ca6f42e0
--- /dev/null
+++ b/tests/automata/test_sevpa.py
@@ -0,0 +1,158 @@
+import unittest
+
+from aalpy.automata import Sevpa, SevpaAlphabet, SevpaState, SevpaTransition
+
+
+def balanced_parens_sevpa():
+ """
+ Single-module 1-SEVPA recognizing the Dyck language, built via create_daisy_hypothesis:
+ call='(' pushes (q0, '('), return ')' pops iff the stack guard matches.
+ """
+ alphabet = SevpaAlphabet(internal_alphabet=[], call_alphabet=['('], return_alphabet=[')'])
+ q0 = SevpaState('q0', is_accepting=True)
+ sevpa = Sevpa.create_daisy_hypothesis(q0, alphabet)
+ return sevpa, q0, alphabet
+
+
+class TestSevpaAlphabet(unittest.TestCase):
+ def test_get_merged_alphabet(self):
+ alphabet = SevpaAlphabet(internal_alphabet=['i'], call_alphabet=['c'], return_alphabet=['r'])
+ self.assertEqual(alphabet.get_merged_alphabet(), ['i', 'c', 'r'])
+
+
+class TestSevpaState(unittest.TestCase):
+ def test_default_transitions_is_empty_defaultdict(self):
+ state = SevpaState('s')
+ self.assertEqual(state.transitions['unused_key'], [])
+
+ def test_default_not_accepting(self):
+ state = SevpaState('s')
+ self.assertFalse(state.is_accepting)
+
+
+class TestSevpaStep(unittest.TestCase):
+ def test_reset_to_initial_returns_true_for_accepting_empty_stack(self):
+ sevpa, q0, _ = balanced_parens_sevpa()
+ self.assertTrue(sevpa.reset_to_initial())
+
+ def test_accepts_empty_word(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ sevpa.reset_to_initial()
+ self.assertTrue(sevpa.step(None))
+
+ def test_accepts_balanced_word(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ sevpa.reset_to_initial()
+ sevpa.step('(')
+ result = sevpa.step(')')
+ self.assertTrue(result)
+ self.assertEqual(sevpa.stack, [Sevpa.empty])
+
+ def test_rejects_incomplete_word(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ sevpa.reset_to_initial()
+ result = sevpa.step('(')
+ self.assertFalse(result)
+ self.assertEqual(len(sevpa.stack), 2)
+
+ def test_unmatched_return_symbol_traps_in_error_state(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ sevpa.reset_to_initial()
+ result = sevpa.step(')')
+ self.assertFalse(result)
+ self.assertTrue(sevpa.error_state_reached)
+
+ # once trapped, further steps stay False without raising
+ self.assertFalse(sevpa.step('('))
+ self.assertFalse(sevpa.step(None))
+
+ def test_nested_balanced_word(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ sevpa.reset_to_initial()
+ outputs = [sevpa.step(c) for c in '(())']
+ self.assertEqual(outputs, [False, False, False, True])
+
+ def test_reset_to_initial_clears_error_state(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ sevpa.reset_to_initial()
+ sevpa.step(')')
+ self.assertTrue(sevpa.error_state_reached)
+ sevpa.reset_to_initial()
+ self.assertFalse(sevpa.error_state_reached)
+
+
+class TestSevpaStructural(unittest.TestCase):
+ def test_get_input_alphabet(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ alphabet = sevpa.get_input_alphabet()
+ self.assertEqual(alphabet.call_alphabet, ['('])
+ self.assertEqual(alphabet.return_alphabet, [')'])
+ self.assertEqual(alphabet.internal_alphabet, [])
+
+ def test_get_state_by_id(self):
+ sevpa, q0, _ = balanced_parens_sevpa()
+ self.assertIs(sevpa.get_state_by_id('q0'), q0)
+ self.assertIsNone(sevpa.get_state_by_id('does_not_exist'))
+
+ def test_get_error_state_none_for_single_state_automaton(self):
+ sevpa, *_ = balanced_parens_sevpa()
+ # only state present is initial & accepting, so there is no error state candidate
+ self.assertIsNone(sevpa.get_error_state())
+
+ def test_get_allowed_call_transitions(self):
+ sevpa, q0, _ = balanced_parens_sevpa()
+ allowed = sevpa.get_allowed_call_transitions()
+ self.assertEqual(allowed['('], {'q0'})
+
+
+class TestSevpaStateSetupRoundtrip(unittest.TestCase):
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ sevpa, q0, _ = balanced_parens_sevpa()
+ setup = sevpa.to_state_setup()
+ rebuilt = Sevpa.from_state_setup(setup, init_state_id='q0')
+
+ for w in [[], ['('], ['(', ')'], ['(', '(', ')', ')']]:
+ rebuilt.reset_to_initial()
+ outputs = [rebuilt.step(letter) for letter in w]
+ sevpa.reset_to_initial()
+ expected = [sevpa.step(letter) for letter in w]
+ self.assertEqual(outputs, expected)
+
+
+class TestSevpaExecuteSequence(unittest.TestCase):
+ def test_execute_sequence_from_initial_state(self):
+ sevpa, q0, _ = balanced_parens_sevpa()
+ result = sevpa.execute_sequence(q0, ['(', '(', ')', ')'])
+ self.assertEqual(result, [False, False, False, True])
+
+ def test_execute_sequence_from_state_with_different_prefix_raises(self):
+ sevpa, q0, _ = balanced_parens_sevpa()
+ foreign = SevpaState('foreign')
+ foreign.prefix = ('unrelated',)
+ with self.assertRaises(AssertionError):
+ sevpa.execute_sequence(foreign, ['('])
+
+
+class TestSevpaDeleteState(unittest.TestCase):
+ def test_delete_state_removes_state_and_references(self):
+ alphabet = SevpaAlphabet(internal_alphabet=['i'], call_alphabet=[], return_alphabet=[])
+ q0 = SevpaState('q0', is_accepting=True)
+ q1 = SevpaState('q1', is_accepting=False)
+ q0.transitions['i'].append(SevpaTransition(q1, 'i', None))
+ q1.transitions['i'].append(SevpaTransition(q1, 'i', None))
+ sevpa = Sevpa(q0, [q0, q1])
+
+ sevpa.delete_state(q1)
+
+ self.assertNotIn(q1, sevpa.states)
+ self.assertEqual(q0.transitions['i'], [])
+
+ def test_delete_state_none_is_a_no_op(self):
+ sevpa, q0, _ = balanced_parens_sevpa()
+ num_states_before = len(sevpa.states)
+ sevpa.delete_state(None)
+ self.assertEqual(len(sevpa.states), num_states_before)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_stochastic_mealy_machine.py b/tests/automata/test_stochastic_mealy_machine.py
new file mode 100644
index 00000000000..b56a5d904fa
--- /dev/null
+++ b/tests/automata/test_stochastic_mealy_machine.py
@@ -0,0 +1,160 @@
+import random
+import unittest
+
+from aalpy.automata import Mdp, StochasticMealyMachine, StochasticMealyState
+
+
+def deterministic_smm():
+ """
+ 2-state SMM over alphabet {a, b}, only probability-1.0 transitions.
+ s0 --a/o1[1.0]--> s1 s0 --b/o2[1.0]--> s0
+ s1 --a/o3[1.0]--> s0 s1 --b/o1[1.0]--> s1
+ """
+ s0 = StochasticMealyState('s0')
+ s1 = StochasticMealyState('s1')
+ s0.transitions['a'].append((s1, 'o1', 1.0))
+ s0.transitions['b'].append((s0, 'o2', 1.0))
+ s1.transitions['a'].append((s0, 'o3', 1.0))
+ s1.transitions['b'].append((s1, 'o1', 1.0))
+ return StochasticMealyMachine(s0, [s0, s1]), s0, s1
+
+
+def branching_smm():
+ """s0 --a--> (s1, 'o1', 0.5) or (s2, 'o2', 0.5)."""
+ s0 = StochasticMealyState('s0')
+ s1 = StochasticMealyState('s1')
+ s2 = StochasticMealyState('s2')
+ s0.transitions['a'].append((s1, 'o1', 0.5))
+ s0.transitions['a'].append((s2, 'o2', 0.5))
+ return StochasticMealyMachine(s0, [s0, s1, s2]), s0, s1, s2
+
+
+class TestStochasticMealyState(unittest.TestCase):
+ def test_default_transitions_is_empty_defaultdict(self):
+ state = StochasticMealyState('s')
+ self.assertEqual(state.transitions['unused_key'], [])
+
+
+class TestStochasticMealyStep(unittest.TestCase):
+ def test_step_returns_output_and_moves_state(self):
+ smm, s0, s1 = deterministic_smm()
+ output = smm.step('a')
+ self.assertEqual(output, 'o1')
+ self.assertIs(smm.current_state, s1)
+
+ def test_step_sequence(self):
+ smm, s0, s1 = deterministic_smm()
+ outputs = [smm.step(i) for i in ['a', 'a', 'b']]
+ self.assertEqual(outputs, ['o1', 'o3', 'o2'])
+
+ def test_step_respects_branching_distribution(self):
+ smm, s0, s1, s2 = branching_smm()
+ for seed in range(20):
+ smm.reset_to_initial()
+ random.seed(seed)
+ output = smm.step('a')
+ self.assertIn(output, ('o1', 'o2'))
+ self.assertIn(smm.current_state, (s1, s2))
+
+ def test_reset_to_initial(self):
+ smm, s0, s1 = deterministic_smm()
+ smm.step('a')
+ self.assertIsNot(smm.current_state, s0)
+ smm.reset_to_initial()
+ self.assertIs(smm.current_state, s0)
+
+
+class TestStochasticMealyExecuteSequence(unittest.TestCase):
+ def test_execute_sequence_matches_stepwise(self):
+ smm, s0, s1 = deterministic_smm()
+ result = smm.execute_sequence(s0, ['a', 'a', 'b'])
+ self.assertEqual(result, ['o1', 'o3', 'o2'])
+ self.assertIs(smm.current_state, s0)
+
+ def test_execute_sequence_empty_returns_empty_list(self):
+ smm, s0, s1 = deterministic_smm()
+ self.assertEqual(smm.execute_sequence(s0, []), [])
+
+ def test_execute_sequence_resets_to_origin_state_first(self):
+ smm, s0, s1 = deterministic_smm()
+ smm.reset_to_initial()
+ smm.step('a') # move to s1
+ result = smm.execute_sequence(s0, ['a'])
+ self.assertEqual(result, ['o1'])
+
+
+class TestStochasticMealyStepTo(unittest.TestCase):
+ def test_step_to_moves_to_matching_output_state(self):
+ smm, s0, s1, s2 = branching_smm()
+ result = smm.step_to('a', 'o2')
+ self.assertEqual(result, 'o2')
+ self.assertIs(smm.current_state, s2)
+
+ def test_step_to_returns_none_for_unreachable_output(self):
+ smm, s0, s1, s2 = branching_smm()
+ result = smm.step_to('a', 'does_not_exist')
+ self.assertIsNone(result)
+ self.assertIs(smm.current_state, s0)
+
+
+class TestStochasticMealyStateSetupRoundtrip(unittest.TestCase):
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ smm, s0, s1 = deterministic_smm()
+ setup = smm.to_state_setup()
+ rebuilt = StochasticMealyMachine.from_state_setup(setup)
+
+ for w in [['a'], ['b'], ['a', 'a', 'b'], ['b', 'a']]:
+ rebuilt.reset_to_initial()
+ outputs = [rebuilt.step(letter) for letter in w]
+ smm.reset_to_initial()
+ expected = [smm.step(letter) for letter in w]
+ self.assertEqual(outputs, expected)
+
+ def test_from_state_setup_first_key_is_initial_state(self):
+ setup = {
+ 's0': {'a': [('s1', 'o1', 1.0)]},
+ 's1': {'a': [('s0', 'o2', 1.0)]},
+ }
+ smm = StochasticMealyMachine.from_state_setup(setup)
+ self.assertEqual(smm.initial_state.state_id, 's0')
+
+ def test_to_state_setup_puts_initial_state_first(self):
+ smm, s0, s1 = deterministic_smm()
+ smm.states = [s1, s0]
+ smm.to_state_setup()
+ self.assertIs(smm.states[0], s0)
+
+
+class TestStochasticMealyToMdp(unittest.TestCase):
+ def test_to_mdp_returns_mdp(self):
+ smm, *_ = deterministic_smm()
+ mdp = smm.to_mdp()
+ self.assertIsInstance(mdp, Mdp)
+
+ def test_to_mdp_preserves_behavior(self):
+ smm, s0, s1 = deterministic_smm()
+ mdp = smm.to_mdp()
+
+ # walk the SMM deterministically and confirm the MDP can reproduce the same output trace
+ # by following matching outputs via step_to
+ for w in [['a'], ['b'], ['a', 'a', 'b']]:
+ smm.reset_to_initial()
+ smm_outputs = [smm.step(letter) for letter in w]
+
+ mdp.reset_to_initial()
+ mdp_outputs = [mdp.step_to(letter, out) for letter, out in zip(w, smm_outputs)]
+ self.assertEqual(mdp_outputs, smm_outputs)
+
+
+class TestStochasticMealyStructural(unittest.TestCase):
+ def test_get_input_alphabet(self):
+ smm, *_ = deterministic_smm()
+ self.assertEqual(set(smm.get_input_alphabet()), {'a', 'b'})
+
+ def test_size(self):
+ smm, *_ = deterministic_smm()
+ self.assertEqual(smm.size, 2)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/automata/test_vpa.py b/tests/automata/test_vpa.py
new file mode 100644
index 00000000000..9f65a519d3b
--- /dev/null
+++ b/tests/automata/test_vpa.py
@@ -0,0 +1,177 @@
+import random
+import unittest
+
+from aalpy.automata import Vpa, VpaAlphabet, VpaState, VpaTransition
+
+
+def balanced_parens_vpa():
+ """
+ Single-state VPA recognizing the Dyck language over '(' (push) / ')' (pop).
+ q0 is initial and accepting; accepted iff the stack is empty.
+ """
+ q0 = VpaState('q0', is_accepting=True)
+ q0.transitions['('].append(VpaTransition(q0, q0, '(', 'push', '('))
+ q0.transitions[')'].append(VpaTransition(q0, q0, ')', 'pop', '('))
+ return Vpa(q0, [q0]), q0
+
+
+class TestVpaState(unittest.TestCase):
+ def test_default_transitions_is_empty_defaultdict(self):
+ state = VpaState('s')
+ self.assertEqual(state.transitions['unused_key'], [])
+
+ def test_default_not_accepting(self):
+ state = VpaState('s')
+ self.assertFalse(state.is_accepting)
+
+
+class TestVpaStep(unittest.TestCase):
+ def test_accepts_empty_word(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ self.assertTrue(vpa.step(None))
+
+ def test_accepts_balanced_word(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ vpa.step('(')
+ result = vpa.step(')')
+ self.assertTrue(result)
+ self.assertEqual(vpa.stack, [])
+
+ def test_rejects_incomplete_word(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ result = vpa.step('(')
+ self.assertFalse(result)
+ self.assertEqual(vpa.stack, ['('])
+
+ def test_unmatched_return_symbol_traps_in_error_state(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ result = vpa.step(')')
+ self.assertFalse(result)
+ self.assertIs(vpa.current_state, Vpa.error_state)
+
+ # once trapped, further steps stay False without raising
+ self.assertFalse(vpa.step('('))
+ self.assertFalse(vpa.step(None))
+
+ def test_nested_balanced_word(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ outputs = [vpa.step(c) for c in '(())']
+ self.assertEqual(outputs, [False, False, False, True])
+
+ def test_top_of_empty_stack_is_empty_list(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ self.assertEqual(vpa.top(), [])
+
+ def test_top_reflects_last_pushed_symbol(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ vpa.step('(')
+ self.assertEqual(vpa.top(), '(')
+
+
+class TestVpaExecuteSequence(unittest.TestCase):
+ def test_execute_sequence_matches_stepwise(self):
+ vpa, q0 = balanced_parens_vpa()
+ result = vpa.execute_sequence(q0, ['(', '(', ')', ')'], stack=[])
+ self.assertEqual(result, [False, False, False, True])
+ self.assertEqual(vpa.stack, [])
+
+ def test_execute_sequence_empty_returns_empty_list(self):
+ vpa, q0 = balanced_parens_vpa()
+ self.assertEqual(vpa.execute_sequence(q0, [], stack=[]), [])
+
+ def test_execute_sequence_ignores_leftover_stack_from_prior_use(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ vpa.step('(') # leaves the stack non-empty: ['(']
+ self.assertEqual(vpa.stack, ['('])
+
+ result = vpa.execute_sequence(q0, [')'], stack=[])
+ # a stale stack would make ')' match and incorrectly report acceptance;
+ # execute_sequence must start from the given stack, not whatever was left over
+ self.assertEqual(result, [False])
+
+ def test_execute_sequence_resumes_from_explicit_stack(self):
+ vpa, q0 = balanced_parens_vpa()
+ result = vpa.execute_sequence(q0, [')'], stack=['('])
+ # resuming as if one '(' had already been pushed makes the lone ')' balance out
+ self.assertEqual(result, [True])
+ self.assertEqual(vpa.stack, [])
+
+ def test_execute_sequence_with_explicit_stack_matches_manual_stepping(self):
+ vpa, q0 = balanced_parens_vpa()
+ vpa.reset_to_initial()
+ vpa.step('(')
+ vpa.step('(')
+ stack_after_two_pushes = list(vpa.stack)
+ manual_result = [vpa.step(c) for c in '))']
+
+ result = vpa.execute_sequence(q0, [')', ')'], stack=stack_after_two_pushes)
+ self.assertEqual(result, manual_result)
+
+
+class TestVpaStructural(unittest.TestCase):
+ def test_get_input_alphabet(self):
+ vpa, q0 = balanced_parens_vpa()
+ alphabet = vpa.get_input_alphabet()
+ self.assertEqual(alphabet.call_alphabet, ['('])
+ self.assertEqual(alphabet.return_alphabet, [')'])
+ self.assertEqual(alphabet.internal_alphabet, [])
+
+ def test_get_merged_alphabet(self):
+ alphabet = VpaAlphabet(internal_alphabet=['i'], call_alphabet=['c'], return_alphabet=['r'])
+ self.assertEqual(alphabet.get_merged_alphabet(), ['i', 'c', 'r'])
+
+ def test_is_input_complete_true(self):
+ vpa, q0 = balanced_parens_vpa()
+ self.assertTrue(vpa.is_input_complete())
+
+ def test_is_input_complete_false(self):
+ # the alphabet is inferred from ALL transitions present in the automaton, so we need a second
+ # state that does use ')' for the alphabet to include it, exposing q0's missing transition
+ q0 = VpaState('q0', is_accepting=False)
+ q1 = VpaState('q1', is_accepting=True)
+ q0.transitions['('].append(VpaTransition(q0, q1, '(', 'push', '('))
+ q1.transitions[')'].append(VpaTransition(q1, q1, ')', 'pop', '('))
+ # q0 has no ')' transition
+ vpa = Vpa(q0, [q0, q1])
+ self.assertFalse(vpa.is_input_complete())
+
+
+class TestVpaStateSetupRoundtrip(unittest.TestCase):
+ def test_to_state_setup_from_state_setup_roundtrip(self):
+ vpa, q0 = balanced_parens_vpa()
+ setup = vpa.to_state_setup()
+ rebuilt = Vpa.from_state_setup(setup, init_state_id='q0')
+
+ for w in [[], ['('], ['(', ')'], ['(', '(', ')', ')']]:
+ rebuilt.reset_to_initial()
+ outputs = [rebuilt.step(letter) for letter in w]
+ vpa.reset_to_initial()
+ expected = [vpa.step(letter) for letter in w]
+ self.assertEqual(outputs, expected)
+
+
+class TestVpaRandomAcceptingWord(unittest.TestCase):
+ def test_generate_random_accepting_word_is_actually_accepting(self):
+ vpa, q0 = balanced_parens_vpa()
+ random.seed(0)
+ # the walk is randomized and may run out of steps before balancing; a generous
+ # max_steps budget keeps this reliable without pinning the RNG's exact trajectory
+ word = vpa.generate_random_accepting_word(min_steps=2, max_steps=200)
+ self.assertIsNotNone(word)
+
+ vpa.reset_to_initial()
+ outputs = [vpa.step(letter) for letter in word]
+ self.assertTrue(outputs[-1])
+ self.assertEqual(vpa.stack, [])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000000..9a9c2e62f62
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,15 @@
+import pytest
+
+
+def pytest_addoption(parser):
+ parser.addoption('--exhaustive', action='store_true', default=False,
+ help='also run tests marked "exhaustive" (large seed/size sweeps, several minutes)')
+
+
+def pytest_collection_modifyitems(config, items):
+ if config.getoption('--exhaustive'):
+ return
+ skip_exhaustive = pytest.mark.skip(reason='exhaustive sweep, use --exhaustive to run')
+ for item in items:
+ if 'exhaustive' in item.keywords:
+ item.add_marker(skip_exhaustive)
diff --git a/tests/oracles/test_baseOracle.py b/tests/oracles/test_baseOracle.py
index beeaae45e21..0e99c19c81f 100644
--- a/tests/oracles/test_baseOracle.py
+++ b/tests/oracles/test_baseOracle.py
@@ -32,7 +32,7 @@ def generate_dfa_suls(self, number_of_states=10, alphabet_size=10, num_accepting
return learning_sul, validation_sul, alphabet
- def test_validate_eq_oracle(self, alphabet, eq_oracle, learning_sul, validation_sul):
+ def validate_eq_oracle(self, alphabet, eq_oracle, learning_sul, validation_sul):
"""
Validates the correctness of the given eq_oracle via WMethodEqOracle.
diff --git a/tests/oracles/test_kWayTransitionCoverageEqOracle.py b/tests/oracles/test_kWayTransitionCoverageEqOracle.py
index 3c53d0fb6a5..0443e7115ca 100644
--- a/tests/oracles/test_kWayTransitionCoverageEqOracle.py
+++ b/tests/oracles/test_kWayTransitionCoverageEqOracle.py
@@ -1,5 +1,7 @@
import unittest
+import pytest
+
from aalpy.oracles import KWayTransitionCoverageEqOracle
from tests.oracles.test_baseOracle import BaseOracleTests
@@ -10,21 +12,21 @@ def test_default(self):
learning_sul, validation_sul, alphabet = self.generate_dfa_suls()
eq_oracle = KWayTransitionCoverageEqOracle(alphabet, learning_sul)
- self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
def test_k_4(self):
learning_sul, validation_sul, alphabet = self.generate_dfa_suls(5, 5, 2)
eq_oracle = KWayTransitionCoverageEqOracle(
alphabet, learning_sul, k=4)
- self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
def test_method_prefix(self):
learning_sul, validation_sul, alphabet = self.generate_dfa_suls()
eq_oracle = KWayTransitionCoverageEqOracle(
alphabet, learning_sul, method='prefix')
- self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
@unittest.expectedFailure
def test_max_number_of_steps_10(self):
@@ -32,10 +34,13 @@ def test_max_number_of_steps_10(self):
eq_oracle = KWayTransitionCoverageEqOracle(
alphabet, learning_sul, max_number_of_steps=10, max_path_len=10)
- self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+ @pytest.mark.exhaustive
def test_default_large_dfa(self):
+ # exercises the oracle at real scale (50 states, alphabet 10); learning + validating here takes
+ # ~10s, which is fine for an opt-in exhaustive run but too slow for the default fast suite
learning_sul, validation_sul, alphabet = self.generate_dfa_suls(50, 10, 10)
eq_oracle = KWayTransitionCoverageEqOracle(alphabet, learning_sul)
- self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
diff --git a/tests/tests_imports.py b/tests/tests_imports.py
index 26212cd1a32..45e7fb35824 100644
--- a/tests/tests_imports.py
+++ b/tests/tests_imports.py
@@ -1,5 +1,10 @@
+import importlib
+import pkgutil
+import sys
import unittest
+import aalpy
+
class ImportTest(unittest.TestCase):
@@ -20,3 +25,45 @@ def test_imports(self):
except:
assert False
assert True
+
+
+class CircularImportTest(unittest.TestCase):
+ """
+ test_imports above only ever imports aalpy's submodules in one fixed order, in one process. A
+ circular dependency between two modules can hide behind that: it only surfaces when whichever
+ module is involved in the cycle gets imported *first*, before the module it depends on has been
+ (even partially) loaded. This test instead imports every submodule of aalpy as the very first
+ aalpy-related import of a clean module cache, one at a time, which is exactly the situation a
+ circular import fails in.
+ """
+
+ def test_every_submodule_imports_standalone(self):
+ submodules = sorted(name for _, name, _ in pkgutil.walk_packages(aalpy.__path__, prefix='aalpy.'))
+ self.assertGreater(len(submodules), 0, "no aalpy submodules were discovered, the test itself is broken")
+
+ # every other test module in the suite already imported classes (Dfa, MealyMachine, ...) from the
+ # ORIGINAL aalpy modules at collection time. Re-importing here creates new, distinct class objects;
+ # if left in sys.modules afterward, later tests comparing an old-class instance against a new-class
+ # instance (e.g. bisimilar()'s `a1.__class__ != a2.__class__` check) would fail spuriously. So the
+ # original modules must be restored once this test is done, regardless of outcome.
+ original_modules = {name: module for name, module in sys.modules.items()
+ if name == 'aalpy' or name.startswith('aalpy.')}
+
+ failures = {}
+ try:
+ for name in submodules:
+ # drop every previously (possibly partially) loaded aalpy module so this import
+ # starts from a clean slate, as if it were the first aalpy import in a fresh interpreter
+ for cached in [m for m in sys.modules if m == 'aalpy' or m.startswith('aalpy.')]:
+ del sys.modules[cached]
+ try:
+ importlib.import_module(name)
+ except ImportError as e:
+ failures[name] = str(e)
+ finally:
+ for cached in [m for m in sys.modules if m == 'aalpy' or m.startswith('aalpy.')]:
+ del sys.modules[cached]
+ sys.modules.update(original_modules)
+
+ self.assertEqual(failures, {}, f"modules that cannot be imported standalone (likely circular imports): "
+ f"{failures}")
From cdf01f075f0314f0f2edf81b87b17a2cffbf70a6 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:15:11 +0200
Subject: [PATCH 12/25] Update minimum python version and add test cases to
GitHubs CI/CD
---
README.md | 24 ++++++++++++++++++++++--
pyproject.toml | 15 +++++++++++++++
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 29ba3164042..f336c0daf99 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
[](https://github.com/DES-Lab/AALpy/issues)

-[](https://www.python.org/downloads/release/python-360/)
+[](https://www.python.org/downloads/release/python-3100/)

[](https://GitHub.com/Naereen/StrapDown.js/graphs/commit-activity)
[](https://opensource.org/licenses/MIT)
@@ -52,7 +52,9 @@ To install current version of the master branch (it might contain bugfixes and a
```bash
pip install https://github.com/DES-Lab/AALpy/archive/master.zip
```
-The minimum required version of Python is 3.6.
+The minimum required version of Python is 3.10.
+If you want to use older Python version (>= 3.6), you can use AALpy versions up to 1.6.2.
+
Ensure that you have [Graphviz](https://graphviz.org/) installed and added to your path if you want to visualize models.
For manual installation, clone the repo and install `pydot` (the only dependency).
@@ -145,6 +147,24 @@ If you use AALpy in your research, please cite us with of the following:
If you have research suggestions or you need specific help concerning your research, feel free to start a [discussion](https://github.com/DES-Lab/AALpy/discussions).
We are happy to help you and consult you in developing automata learning algorithms and in applying automata learning in various domains.
+
+## Testing
+
+Install the test dependencies once (`pytest` and `pytest-timeout` aren't needed to use AALpy itself, only to run its test suite):
+```bash
+pip install -e .[test]
+```
+
+Then, from the repository root, run AALpy's test suite:
+```bash
+pytest
+```
+
+For a more comprehensive test suite, which covers more seeds per learning experiment, run:
+```bash
+pytest --exhaustive
+```
+
## Contributing
Pull requests are welcome. For significant changes, please open an issue first to discuss what you would like to change.
In case of any questions or possible bugs, please open issues.
diff --git a/pyproject.toml b/pyproject.toml
index 6b02c94dcbf..94a322ff04f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,12 @@ classifiers = [
"Operating System :: OS Independent"
]
+[project.optional-dependencies]
+test = [
+"pytest",
+"pytest-timeout"
+]
+
[project.urls]
Homepage = "https://github.com/DES-Lab/AALpy"
@@ -31,6 +37,15 @@ license-files = ["LICENSE.txt"]
[tool.setuptools.packages.find]
include = ["aalpy*"]
+[tool.pytest.ini_options]
+# a few test files cross-import from one another as `tests.oracles.test_x` (e.g. to reuse a shared
+# BaseOracleTests helper); that only resolves if the repo root is on sys.path, which happens
+# incidentally under `python -m pytest` (cwd is auto-added) but not for the plain `pytest` entry point
+pythonpath = ["."]
+markers = [
+ "exhaustive: large parametrized sweeps (many seeds/sizes), skipped by default; run with `pytest --exhaustive`",
+]
+
# pip install build twine
# python -m build
# python -m twine upload dist/*
\ No newline at end of file
From 1ee81c64eb76eecf9e7f67be81a4e0099f7dc5cf Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:15:33 +0200
Subject: [PATCH 13/25] Add base and oracle tests
---
tests/base/test_cache_tree.py | 104 +++++++++++++++
tests/base/test_oracle.py | 70 +++++++++++
tests/base/test_sul.py | 119 ++++++++++++++++++
...est_breadth_first_exploration_eq_oracle.py | 76 +++++++++++
tests/oracles/test_cache_based_eq_oracle.py | 78 ++++++++++++
.../test_k_way_state_coverage_eq_oracle.py | 75 +++++++++++
tests/oracles/test_pac_oracle.py | 95 ++++++++++++++
.../test_perfect_knowledge_eq_oracle.py | 92 ++++++++++++++
.../test_provided_sequences_oracle_wrapper.py | 94 ++++++++++++++
.../oracles/test_random_w_method_eq_oracle.py | 62 +++++++++
tests/oracles/test_random_walk_eq_oracle.py | 92 ++++++++++++++
tests/oracles/test_random_word_eq_oracle.py | 95 ++++++++++++++
tests/oracles/test_rwpmethod_oracle.py | 114 +++++++++++++++++
tests/oracles/test_state_prefix_eq_oracle.py | 79 ++++++++++++
tests/oracles/test_transition_focus_oracle.py | 72 +++++++++++
tests/oracles/test_user_input_eq_oracle.py | 81 ++++++++++++
tests/oracles/test_wmethod_oracle.py | 110 ++++++++++++++++
tests/oracles/test_wpmethod_oracle.py | 114 +++++++++++++++++
18 files changed, 1622 insertions(+)
create mode 100644 tests/base/test_cache_tree.py
create mode 100644 tests/base/test_oracle.py
create mode 100644 tests/base/test_sul.py
create mode 100644 tests/oracles/test_breadth_first_exploration_eq_oracle.py
create mode 100644 tests/oracles/test_cache_based_eq_oracle.py
create mode 100644 tests/oracles/test_k_way_state_coverage_eq_oracle.py
create mode 100644 tests/oracles/test_pac_oracle.py
create mode 100644 tests/oracles/test_perfect_knowledge_eq_oracle.py
create mode 100644 tests/oracles/test_provided_sequences_oracle_wrapper.py
create mode 100644 tests/oracles/test_random_w_method_eq_oracle.py
create mode 100644 tests/oracles/test_random_walk_eq_oracle.py
create mode 100644 tests/oracles/test_random_word_eq_oracle.py
create mode 100644 tests/oracles/test_rwpmethod_oracle.py
create mode 100644 tests/oracles/test_state_prefix_eq_oracle.py
create mode 100644 tests/oracles/test_transition_focus_oracle.py
create mode 100644 tests/oracles/test_user_input_eq_oracle.py
create mode 100644 tests/oracles/test_wmethod_oracle.py
create mode 100644 tests/oracles/test_wpmethod_oracle.py
diff --git a/tests/base/test_cache_tree.py b/tests/base/test_cache_tree.py
new file mode 100644
index 00000000000..ae8e86796b9
--- /dev/null
+++ b/tests/base/test_cache_tree.py
@@ -0,0 +1,104 @@
+import unittest
+
+from aalpy.base.CacheTree import CacheDict, CacheTree
+
+
+class TestCacheTree(unittest.TestCase):
+ def test_in_cache_on_empty_tree_returns_none(self):
+ tree = CacheTree()
+ self.assertIsNone(tree.in_cache(('a', 'b')))
+
+ def test_add_and_retrieve(self):
+ tree = CacheTree()
+ tree.add_to_cache(('a', 'b'), (1, 2))
+ self.assertEqual(tree.in_cache(('a', 'b')), (1, 2))
+
+ def test_in_cache_returns_prefix_output(self):
+ tree = CacheTree()
+ tree.add_to_cache(('a', 'b', 'c'), (1, 2, 3))
+ self.assertEqual(tree.in_cache(('a', 'b')), (1, 2))
+
+ def test_in_cache_missing_suffix_returns_none(self):
+ tree = CacheTree()
+ tree.add_to_cache(('a',), (1,))
+ self.assertIsNone(tree.in_cache(('a', 'b')))
+
+ def test_in_cache_unknown_branch_returns_none(self):
+ tree = CacheTree()
+ tree.add_to_cache(('a',), (1,))
+ self.assertIsNone(tree.in_cache(('b',)))
+
+ def test_empty_sequence_is_cached(self):
+ tree = CacheTree()
+ self.assertEqual(tree.in_cache(()), ())
+
+ def test_reset_clears_current_position(self):
+ tree = CacheTree()
+ tree.reset()
+ tree.step_in_cache('a', 1)
+ self.assertEqual(tree.inputs, ('a',))
+ tree.reset()
+ self.assertEqual(tree.inputs, ())
+ self.assertIs(tree.curr_node, tree.root_node)
+
+ def test_step_in_cache_none_sets_root_value(self):
+ tree = CacheTree()
+ tree.reset()
+ tree.step_in_cache(None, 'root_output')
+ self.assertEqual(tree.root_node.value, 'root_output')
+
+ def test_consistent_repeated_insert_is_fine(self):
+ tree = CacheTree()
+ tree.add_to_cache(('a', 'b'), (1, 2))
+ # inserting the exact same sequence again must not raise
+ tree.add_to_cache(('a', 'b'), (1, 2))
+ self.assertEqual(tree.in_cache(('a', 'b')), (1, 2))
+
+ def test_non_determinism_raises_system_exit(self):
+ tree = CacheTree()
+ tree.add_to_cache(('a',), (1,))
+ with self.assertRaises(SystemExit):
+ tree.add_to_cache(('a',), (2,))
+
+ def test_branching_inputs_are_independent(self):
+ tree = CacheTree()
+ tree.add_to_cache(('a',), (1,))
+ tree.add_to_cache(('b',), (2,))
+ self.assertEqual(tree.in_cache(('a',)), (1,))
+ self.assertEqual(tree.in_cache(('b',)), (2,))
+
+
+class TestCacheDict(unittest.TestCase):
+ def test_in_cache_on_empty_dict_returns_none(self):
+ cache = CacheDict()
+ self.assertIsNone(cache.in_cache(('a', 'b')))
+
+ def test_add_and_retrieve(self):
+ cache = CacheDict()
+ cache.add_to_cache(('a', 'b'), (1, 2))
+ self.assertEqual(cache.in_cache(('a', 'b')), (1, 2))
+
+ def test_in_cache_missing_returns_none(self):
+ cache = CacheDict()
+ cache.add_to_cache(('a',), (1,))
+ self.assertIsNone(cache.in_cache(('a', 'b')))
+
+ def test_non_determinism_raises_system_exit(self):
+ cache = CacheDict()
+ cache.reset()
+ cache.step_in_cache('a', 1)
+ cache.reset()
+ with self.assertRaises(SystemExit):
+ cache.step_in_cache('a', 2)
+
+ def test_consistent_repeated_step_is_fine(self):
+ cache = CacheDict()
+ cache.reset()
+ cache.step_in_cache('a', 1)
+ cache.reset()
+ # re-affirming the same input/output for the same accumulated path must not raise
+ cache.step_in_cache('a', 1)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/base/test_oracle.py b/tests/base/test_oracle.py
new file mode 100644
index 00000000000..e86f6dd9b49
--- /dev/null
+++ b/tests/base/test_oracle.py
@@ -0,0 +1,70 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Dfa, DfaState
+from aalpy.base import Oracle
+
+
+def parity_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+class DummyOracle(Oracle):
+ """Minimal concrete Oracle used to exercise the shared base behaviour."""
+
+ def find_cex(self, hypothesis):
+ return None
+
+
+class TestOracle(unittest.TestCase):
+ def test_constructor_sets_alphabet_and_sul(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ oracle = DummyOracle(['a', 'b'], sul)
+ self.assertEqual(oracle.alphabet, ['a', 'b'])
+ self.assertIs(oracle.sul, sul)
+ self.assertEqual(oracle.num_queries, 0)
+ self.assertEqual(oracle.num_steps, 0)
+
+ def test_find_cex_is_abstract_and_must_be_implemented(self):
+ with self.assertRaises(TypeError):
+ Oracle(['a'], AutomatonSUL(parity_dfa()))
+
+ def test_reset_hyp_and_sul_resets_hypothesis_to_initial(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ oracle = DummyOracle(dfa.get_input_alphabet(), sul)
+
+ dfa.step('a')
+ self.assertIsNot(dfa.current_state, dfa.initial_state)
+
+ oracle.reset_hyp_and_sul(dfa)
+ self.assertIs(dfa.current_state, dfa.initial_state)
+
+ def test_reset_hyp_and_sul_resets_sul_automaton(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ oracle = DummyOracle(dfa.get_input_alphabet(), sul)
+
+ sul.step('a')
+ self.assertIsNot(sul.automaton.current_state, sul.automaton.initial_state)
+
+ oracle.reset_hyp_and_sul(dfa)
+ self.assertIs(sul.automaton.current_state, sul.automaton.initial_state)
+
+ def test_reset_hyp_and_sul_increments_num_queries(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ oracle = DummyOracle(dfa.get_input_alphabet(), sul)
+
+ oracle.reset_hyp_and_sul(dfa)
+ oracle.reset_hyp_and_sul(dfa)
+ self.assertEqual(oracle.num_queries, 2)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/base/test_sul.py b/tests/base/test_sul.py
new file mode 100644
index 00000000000..6c5d5572ee1
--- /dev/null
+++ b/tests/base/test_sul.py
@@ -0,0 +1,119 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Dfa, DfaState
+from aalpy.base.SUL import CacheSUL
+
+
+def parity_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+class TestAutomatonSUL(unittest.TestCase):
+ def test_step_delegates_to_automaton(self):
+ sul = AutomatonSUL(parity_dfa())
+ self.assertFalse(sul.step('a'))
+ self.assertTrue(sul.step('a'))
+
+ def test_pre_resets_automaton(self):
+ sul = AutomatonSUL(parity_dfa())
+ sul.step('a')
+ self.assertIsNot(sul.automaton.current_state, sul.automaton.initial_state)
+ sul.pre()
+ self.assertIs(sul.automaton.current_state, sul.automaton.initial_state)
+
+ def test_query_resets_before_and_after(self):
+ sul = AutomatonSUL(parity_dfa())
+ sul.query(('a', 'a', 'a'))
+ # after query(), the automaton should be back to initial (pre() was called at query start
+ # and the automaton's current_state is left wherever the last step went, post() does not reset)
+ self.assertIsNot(sul.automaton.current_state, sul.automaton.initial_state)
+ sul.pre()
+ self.assertIs(sul.automaton.current_state, sul.automaton.initial_state)
+
+ def test_query_empty_word_on_dfa(self):
+ sul = AutomatonSUL(parity_dfa())
+ self.assertEqual(sul.query(()), [True])
+
+ def test_query_output_matches_manual_stepping(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ result = sul.query(('a', 'b', 'a'))
+ self.assertEqual(result, [False, False, True])
+
+ def test_io_query_pairs_inputs_with_outputs(self):
+ sul = AutomatonSUL(parity_dfa())
+ result = sul.io_query(('a', 'a'))
+ self.assertEqual(result, [('a', False), ('a', True)])
+
+ def test_num_queries_and_steps_counters(self):
+ sul = AutomatonSUL(parity_dfa())
+ sul.query(('a', 'b'))
+ sul.query(('a',))
+ self.assertEqual(sul.num_queries, 2)
+ self.assertEqual(sul.num_steps, 3)
+
+ def test_independent_suls_have_independent_state(self):
+ dfa = parity_dfa()
+ sul1 = AutomatonSUL(dfa)
+ sul2 = AutomatonSUL(dfa)
+ sul1.step('a')
+ # both wrap the same automaton instance, so state is shared -- this documents that behaviour
+ self.assertIs(sul1.automaton, sul2.automaton)
+
+
+class TestCacheSUL(unittest.TestCase):
+ def test_cache_hit_avoids_wrapped_query(self):
+ wrapped = AutomatonSUL(parity_dfa())
+ cache_sul = CacheSUL(wrapped)
+
+ result1 = cache_sul.query(('a', 'b'))
+ self.assertEqual(wrapped.num_queries, 1)
+
+ result2 = cache_sul.query(('a', 'b'))
+ self.assertEqual(result1, result2)
+ self.assertIsInstance(result2, list) # cache hits and misses must return the same type
+ self.assertEqual(wrapped.num_queries, 1) # not called again
+ self.assertEqual(cache_sul.num_cached_queries, 1)
+
+ def test_cache_miss_for_new_query(self):
+ wrapped = AutomatonSUL(parity_dfa())
+ cache_sul = CacheSUL(wrapped)
+
+ cache_sul.query(('a',))
+ cache_sul.query(('b',))
+ self.assertEqual(wrapped.num_queries, 2)
+ self.assertEqual(cache_sul.num_cached_queries, 0)
+
+ def test_prefix_of_cached_query_is_a_hit(self):
+ wrapped = AutomatonSUL(parity_dfa())
+ cache_sul = CacheSUL(wrapped)
+
+ cache_sul.query(('a', 'b', 'a'))
+ result = cache_sul.query(('a', 'b'))
+ self.assertEqual(result, [False, False])
+ self.assertEqual(cache_sul.num_cached_queries, 1)
+
+ def test_dict_cache_type(self):
+ wrapped = AutomatonSUL(parity_dfa())
+ cache_sul = CacheSUL(wrapped, cache_type='dict')
+
+ cache_sul.query(('a', 'b'))
+ result = cache_sul.query(('a', 'b'))
+ self.assertEqual(result, [False, False])
+ self.assertEqual(cache_sul.num_cached_queries, 1)
+
+ def test_step_updates_cache(self):
+ wrapped = AutomatonSUL(parity_dfa())
+ cache_sul = CacheSUL(wrapped)
+ cache_sul.pre()
+ cache_sul.step('a')
+ self.assertEqual(cache_sul.cache.in_cache(('a',)), (False,))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_breadth_first_exploration_eq_oracle.py b/tests/oracles/test_breadth_first_exploration_eq_oracle.py
new file mode 100644
index 00000000000..30467661965
--- /dev/null
+++ b/tests/oracles/test_breadth_first_exploration_eq_oracle.py
@@ -0,0 +1,76 @@
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import BreadthFirstExplorationEqOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def chain_mealy(length, alphabet=('a',)):
+ """
+ Chain of states over `alphabet`, deterministic on the first letter of `alphabet`. Repeatedly feeding the
+ first letter walks s0 -> s1 -> ... -> s_length.
+ """
+ states = [MealyState(f's{i}') for i in range(length + 1)]
+ first = alphabet[0]
+ for i in range(length):
+ states[i].transitions = {a: states[i] for a in alphabet}
+ states[i].transitions[first] = states[i + 1]
+ states[i].output_fun = {a: 'o' for a in alphabet}
+ states[length].transitions = {a: states[length] for a in alphabet}
+ states[length].output_fun = {a: 'o' for a in alphabet}
+ mm = MealyMachine(states[0], states)
+ mm.compute_prefixes()
+ return mm
+
+
+def with_diverging_transition(mm, diverge_at, alphabet=('a',)):
+ first = alphabet[0]
+ mm.states[diverge_at - 1].output_fun[first] = 'x'
+ return mm
+
+
+class BreadthFirstExplorationEqOracleTests(unittest.TestCase):
+
+ def test_finds_cex_within_depth(self):
+ reference = chain_mealy(5)
+ hypothesis = chain_mealy(5)
+ with_diverging_transition(hypothesis, 3)
+
+ oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=5)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertIsNotNone(cex)
+ reference.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [reference.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+
+ def test_returns_shortest_cex_not_full_depth(self):
+ reference = chain_mealy(5)
+ hypothesis = chain_mealy(5)
+ with_diverging_transition(hypothesis, 2)
+
+ oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=5)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertEqual(tuple(cex), ('a', 'a'))
+
+ def test_difference_beyond_depth_bound_is_not_found(self):
+ reference = chain_mealy(5)
+ hypothesis = chain_mealy(5)
+ with_diverging_transition(hypothesis, 5)
+
+ oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=3)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ reference = chain_mealy(4)
+ hypothesis = chain_mealy(4)
+
+ oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=4)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_cache_based_eq_oracle.py b/tests/oracles/test_cache_based_eq_oracle.py
new file mode 100644
index 00000000000..83b964adeaf
--- /dev/null
+++ b/tests/oracles/test_cache_based_eq_oracle.py
@@ -0,0 +1,78 @@
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.base.SUL import CacheSUL
+from aalpy.oracles import CacheBasedEqOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def chain_mealy(length, last_output='o'):
+ """
+ Single-input-letter Mealy chain s0 -a/o-> s1 -a/o-> ... -a/o-> s_length, where the final
+ transition into s_length produces last_output instead of 'o'.
+ """
+ states = [MealyState(f's{i}') for i in range(length + 1)]
+ for i in range(length):
+ out = last_output if i == length - 1 else 'o'
+ states[i].transitions = {'a': states[i + 1]}
+ states[i].output_fun = {'a': out}
+ states[length].transitions = {'a': states[length]}
+ states[length].output_fun = {'a': 'o'}
+ mm = MealyMachine(states[0], states)
+ mm.compute_prefixes()
+ return mm
+
+
+class CacheBasedEqOracleTests(unittest.TestCase):
+
+ def test_finds_cex_reachable_via_cached_prefix(self):
+ reference = chain_mealy(6, last_output='o')
+ hypothesis = chain_mealy(6, last_output='x')
+
+ sul = CacheSUL(AutomatonSUL(reference))
+ sul.query(('a',) * 5)
+
+ oracle = CacheBasedEqOracle(['a'], sul, num_walks=10, depth_increase=1)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertIsNotNone(cex)
+ reference.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [reference.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+ self.assertEqual(sul_out[:-1], hyp_out[:-1])
+
+ def test_does_not_find_cex_when_difference_is_uncached_and_out_of_reach(self):
+ reference = chain_mealy(6, last_output='o')
+ hypothesis = chain_mealy(6, last_output='x')
+
+ sul = CacheSUL(AutomatonSUL(reference))
+ # nothing has been queried yet, so the only cached "leaf" is the empty prefix
+ oracle = CacheBasedEqOracle(['a'], sul, num_walks=10, depth_increase=1)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertIsNone(cex)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ reference = chain_mealy(4, last_output='o')
+ hypothesis = chain_mealy(4, last_output='o')
+
+ sul = CacheSUL(AutomatonSUL(reference))
+ sul.query(('a', 'a', 'a'))
+
+ oracle = CacheBasedEqOracle(['a'], sul, num_walks=20, depth_increase=3)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_get_paths_collects_all_leaves(self):
+ reference = chain_mealy(3)
+ sul = CacheSUL(AutomatonSUL(reference))
+ sul.query(('a', 'a'))
+
+ oracle = CacheBasedEqOracle(['a'], sul)
+ paths = oracle.get_paths(sul.cache.root_node)
+ self.assertEqual(paths, [['a', 'a']])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_k_way_state_coverage_eq_oracle.py b/tests/oracles/test_k_way_state_coverage_eq_oracle.py
new file mode 100644
index 00000000000..2cbe81fcc46
--- /dev/null
+++ b/tests/oracles/test_k_way_state_coverage_eq_oracle.py
@@ -0,0 +1,75 @@
+import random
+import unittest
+
+from aalpy.oracles import KWayStateCoverageEqOracle
+from tests.oracles.test_baseOracle import BaseOracleTests
+
+
+class KWayStateCoverageEqOracleTests(BaseOracleTests):
+
+ def test_default(self):
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls()
+
+ eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+
+ def test_k_3(self):
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 5, 3)
+
+ eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul, k=3)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+
+ def test_method_combinations(self):
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls()
+
+ eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul, method='combinations')
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+
+ def test_lower_and_upper_bounds(self):
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 4, 3)
+
+ eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul, num_test_lower_bound=20,
+ num_test_upper_bound=200)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+
+ def test_finds_transition_only_difference(self):
+ # a difference that is only reachable via a k-way state combination, not via the random walk tail
+ random.seed(0)
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(8, 4, 3)
+
+ # no need to (re-)learn a reference model via run_Lstar here - learning_sul already wraps a
+ # correct ground-truth DFA (this used to run a full L* + WMethodEqOracle learning pass just to
+ # get back an equivalent copy of the model it already had, taking ~30s for no benefit)
+ reference_model = learning_sul.automaton
+
+ # flip acceptance of a non-initial state to create a real, deterministic difference
+ target_state = next(s for s in reference_model.states if s is not reference_model.initial_state)
+ broken_model = reference_model.copy()
+ broken_state = next(s for s in broken_model.states if s.state_id == target_state.state_id)
+ broken_state.is_accepting = not broken_state.is_accepting
+
+ oracle = KWayStateCoverageEqOracle(alphabet, validation_sul, k=2)
+ cex = oracle.find_cex(broken_model)
+ self.assertIsNotNone(cex)
+
+ def test_k_larger_than_hypothesis_state_count_still_tests(self):
+ # Regression test: previously, when k exceeded the hypothesis' number of states, both
+ # itertools.combinations/permutations produced no k-wise tuples AND the num_test_lower_bound
+ # fallback (meant to cover this exact situation) only triggered for single-state hypotheses,
+ # so test_cases stayed empty and find_cex returned None without testing anything at all.
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 4, 3)
+ reference_dfa = learning_sul.automaton
+
+ broken_model = reference_dfa.copy()
+ broken_model.states = broken_model.states[:2]
+ broken_model.initial_state = broken_model.states[0]
+ for state in broken_model.states:
+ state.transitions = {a: broken_model.states[0] for a in alphabet}
+
+ oracle = KWayStateCoverageEqOracle(alphabet, validation_sul, k=3)
+ cex = oracle.find_cex(broken_model)
+ self.assertIsNotNone(cex)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_pac_oracle.py b/tests/oracles/test_pac_oracle.py
new file mode 100644
index 00000000000..1370d5fcd57
--- /dev/null
+++ b/tests/oracles/test_pac_oracle.py
@@ -0,0 +1,95 @@
+import random
+import unittest
+from math import ceil, log
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import PacOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def chain_mealy(length, alphabet=('a', 'b')):
+ """Chain of states over `alphabet`, s0 -> s1 -> ... -> s_length, driven by repeating the first letter."""
+ states = [MealyState(f's{i}') for i in range(length + 1)]
+ first = alphabet[0]
+ for i in range(length):
+ states[i].transitions = {a: states[i] for a in alphabet}
+ states[i].transitions[first] = states[i + 1]
+ states[i].output_fun = {a: 'o' for a in alphabet}
+ states[length].transitions = {a: states[length] for a in alphabet}
+ states[length].output_fun = {a: 'o' for a in alphabet}
+ mm = MealyMachine(states[0], states)
+ mm.compute_prefixes()
+ return mm
+
+
+def with_diverging_transition(mm, diverge_at, alphabet=('a', 'b')):
+ mm.states[diverge_at - 1].output_fun[alphabet[0]] = 'x'
+ return mm
+
+
+class PacOracleTests(unittest.TestCase):
+
+ def test_finds_cex_over_several_seeds(self):
+ successes = 0
+ for seed in range(10):
+ random.seed(seed)
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+ with_diverging_transition(hypothesis, 3)
+
+ oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.05, delta=0.05,
+ min_walk_len=5, max_walk_len=10)
+ cex = oracle.find_cex(hypothesis)
+ if cex is not None:
+ successes += 1
+ reference.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [reference.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+
+ self.assertGreaterEqual(successes, 9)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ # No amount of randomness can turn a truly equivalent hypothesis into a false positive: since every
+ # input sequence produces identical output on both models, out_sul == out_hyp always holds, regardless
+ # of which random sequences are sampled. This assertion is therefore exact, not merely "usually true".
+ random.seed(0)
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+
+ oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.05, delta=0.05)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_number_of_test_cases_grows_with_round_and_shrinks_with_epsilon_delta(self):
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+
+ loose_oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.5, delta=0.5)
+ loose_oracle.find_cex(hypothesis)
+ expected_loose = ceil(1 / 0.5 * (log(1 / 0.5) + 1 * log(2)))
+ self.assertEqual(loose_oracle.num_queries, expected_loose)
+
+ strict_oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.02, delta=0.02)
+ strict_oracle.find_cex(hypothesis)
+ expected_strict = ceil(1 / 0.02 * (log(1 / 0.02) + 1 * log(2)))
+ self.assertEqual(strict_oracle.num_queries, expected_strict)
+
+ self.assertGreater(strict_oracle.num_queries, loose_oracle.num_queries)
+
+ def test_round_counter_increases_number_of_test_cases_across_calls(self):
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+
+ oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.1, delta=0.1)
+ oracle.find_cex(hypothesis)
+ first_round_queries = oracle.num_queries
+
+ oracle.find_cex(hypothesis)
+ second_round_queries = oracle.num_queries - first_round_queries
+
+ self.assertGreater(second_round_queries, first_round_queries)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_perfect_knowledge_eq_oracle.py b/tests/oracles/test_perfect_knowledge_eq_oracle.py
new file mode 100644
index 00000000000..339570f146f
--- /dev/null
+++ b/tests/oracles/test_perfect_knowledge_eq_oracle.py
@@ -0,0 +1,92 @@
+import unittest
+
+from aalpy.automata import Dfa, DfaState
+from aalpy.oracles import PerfectKnowledgeEqOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def parity_dfa():
+ """2-state complete, minimal DFA accepting words with an even number of 'a's."""
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ dfa = Dfa(q0, [q0, q1])
+ dfa.compute_prefixes()
+ return dfa
+
+
+def parity_dfa_relabeled():
+ """Behaviorally identical to parity_dfa, but built from differently-named/ordered states."""
+ r1 = DfaState('r1', is_accepting=False)
+ r0 = DfaState('r0', is_accepting=True)
+ r0.transitions = {'a': r1, 'b': r0}
+ r1.transitions = {'a': r0, 'b': r1}
+ dfa = Dfa(r0, [r1, r0])
+ dfa.compute_prefixes()
+ return dfa
+
+
+def parity_dfa_with_wrong_transition():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q1, 'b': q1} # 'a' from q1 should go back to q0, not self-loop
+ dfa = Dfa(q0, [q0, q1])
+ dfa.compute_prefixes()
+ return dfa
+
+
+class PerfectKnowledgeEqOracleTests(unittest.TestCase):
+
+ def test_finds_cex_for_wrong_transition(self):
+ ground_truth = parity_dfa()
+ hypothesis = parity_dfa_with_wrong_transition()
+
+ oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertIsNotNone(cex)
+ ground_truth.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [ground_truth.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+
+ def test_no_cex_for_behaviorally_equivalent_but_structurally_different_hypothesis(self):
+ ground_truth = parity_dfa()
+ hypothesis = parity_dfa_relabeled()
+
+ oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_deterministically_finds_cex_on_first_try_no_randomness_involved(self):
+ # unlike the other oracles, this one has direct access to ground truth, so it must succeed every time
+ for _ in range(5):
+ ground_truth = parity_dfa()
+ hypothesis = parity_dfa_with_wrong_transition()
+
+ oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth)
+ self.assertIsNotNone(oracle.find_cex(hypothesis))
+
+ def test_missing_state_in_hypothesis_is_detected(self):
+ ground_truth = parity_dfa()
+
+ single_state_hyp = DfaState('only', is_accepting=True)
+ single_state_hyp.transitions = {'a': single_state_hyp, 'b': single_state_hyp}
+ hypothesis = Dfa(single_state_hyp, [single_state_hyp])
+ hypothesis.compute_prefixes()
+
+ oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertIsNotNone(cex)
+ ground_truth.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [ground_truth.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_provided_sequences_oracle_wrapper.py b/tests/oracles/test_provided_sequences_oracle_wrapper.py
new file mode 100644
index 00000000000..10bc4181384
--- /dev/null
+++ b/tests/oracles/test_provided_sequences_oracle_wrapper.py
@@ -0,0 +1,94 @@
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import ProvidedSequencesOracleWrapper
+from aalpy.SULs import AutomatonSUL
+
+
+def sample_mealy():
+ """2-state Mealy machine over {x, y}, matching tests/automata/test_mealy_machine.py's fixture."""
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'x': s1, 'y': s0}
+ s0.output_fun = {'x': 'o1', 'y': 'o2'}
+ s1.transitions = {'x': s0, 'y': s1}
+ s1.output_fun = {'x': 'o3', 'y': 'o1'}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm
+
+
+def sample_mealy_with_wrong_output():
+ mm = sample_mealy()
+ mm.states[0].output_fun['y'] = 'wrong'
+ return mm
+
+
+class RecordingOracle:
+ """Never finds a counterexample, records whether it was ever invoked."""
+
+ def __init__(self):
+ self.called = False
+ self.num_queries = 0
+ self.num_steps = 0
+
+ def find_cex(self, hypothesis):
+ self.called = True
+ return None
+
+
+class ProvidedSequencesOracleWrapperTests(unittest.TestCase):
+
+ def test_finds_cex_among_provided_sequences(self):
+ reference = sample_mealy()
+ hypothesis = sample_mealy_with_wrong_output()
+
+ fallback = RecordingOracle()
+ oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback,
+ [['x', 'x'], ['y'], ['x', 'y', 'x']])
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertEqual(tuple(cex), ('y',))
+ self.assertFalse(fallback.called, "fallback oracle should not run once a provided sequence finds a cex")
+
+ def test_no_cex_when_none_of_the_provided_sequences_reveal_it_and_delegates_to_wrapped_oracle(self):
+ reference = sample_mealy()
+ hypothesis = sample_mealy_with_wrong_output()
+
+ # none of these sequences ever exercise s0's 'y' transition, so they can't reveal the difference
+ fallback = RecordingOracle()
+ oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback,
+ [['x'], ['x', 'x']])
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertIsNone(cex)
+ self.assertTrue(fallback.called, "wrapped oracle should be used once provided sequences are exhausted")
+
+ def test_exactly_the_provided_sequences_are_checked_and_only_once(self):
+ reference = sample_mealy()
+ hypothesis = sample_mealy()
+
+ fallback = RecordingOracle()
+ provided = [['x'], ['y'], ['x', 'y']]
+ oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback, provided)
+
+ self.assertIsNone(oracle.find_cex(hypothesis))
+ self.assertEqual(provided, [], "all provided sequences should be consumed")
+ self.assertTrue(fallback.called)
+
+ def test_non_revealing_sequences_are_removed_before_the_one_that_finds_the_cex(self):
+ # sequences are consumed in order; once one of them reveals the difference, find_cex returns
+ # immediately, so that sequence (and anything after it) is left untouched in the provided list.
+ reference = sample_mealy()
+ hypothesis = sample_mealy_with_wrong_output()
+
+ fallback = RecordingOracle()
+ provided = [['x', 'x'], ['y'], ['x', 'y', 'x']]
+ oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback, provided)
+ oracle.find_cex(hypothesis)
+
+ self.assertEqual(provided, [['y'], ['x', 'y', 'x']])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_random_w_method_eq_oracle.py b/tests/oracles/test_random_w_method_eq_oracle.py
new file mode 100644
index 00000000000..b9507f74cde
--- /dev/null
+++ b/tests/oracles/test_random_w_method_eq_oracle.py
@@ -0,0 +1,62 @@
+import random
+import unittest
+
+from aalpy.oracles import RandomWMethodEqOracle
+from tests.oracles.test_baseOracle import BaseOracleTests
+
+
+class RandomWMethodEqOracleTests(BaseOracleTests):
+
+ def test_default(self):
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls()
+
+ eq_oracle = RandomWMethodEqOracle(alphabet, learning_sul)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+
+ def test_small_alphabet_more_walks(self):
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 2)
+
+ eq_oracle = RandomWMethodEqOracle(alphabet, learning_sul, walks_per_state=40, walk_len=8)
+ self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul)
+
+ def test_finds_cex_over_several_seeds(self):
+ successes = 0
+ for seed in range(10):
+ random.seed(seed)
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 3)
+ reference_dfa = learning_sul.automaton
+
+ broken_model = reference_dfa.copy()
+ broken_model.states[-1].is_accepting = not broken_model.states[-1].is_accepting
+
+ oracle = RandomWMethodEqOracle(alphabet, validation_sul, walks_per_state=25, walk_len=12)
+ cex = oracle.find_cex(broken_model)
+ if cex is not None:
+ successes += 1
+
+ self.assertGreaterEqual(successes, 9)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ random.seed(0)
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 3)
+ reference_dfa = learning_sul.automaton
+
+ equivalent_model = reference_dfa.copy()
+
+ oracle = RandomWMethodEqOracle(alphabet, validation_sul, walks_per_state=25, walk_len=12)
+ self.assertIsNone(oracle.find_cex(equivalent_model))
+
+ def test_walks_per_state_zero_never_tests_anything(self):
+ learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 3)
+ reference_dfa = learning_sul.automaton
+
+ broken_model = reference_dfa.copy()
+ broken_model.states[-1].is_accepting = not broken_model.states[-1].is_accepting
+
+ oracle = RandomWMethodEqOracle(alphabet, validation_sul, walks_per_state=0, walk_len=12)
+ self.assertIsNone(oracle.find_cex(broken_model))
+ self.assertEqual(oracle.num_queries, 0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_random_walk_eq_oracle.py b/tests/oracles/test_random_walk_eq_oracle.py
new file mode 100644
index 00000000000..5ea43e94168
--- /dev/null
+++ b/tests/oracles/test_random_walk_eq_oracle.py
@@ -0,0 +1,92 @@
+import random
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import RandomWalkEqOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def chain_mealy(length, alphabet=('a',)):
+ """Chain of states, s0 -> s1 -> ... -> s_length, driven by repeating the first letter of `alphabet`."""
+ states = [MealyState(f's{i}') for i in range(length + 1)]
+ first = alphabet[0]
+ for i in range(length):
+ states[i].transitions = {a: states[i] for a in alphabet}
+ states[i].transitions[first] = states[i + 1]
+ states[i].output_fun = {a: 'o' for a in alphabet}
+ states[length].transitions = {a: states[length] for a in alphabet}
+ states[length].output_fun = {a: 'o' for a in alphabet}
+ mm = MealyMachine(states[0], states)
+ mm.compute_prefixes()
+ return mm
+
+
+def with_diverging_transition(mm, diverge_at, alphabet=('a',)):
+ mm.states[diverge_at - 1].output_fun[alphabet[0]] = 'x'
+ return mm
+
+
+class RandomWalkEqOracleTests(unittest.TestCase):
+
+ def test_finds_cex_over_several_seeds(self):
+ successes = 0
+ for seed in range(10):
+ random.seed(seed)
+ reference = chain_mealy(6, alphabet=('a', 'b'))
+ hypothesis = chain_mealy(6, alphabet=('a', 'b'))
+ with_diverging_transition(hypothesis, 6, alphabet=('a', 'b'))
+
+ oracle = RandomWalkEqOracle(['a', 'b'], AutomatonSUL(reference), num_steps=2000, reset_prob=0.05)
+ cex = oracle.find_cex(hypothesis)
+ if cex is not None:
+ successes += 1
+ reference.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [reference.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+ self.assertEqual(sul_out[:-1], hyp_out[:-1])
+
+ self.assertGreaterEqual(successes, 9)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ random.seed(0)
+ reference = chain_mealy(4, alphabet=('a', 'b'))
+ hypothesis = chain_mealy(4, alphabet=('a', 'b'))
+
+ oracle = RandomWalkEqOracle(['a', 'b'], AutomatonSUL(reference), num_steps=2000)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_reset_after_cex_false_does_not_replenish_step_budget(self):
+ reference = chain_mealy(5)
+ hypothesis = chain_mealy(5)
+ with_diverging_transition(hypothesis, 5)
+
+ oracle = RandomWalkEqOracle(['a'], AutomatonSUL(reference), num_steps=5, reset_prob=0.0,
+ reset_after_cex=False)
+
+ first_cex = oracle.find_cex(hypothesis)
+ self.assertIsNotNone(first_cex)
+ self.assertEqual(oracle.random_steps_done, oracle.step_limit)
+
+ second_cex = oracle.find_cex(hypothesis)
+ self.assertIsNone(second_cex)
+
+ def test_reset_after_cex_true_replenishes_step_budget(self):
+ reference = chain_mealy(5)
+ hypothesis = chain_mealy(5)
+ with_diverging_transition(hypothesis, 5)
+
+ oracle = RandomWalkEqOracle(['a'], AutomatonSUL(reference), num_steps=5, reset_prob=0.0,
+ reset_after_cex=True)
+
+ first_cex = oracle.find_cex(hypothesis)
+ self.assertIsNotNone(first_cex)
+ self.assertEqual(oracle.random_steps_done, 0)
+
+ second_cex = oracle.find_cex(hypothesis)
+ self.assertIsNotNone(second_cex)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_random_word_eq_oracle.py b/tests/oracles/test_random_word_eq_oracle.py
new file mode 100644
index 00000000000..033dc3b69dc
--- /dev/null
+++ b/tests/oracles/test_random_word_eq_oracle.py
@@ -0,0 +1,95 @@
+import random
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import RandomWordEqOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def chain_mealy(length, alphabet=('a',)):
+ """Chain of states, s0 -> s1 -> ... -> s_length, driven by repeating the first letter of `alphabet`."""
+ states = [MealyState(f's{i}') for i in range(length + 1)]
+ first = alphabet[0]
+ for i in range(length):
+ states[i].transitions = {a: states[i] for a in alphabet}
+ states[i].transitions[first] = states[i + 1]
+ states[i].output_fun = {a: 'o' for a in alphabet}
+ states[length].transitions = {a: states[length] for a in alphabet}
+ states[length].output_fun = {a: 'o' for a in alphabet}
+ mm = MealyMachine(states[0], states)
+ mm.compute_prefixes()
+ return mm
+
+
+def with_diverging_transition(mm, diverge_at, alphabet=('a',)):
+ mm.states[diverge_at - 1].output_fun[alphabet[0]] = 'x'
+ return mm
+
+
+class RandomWordEqOracleTests(unittest.TestCase):
+
+ def test_finds_cex_over_several_seeds(self):
+ successes = 0
+ for seed in range(10):
+ random.seed(seed)
+ reference = chain_mealy(4, alphabet=('a', 'b'))
+ hypothesis = chain_mealy(4, alphabet=('a', 'b'))
+ with_diverging_transition(hypothesis, 4, alphabet=('a', 'b'))
+
+ oracle = RandomWordEqOracle(['a', 'b'], AutomatonSUL(reference), num_walks=200,
+ min_walk_len=5, max_walk_len=10)
+ cex = oracle.find_cex(hypothesis)
+ if cex is not None:
+ successes += 1
+ reference.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [reference.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+
+ self.assertGreaterEqual(successes, 9)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ random.seed(0)
+ reference = chain_mealy(4, alphabet=('a', 'b'))
+ hypothesis = chain_mealy(4, alphabet=('a', 'b'))
+
+ oracle = RandomWordEqOracle(['a', 'b'], AutomatonSUL(reference), num_walks=200)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_fixed_walk_length_bounds_reachable_difference(self):
+ # min_walk_len == max_walk_len == 5, alphabet of size 1, so every walk is exactly 'aaaaa'
+ reference = chain_mealy(6)
+ hypothesis = chain_mealy(6)
+ with_diverging_transition(hypothesis, 6)
+
+ oracle = RandomWordEqOracle(['a'], AutomatonSUL(reference), num_walks=5, min_walk_len=5, max_walk_len=5)
+ self.assertIsNone(oracle.find_cex(hypothesis), "walk length is fixed below the divergence depth")
+
+ def test_fixed_walk_length_finds_reachable_difference(self):
+ reference = chain_mealy(5)
+ hypothesis = chain_mealy(5)
+ with_diverging_transition(hypothesis, 5)
+
+ oracle = RandomWordEqOracle(['a'], AutomatonSUL(reference), num_walks=5, min_walk_len=5, max_walk_len=5)
+ cex = oracle.find_cex(hypothesis)
+ self.assertEqual(tuple(cex), ('a',) * 5)
+
+ def test_reset_after_cex_false_does_not_replenish_walk_budget(self):
+ reference = chain_mealy(5)
+ hypothesis = chain_mealy(5)
+ with_diverging_transition(hypothesis, 5)
+
+ oracle = RandomWordEqOracle(['a'], AutomatonSUL(reference), num_walks=1, min_walk_len=5, max_walk_len=5,
+ reset_after_cex=False)
+
+ first_cex = oracle.find_cex(hypothesis)
+ self.assertIsNotNone(first_cex)
+ self.assertEqual(oracle.num_walks_done, oracle.num_walks)
+
+ second_cex = oracle.find_cex(hypothesis)
+ self.assertIsNone(second_cex)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_rwpmethod_oracle.py b/tests/oracles/test_rwpmethod_oracle.py
new file mode 100644
index 00000000000..40c2c1077d6
--- /dev/null
+++ b/tests/oracles/test_rwpmethod_oracle.py
@@ -0,0 +1,114 @@
+import unittest
+
+try:
+ from aalpy.automata import MooreMachine, MooreState
+ from aalpy.learning_algs import run_Lstar
+ from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle
+ from aalpy.SULs import AutomatonSUL
+ from aalpy.utils import visualize_automaton
+except ImportError:
+ import sys
+ from pathlib import Path
+
+ # if you want to run the test directly from CLI
+ # either from root or from tests folder
+ p = Path(__file__).parent.resolve()
+ sys.path.append(str(p))
+ sys.path.append(str(p.parent))
+ sys.path.append(str(p.parent.parent))
+ from aalpy.automata import MooreMachine, MooreState
+ from aalpy.learning_algs import run_Lstar
+ from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle
+ from aalpy.SULs import AutomatonSUL
+ from aalpy.utils import visualize_automaton
+
+
+class TestRandomWpMethodOracle(unittest.TestCase):
+ @staticmethod
+ def gen_moore_from_state_setup(state_setup) -> MooreMachine:
+ # state_setup shoud map from state_id to tuple(output and transitions_dict)
+
+ # build states with state_id and output
+ states = {key: MooreState(key, val[0]) for key, val in state_setup.items()}
+
+ # add transitions to states
+ for state_id, state in states.items():
+ for _input, target_state_id in state_setup[state_id][1].items():
+ state.transitions[_input] = states[target_state_id]
+
+ # states to list
+ states = [state for state in states.values()]
+
+ # build moore machine with first state as starting state
+ mm = MooreMachine(states[0], states)
+
+ for state in states:
+ state.prefix = mm.get_shortest_path(mm.initial_state, state)
+
+ return mm
+
+ def generate_real_automata(self) -> MooreMachine:
+ state_setup = {
+ "a": ("a", {"x": "b1", "y": "a"}),
+ "b1": ("b", {"x": "b2", "y": "a"}),
+ "b2": ("b", {"x": "b3", "y": "a"}),
+ "b3": ("b", {"x": "b4", "y": "a"}),
+ "b4": ("b", {"x": "c", "y": "a"}),
+ "c": ("c", {"x": "a", "y": "a"}),
+ }
+
+ mm = self.gen_moore_from_state_setup(state_setup)
+ mm.characterization_set = mm.compute_characterization_set() + [tuple()]
+ return mm
+
+ def generate_hypothesis(self) -> MooreMachine:
+ state_setup = {
+ "a": ("a", {"x": "b", "y": "a"}),
+ "b": ("b", {"x": "b", "y": "a"}),
+ }
+
+ mm = self.gen_moore_from_state_setup(state_setup)
+ # ! computer_characterization_set does not work for Moore machines in general!
+ # mm.characterization_set = mm.compute_characterization_set() + [tuple()]
+ mm.characterization_set = [tuple(), ("x",), ("y",)]
+ return mm
+
+ def test_rwpmethod_oracle(self):
+ real = self.generate_real_automata()
+ hyp = self.generate_hypothesis()
+ # visualize_automaton(real)
+ # visualize_automaton(hyp)
+ assert set(real.get_input_alphabet()) == {"x", "y"}
+ assert set(hyp.get_input_alphabet()) == {"x", "y"}
+ assert len(real.states) == 6
+ assert len(hyp.states) == 2
+ alphabet = real.get_input_alphabet()
+ oracle = RandomWpMethodEqOracle(
+ alphabet, AutomatonSUL(real)
+ )
+ cex = oracle.find_cex(hyp)
+ assert cex is not None, "Expected a counterexample, but got None"
+
+ def test_rwpmethod_oracle_with_lstar(self):
+ real = self.generate_real_automata()
+ hyp = self.generate_hypothesis()
+ # visualize_automaton(real)
+ # visualize_automaton(hyp)
+ assert real.get_input_alphabet() == ["x", "y"]
+ assert hyp.get_input_alphabet() == ["x", "y"]
+ assert len(real.states) == 6
+ assert len(hyp.states) == 2
+ alphabet = real.get_input_alphabet()
+ oracle = RandomWpMethodEqOracle(
+ alphabet, AutomatonSUL(real)
+ )
+ lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore")
+ # print(lstar_hyp)
+ # visualize_automaton(lstar_hyp)
+ assert (
+ len(lstar_hyp.states) == 6
+ ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis"
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/oracles/test_state_prefix_eq_oracle.py b/tests/oracles/test_state_prefix_eq_oracle.py
new file mode 100644
index 00000000000..5f09275a21e
--- /dev/null
+++ b/tests/oracles/test_state_prefix_eq_oracle.py
@@ -0,0 +1,79 @@
+import random
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import StatePrefixEqOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def branching_mealy():
+ """
+ 4-state Mealy machine over {a, b}. From s0, 'a' reaches s1 and 'b' reaches s2; s1 and s2 both lead to s3 on 'a'.
+ s3's outgoing transitions are where the two variants below differ.
+ """
+ s0, s1, s2, s3 = (MealyState(f's{i}') for i in range(4))
+ s0.transitions = {'a': s1, 'b': s2}
+ s0.output_fun = {'a': 'o', 'b': 'o'}
+ s1.transitions = {'a': s3, 'b': s1}
+ s1.output_fun = {'a': 'o', 'b': 'o'}
+ s2.transitions = {'a': s3, 'b': s2}
+ s2.output_fun = {'a': 'o', 'b': 'o'}
+ s3.transitions = {'a': s3, 'b': s3}
+ s3.output_fun = {'a': 'o', 'b': 'o'}
+ mm = MealyMachine(s0, [s0, s1, s2, s3])
+ mm.compute_prefixes()
+ return mm, (s0, s1, s2, s3)
+
+
+class StatePrefixEqOracleTests(unittest.TestCase):
+
+ def test_finds_difference_reachable_only_via_specific_state_suffix(self):
+ # difference is only observable from s2's own 'b' self-loop, unreachable from s0/s1's local suffixes alone
+ reference, (s0, s1, s2, s3) = branching_mealy()
+ hypothesis, (h0, h1, h2, h3) = branching_mealy()
+ h2.output_fun['b'] = 'x'
+
+ random.seed(0)
+ oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=30, walk_len=3)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertIsNotNone(cex)
+ reference.reset_to_initial()
+ hypothesis.reset_to_initial()
+ sul_out = [reference.step(i) for i in cex]
+ hyp_out = [hypothesis.step(i) for i in cex]
+ self.assertNotEqual(sul_out[-1], hyp_out[-1])
+ self.assertEqual(sul_out[:-1], hyp_out[:-1])
+ # the divergent suffix step must be taken from s2 (reached only via prefix 'b')
+ self.assertEqual(cex[0], 'b')
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ random.seed(0)
+ reference, _ = branching_mealy()
+ hypothesis, _ = branching_mealy()
+
+ oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=30, walk_len=5)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_max_tests_bounds_number_of_queries(self):
+ random.seed(0)
+ reference, _ = branching_mealy()
+ hypothesis, _ = branching_mealy()
+
+ oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=30, walk_len=5,
+ max_tests=5)
+ oracle.find_cex(hypothesis)
+ self.assertLessEqual(oracle.num_queries, 5)
+
+ def test_zero_walks_per_state_never_tests_anything(self):
+ reference, _ = branching_mealy()
+ hypothesis, (h0, h1, h2, h3) = branching_mealy()
+ h2.output_fun['b'] = 'x'
+
+ oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=0, walk_len=5)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+ self.assertEqual(oracle.num_queries, 0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_transition_focus_oracle.py b/tests/oracles/test_transition_focus_oracle.py
new file mode 100644
index 00000000000..a382cbf286b
--- /dev/null
+++ b/tests/oracles/test_transition_focus_oracle.py
@@ -0,0 +1,72 @@
+import random
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import TransitionFocusOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def self_loop_vs_transition_mealy():
+ """
+ 2-state Mealy machine. s0 --a/o1--> s0 (self loop) and s0 --b/o2--> s1 (different-state transition).
+ s1 mirrors s0's transitions so the walk never gets stuck.
+ """
+ s0, s1 = MealyState('s0'), MealyState('s1')
+ s0.transitions = {'a': s0, 'b': s1}
+ s0.output_fun = {'a': 'o1', 'b': 'o2'}
+ s1.transitions = {'a': s1, 'b': s0}
+ s1.output_fun = {'a': 'o1', 'b': 'o2'}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm, s0, s1
+
+
+class TransitionFocusOracleTests(unittest.TestCase):
+
+ def test_same_state_prob_1_finds_self_loop_only_difference(self):
+ reference, s0, s1 = self_loop_vs_transition_mealy()
+ hypothesis, h0, h1 = self_loop_vs_transition_mealy()
+ h0.output_fun['a'] = 'x' # only the self-loop on s0 differs
+
+ oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=1, walk_len=1,
+ same_state_prob=1.0)
+ cex = oracle.find_cex(hypothesis)
+
+ self.assertEqual(tuple(cex), ('a',))
+
+ def test_same_state_prob_0_never_probes_the_self_loop(self):
+ reference, s0, s1 = self_loop_vs_transition_mealy()
+ hypothesis, h0, h1 = self_loop_vs_transition_mealy()
+ h0.output_fun['a'] = 'x' # only the self-loop on s0 differs, 'b' transitions remain correct everywhere
+
+ oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=20, walk_len=10,
+ same_state_prob=0.0)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_finds_difference_on_diff_state_transition_over_several_seeds(self):
+ successes = 0
+ for seed in range(10):
+ random.seed(seed)
+ reference, s0, s1 = self_loop_vs_transition_mealy()
+ hypothesis, h0, h1 = self_loop_vs_transition_mealy()
+ h0.output_fun['b'] = 'x' # only the s0 -> s1 transition differs
+
+ oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=30, walk_len=10,
+ same_state_prob=0.2)
+ cex = oracle.find_cex(hypothesis)
+ if cex is not None:
+ successes += 1
+
+ self.assertGreaterEqual(successes, 9)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ random.seed(0)
+ reference, _, _ = self_loop_vs_transition_mealy()
+ hypothesis, _, _ = self_loop_vs_transition_mealy()
+
+ oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=50, walk_len=10)
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_user_input_eq_oracle.py b/tests/oracles/test_user_input_eq_oracle.py
new file mode 100644
index 00000000000..4185fe23856
--- /dev/null
+++ b/tests/oracles/test_user_input_eq_oracle.py
@@ -0,0 +1,81 @@
+import unittest
+from unittest.mock import patch
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles import UserInputEqOracle
+from aalpy.SULs import AutomatonSUL
+
+
+def sample_mealy():
+ """2-state Mealy machine over {x, y}."""
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'x': s1, 'y': s0}
+ s0.output_fun = {'x': 'o1', 'y': 'o2'}
+ s1.transitions = {'x': s0, 'y': s1}
+ s1.output_fun = {'x': 'o3', 'y': 'o1'}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm
+
+
+class UserInputEqOracleTests(unittest.TestCase):
+
+ def setUp(self):
+ self.visualize_patcher = patch('aalpy.oracles.UserInputEqOracle.visualize_automaton')
+ self.visualize_patcher.start()
+
+ def tearDown(self):
+ self.visualize_patcher.stop()
+
+ def test_user_enters_inputs_then_requests_cex(self):
+ mm = sample_mealy()
+ oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm))
+
+ with patch('builtins.input', side_effect=['x', 'y', 'cex']):
+ cex = oracle.find_cex(mm)
+
+ self.assertEqual(cex, ['x', 'y'])
+
+ def test_user_ends_session_without_a_counterexample(self):
+ mm = sample_mealy()
+ oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm))
+
+ with patch('builtins.input', side_effect=['x', 'end']):
+ cex = oracle.find_cex(mm)
+
+ self.assertIsNone(cex)
+
+ def test_cex_command_with_no_inputs_yet_is_ignored_and_prompts_again(self):
+ mm = sample_mealy()
+ oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm))
+
+ # 'cex' before any input is entered is a no-op (inputs is empty and falsy), so the loop must continue
+ with patch('builtins.input', side_effect=['cex', 'x', 'cex']):
+ cex = oracle.find_cex(mm)
+
+ self.assertEqual(cex, ['x'])
+
+ def test_reset_clears_inputs_entered_so_far(self):
+ mm = sample_mealy()
+ oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm))
+
+ with patch('builtins.input', side_effect=['x', 'x', 'reset', 'y', 'cex']):
+ cex = oracle.find_cex(mm)
+
+ self.assertEqual(cex, ['y'])
+
+ def test_unknown_command_and_letter_not_in_alphabet_are_rejected(self):
+ mm = sample_mealy()
+ oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm))
+
+ with patch('builtins.input', side_effect=['help', 'print alphabet', 'current inputs',
+ 'not_a_valid_letter', 'x', 'cex']) as mocked_input:
+ cex = oracle.find_cex(mm)
+
+ self.assertEqual(cex, ['x'])
+ self.assertEqual(mocked_input.call_count, 6)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/oracles/test_wmethod_oracle.py b/tests/oracles/test_wmethod_oracle.py
new file mode 100644
index 00000000000..30e9030824e
--- /dev/null
+++ b/tests/oracles/test_wmethod_oracle.py
@@ -0,0 +1,110 @@
+import unittest
+
+try:
+ from aalpy.automata import MooreMachine, MooreState
+ from aalpy.learning_algs import run_Lstar
+ from aalpy.oracles.WMethodEqOracle import WMethodEqOracle
+ from aalpy.SULs import AutomatonSUL
+ from aalpy.utils import visualize_automaton
+except ImportError:
+ import sys
+ from pathlib import Path
+
+ # if you want to run the test directly from CLI
+ # either from root or from tests folder
+ p = Path(__file__).parent.resolve()
+ sys.path.append(str(p))
+ sys.path.append(str(p.parent))
+ sys.path.append(str(p.parent.parent))
+ from aalpy.automata import MooreMachine, MooreState
+ from aalpy.learning_algs import run_Lstar
+ from aalpy.oracles.WMethodEqOracle import WMethodEqOracle
+ from aalpy.SULs import AutomatonSUL
+ from aalpy.utils import visualize_automaton
+
+
+class TestWMethodOracle(unittest.TestCase):
+ @staticmethod
+ def gen_moore_from_state_setup(state_setup) -> MooreMachine:
+ # state_setup shoud map from state_id to tuple(output and transitions_dict)
+
+ # build states with state_id and output
+ states = {key: MooreState(key, val[0]) for key, val in state_setup.items()}
+
+ # add transitions to states
+ for state_id, state in states.items():
+ for _input, target_state_id in state_setup[state_id][1].items():
+ state.transitions[_input] = states[target_state_id]
+
+ # states to list
+ states = [state for state in states.values()]
+
+ # build moore machine with first state as starting state
+ mm = MooreMachine(states[0], states)
+
+ for state in states:
+ state.prefix = mm.get_shortest_path(mm.initial_state, state)
+
+ return mm
+
+ def generate_real_automata(self) -> MooreMachine:
+ state_setup = {
+ "a": ("a", {"x": "b1", "y": "a"}),
+ "b1": ("b", {"x": "b2", "y": "a"}),
+ "b2": ("b", {"x": "b3", "y": "a"}),
+ "b3": ("b", {"x": "b4", "y": "a"}),
+ "b4": ("b", {"x": "c", "y": "a"}),
+ "c": ("c", {"x": "a", "y": "a"}),
+ }
+
+ mm = self.gen_moore_from_state_setup(state_setup)
+ mm.characterization_set = mm.compute_characterization_set() + [tuple()]
+ return mm
+
+ def generate_hypothesis(self) -> MooreMachine:
+ state_setup = {
+ "a": ("a", {"x": "b", "y": "a"}),
+ "b": ("b", {"x": "b", "y": "a"}),
+ }
+
+ mm = self.gen_moore_from_state_setup(state_setup)
+ # ! computer_characterization_set does not work for Moore machines in general!
+ # mm.characterization_set = mm.compute_characterization_set() + [tuple()]
+ mm.characterization_set = [tuple(), ("x",), ("y",)]
+ return mm
+
+ def test_wmethod_oracle(self):
+ real = self.generate_real_automata()
+ hyp = self.generate_hypothesis()
+ # visualize_automaton(real)
+ # visualize_automaton(hyp)
+ assert set(real.get_input_alphabet()) == {"x", "y"}
+ assert set(hyp.get_input_alphabet()) == {"x", "y"}
+ assert len(real.states) == 6
+ assert len(hyp.states) == 2
+ alphabet = real.get_input_alphabet()
+ oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1)
+ cex = oracle.find_cex(hyp)
+ assert cex is not None, "Expected a counterexample, but got None"
+
+ def test_wmethod_oracle_with_lstar(self):
+ real = self.generate_real_automata()
+ hyp = self.generate_hypothesis()
+ # visualize_automaton(real)
+ # visualize_automaton(hyp)
+ assert real.get_input_alphabet() == ["x", "y"]
+ assert hyp.get_input_alphabet() == ["x", "y"]
+ assert len(real.states) == 6
+ assert len(hyp.states) == 2
+ alphabet = real.get_input_alphabet()
+ oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1)
+ lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore")
+ # print(lstar_hyp)
+ # visualize_automaton(lstar_hyp)
+ assert (
+ len(lstar_hyp.states) == 6
+ ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis"
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/oracles/test_wpmethod_oracle.py b/tests/oracles/test_wpmethod_oracle.py
new file mode 100644
index 00000000000..8cbd89625a4
--- /dev/null
+++ b/tests/oracles/test_wpmethod_oracle.py
@@ -0,0 +1,114 @@
+import unittest
+
+try:
+ from aalpy.automata import MooreMachine, MooreState
+ from aalpy.learning_algs import run_Lstar
+ from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle
+ from aalpy.SULs import AutomatonSUL
+ from aalpy.utils import visualize_automaton
+except ImportError:
+ import sys
+ from pathlib import Path
+
+ # if you want to run the test directly from CLI
+ # either from root or from tests folder
+ p = Path(__file__).parent.resolve()
+ sys.path.append(str(p))
+ sys.path.append(str(p.parent))
+ sys.path.append(str(p.parent.parent))
+ from aalpy.automata import MooreMachine, MooreState
+ from aalpy.learning_algs import run_Lstar
+ from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle
+ from aalpy.SULs import AutomatonSUL
+ from aalpy.utils import visualize_automaton
+
+
+class TestWpMethodOracle(unittest.TestCase):
+ @staticmethod
+ def gen_moore_from_state_setup(state_setup) -> MooreMachine:
+ # state_setup shoud map from state_id to tuple(output and transitions_dict)
+
+ # build states with state_id and output
+ states = {key: MooreState(key, val[0]) for key, val in state_setup.items()}
+
+ # add transitions to states
+ for state_id, state in states.items():
+ for _input, target_state_id in state_setup[state_id][1].items():
+ state.transitions[_input] = states[target_state_id]
+
+ # states to list
+ states = [state for state in states.values()]
+
+ # build moore machine with first state as starting state
+ mm = MooreMachine(states[0], states)
+
+ for state in states:
+ state.prefix = mm.get_shortest_path(mm.initial_state, state)
+
+ return mm
+
+ def generate_real_automata(self) -> MooreMachine:
+ state_setup = {
+ "a": ("a", {"x": "b1", "y": "a"}),
+ "b1": ("b", {"x": "b2", "y": "a"}),
+ "b2": ("b", {"x": "b3", "y": "a"}),
+ "b3": ("b", {"x": "b4", "y": "a"}),
+ "b4": ("b", {"x": "c", "y": "a"}),
+ "c": ("c", {"x": "a", "y": "a"}),
+ }
+
+ mm = self.gen_moore_from_state_setup(state_setup)
+ mm.characterization_set = mm.compute_characterization_set() + [tuple()]
+ return mm
+
+ def generate_hypothesis(self) -> MooreMachine:
+ state_setup = {
+ "a": ("a", {"x": "b", "y": "a"}),
+ "b": ("b", {"x": "b", "y": "a"}),
+ }
+
+ mm = self.gen_moore_from_state_setup(state_setup)
+ # ! computer_characterization_set does not work for Moore machines in general!
+ # mm.characterization_set = mm.compute_characterization_set() + [tuple()]
+ mm.characterization_set = [tuple(), ("x",), ("y",)]
+ return mm
+
+ def test_wpmethod_oracle(self):
+ real = self.generate_real_automata()
+ hyp = self.generate_hypothesis()
+ # visualize_automaton(real)
+ # visualize_automaton(hyp)
+ assert set(real.get_input_alphabet()) == {"x", "y"}
+ assert set(hyp.get_input_alphabet()) == {"x", "y"}
+ assert len(real.states) == 6
+ assert len(hyp.states) == 2
+ alphabet = real.get_input_alphabet()
+ oracle = WpMethodEqOracle(
+ alphabet, AutomatonSUL(real), len(real.states) + 1
+ )
+ cex = oracle.find_cex(hyp)
+ assert cex is not None, "Expected a counterexample, but got None"
+
+ def test_wpmethod_oracle_with_lstar(self):
+ real = self.generate_real_automata()
+ hyp = self.generate_hypothesis()
+ # visualize_automaton(real)
+ # visualize_automaton(hyp)
+ assert real.get_input_alphabet() == ["x", "y"]
+ assert hyp.get_input_alphabet() == ["x", "y"]
+ assert len(real.states) == 6
+ assert len(hyp.states) == 2
+ alphabet = real.get_input_alphabet()
+ oracle = WpMethodEqOracle(
+ alphabet, AutomatonSUL(real), len(real.states) + 1
+ )
+ lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore")
+ # print(lstar_hyp)
+ # visualize_automaton(lstar_hyp)
+ assert (
+ len(lstar_hyp.states) == 6
+ ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis"
+
+
+if __name__ == "__main__":
+ unittest.main()
From 7de29ed77d5d1bc03811e7addd896ada8112027e Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:15:53 +0200
Subject: [PATCH 14/25] Add SULs and utils tests
---
tests/SULs/test_automata_sul.py | 210 ++++++++++++++
tests/SULs/test_py_method_sul.py | 81 ++++++
tests/SULs/test_regex_sul.py | 56 ++++
tests/SULs/test_tomita_sul.py | 78 +++++
tests/utils/test_automaton_generators.py | 254 ++++++++++++++++
tests/utils/test_char_set.py | 73 +++++
tests/utils/test_data_handler.py | 83 ++++++
tests/utils/test_file_handler_extra.py | 154 ++++++++++
tests/utils/test_file_operations.py | 86 ++++++
tests/utils/test_helper_functions.py | 353 +++++++++++++++++++++++
tests/utils/test_model_checking.py | 160 ++++++++++
tests/utils/test_sampling.py | 157 ++++++++++
12 files changed, 1745 insertions(+)
create mode 100644 tests/SULs/test_automata_sul.py
create mode 100644 tests/SULs/test_py_method_sul.py
create mode 100644 tests/SULs/test_regex_sul.py
create mode 100644 tests/SULs/test_tomita_sul.py
create mode 100644 tests/utils/test_automaton_generators.py
create mode 100644 tests/utils/test_char_set.py
create mode 100644 tests/utils/test_data_handler.py
create mode 100644 tests/utils/test_file_handler_extra.py
create mode 100644 tests/utils/test_file_operations.py
create mode 100644 tests/utils/test_helper_functions.py
create mode 100644 tests/utils/test_model_checking.py
create mode 100644 tests/utils/test_sampling.py
diff --git a/tests/SULs/test_automata_sul.py b/tests/SULs/test_automata_sul.py
new file mode 100644
index 00000000000..78802030dd9
--- /dev/null
+++ b/tests/SULs/test_automata_sul.py
@@ -0,0 +1,210 @@
+import unittest
+
+from aalpy.automata import (Dfa, DfaState, MealyMachine, MealyState, Mdp, MdpState, MooreMachine, MooreState,
+ Onfsm, OnfsmState)
+from aalpy.SULs import AutomatonSUL, DfaSUL, MealySUL, MdpSUL, MooreSUL, OnfsmSUL
+
+
+def parity_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+def sample_mealy():
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'x': s1, 'y': s0}
+ s0.output_fun = {'x': 'o1', 'y': 'o2'}
+ s1.transitions = {'x': s0, 'y': s1}
+ s1.output_fun = {'x': 'o3', 'y': 'o1'}
+ return MealyMachine(s0, [s0, s1])
+
+
+def sample_moore():
+ s0 = MooreState('s0', output='A')
+ s1 = MooreState('s1', output='B')
+ s0.transitions = {'x': s1, 'y': s0}
+ s1.transitions = {'x': s0, 'y': s1}
+ return MooreMachine(s0, [s0, s1])
+
+
+def deterministic_mdp():
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s0.transitions['a'].append((s1, 1.0))
+ s1.transitions['a'].append((s0, 1.0))
+ return Mdp(s0, [s0, s1])
+
+
+def sample_onfsm():
+ s0 = OnfsmState('s0')
+ s1 = OnfsmState('s1')
+ s0.transitions['a'].append(('out1', s1))
+ s1.transitions['a'].append(('out2', s0))
+ return Onfsm(s0, [s0, s1])
+
+
+class TestAutomatonSULAliases(unittest.TestCase):
+ """DfaSUL, MealySUL, MooreSUL, MdpSUL, OnfsmSUL are all just AutomatonSUL under a different name."""
+
+ def test_aliases_are_automaton_sul(self):
+ self.assertIs(DfaSUL, AutomatonSUL)
+ self.assertIs(MealySUL, AutomatonSUL)
+ self.assertIs(MooreSUL, AutomatonSUL)
+ self.assertIs(MdpSUL, AutomatonSUL)
+ self.assertIs(OnfsmSUL, AutomatonSUL)
+
+
+class TestAutomatonSULConstruction(unittest.TestCase):
+ def test_wraps_given_automaton(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ self.assertIs(sul.automaton, dfa)
+
+ def test_counters_start_at_zero(self):
+ sul = AutomatonSUL(parity_dfa())
+ self.assertEqual(sul.num_queries, 0)
+ self.assertEqual(sul.num_steps, 0)
+ self.assertEqual(sul.num_cached_queries, 0)
+
+
+class TestAutomatonSULWithDfa(unittest.TestCase):
+ def test_step_delegates_to_automaton_and_moves_state(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ self.assertFalse(sul.step('a'))
+ self.assertIs(dfa.current_state, dfa.get_state_by_id('q1'))
+ self.assertTrue(sul.step('a'))
+ self.assertIs(dfa.current_state, dfa.initial_state)
+
+ def test_pre_resets_wrapped_automaton_to_initial_state(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ sul.step('a')
+ self.assertIsNot(dfa.current_state, dfa.initial_state)
+ sul.pre()
+ self.assertIs(dfa.current_state, dfa.initial_state)
+
+ def test_post_is_a_no_op(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ sul.step('a')
+ state_before = dfa.current_state
+ sul.post()
+ self.assertIs(dfa.current_state, state_before)
+
+ def test_query_matches_manual_stepping(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ self.assertEqual(sul.query(('a', 'b', 'a')), [False, False, True])
+
+ def test_query_empty_word_returns_initial_state_output(self):
+ sul = AutomatonSUL(parity_dfa())
+ self.assertEqual(sul.query(()), [True])
+
+ def test_query_resets_before_running(self):
+ dfa = parity_dfa()
+ sul = AutomatonSUL(dfa)
+ sul.step('a') # leave the automaton in a non-initial state
+ self.assertEqual(sul.query(('a',)), [False]) # query() calls pre() first, so this is from q0 again
+
+ def test_io_query_pairs_inputs_with_outputs(self):
+ sul = AutomatonSUL(parity_dfa())
+ self.assertEqual(sul.io_query(('a', 'a')), [('a', False), ('a', True)])
+
+ def test_num_queries_and_steps_accumulate_across_calls(self):
+ sul = AutomatonSUL(parity_dfa())
+ sul.query(('a', 'b'))
+ sul.query(('a',))
+ sul.query(())
+ self.assertEqual(sul.num_queries, 3)
+ self.assertEqual(sul.num_steps, 3) # the empty query contributes 0 steps
+
+ def test_multiple_suls_wrapping_the_same_automaton_share_state(self):
+ dfa = parity_dfa()
+ sul1 = AutomatonSUL(dfa)
+ sul2 = AutomatonSUL(dfa)
+ sul1.step('a')
+ # both wrap the very same automaton object, so state is not per-SUL
+ self.assertIs(sul1.automaton.current_state, sul2.automaton.current_state)
+
+
+class TestAutomatonSULWithMealy(unittest.TestCase):
+ def test_step_returns_output_and_moves_state(self):
+ mm = sample_mealy()
+ sul = AutomatonSUL(mm)
+ self.assertEqual(sul.step('x'), 'o1')
+ self.assertIs(mm.current_state, mm.get_state_by_id('s1'))
+
+ def test_query_matches_manual_stepping(self):
+ sul = AutomatonSUL(sample_mealy())
+ self.assertEqual(sul.query(('x', 'x', 'y')), ['o1', 'o3', 'o2'])
+
+ def test_query_empty_word_raises_key_error(self):
+ # Mealy machines have no state-based output, so there is no well-defined answer for the
+ # empty word; SUL.query(()) calls step(None), and Mealy's output_fun has no None entry.
+ sul = AutomatonSUL(sample_mealy())
+ with self.assertRaises(KeyError):
+ sul.query(())
+
+ def test_io_query_pairs_inputs_with_outputs(self):
+ sul = AutomatonSUL(sample_mealy())
+ self.assertEqual(sul.io_query(('x', 'y')), [('x', 'o1'), ('y', 'o1')])
+
+
+class TestAutomatonSULWithMoore(unittest.TestCase):
+ def test_step_returns_output_of_reached_state(self):
+ mm = sample_moore()
+ sul = AutomatonSUL(mm)
+ self.assertEqual(sul.step('x'), 'B')
+ self.assertIs(mm.current_state, mm.get_state_by_id('s1'))
+
+ def test_query_matches_manual_stepping(self):
+ sul = AutomatonSUL(sample_moore())
+ self.assertEqual(sul.query(('x', 'y', 'x')), ['B', 'B', 'A'])
+
+ def test_query_empty_word_returns_initial_state_output(self):
+ sul = AutomatonSUL(sample_moore())
+ self.assertEqual(sul.query(()), ['A'])
+
+ def test_io_query_pairs_inputs_with_outputs(self):
+ sul = AutomatonSUL(sample_moore())
+ self.assertEqual(sul.io_query(('x',)), [('x', 'B')])
+
+
+class TestAutomatonSULWithMdp(unittest.TestCase):
+ def test_step_delegates_and_moves_state(self):
+ mdp = deterministic_mdp()
+ sul = AutomatonSUL(mdp)
+ self.assertEqual(sul.step('a'), 'B')
+ self.assertIs(mdp.current_state, mdp.get_state_by_id('s1'))
+
+ def test_query_matches_manual_stepping(self):
+ sul = AutomatonSUL(deterministic_mdp())
+ self.assertEqual(sul.query(('a', 'a', 'a')), ['B', 'A', 'B'])
+
+ def test_query_empty_word_returns_initial_state_output_without_moving(self):
+ mdp = deterministic_mdp()
+ sul = AutomatonSUL(mdp)
+ self.assertEqual(sul.query(()), ['A'])
+ self.assertIs(mdp.current_state, mdp.initial_state)
+
+
+class TestAutomatonSULWithOnfsm(unittest.TestCase):
+ def test_step_returns_one_of_the_possible_outputs(self):
+ onfsm = sample_onfsm()
+ sul = AutomatonSUL(onfsm)
+ output = sul.step('a')
+ self.assertIn(output, ('out1',))
+ self.assertIs(onfsm.current_state, onfsm.get_state_by_id('s1'))
+
+ def test_query_matches_manual_stepping(self):
+ sul = AutomatonSUL(sample_onfsm())
+ self.assertEqual(sul.query(('a', 'a')), ['out1', 'out2'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/SULs/test_py_method_sul.py b/tests/SULs/test_py_method_sul.py
new file mode 100644
index 00000000000..d2c9a112e1f
--- /dev/null
+++ b/tests/SULs/test_py_method_sul.py
@@ -0,0 +1,81 @@
+import unittest
+
+from aalpy.SULs.PyMethodSUL import FunctionDecorator, PyClassSUL
+
+
+class Counter:
+ """Tiny stateful class used as a Python-class SUL target."""
+
+ def __init__(self):
+ self.value = 0
+
+ def increment(self):
+ self.value += 1
+ return self.value
+
+ def add(self, amount):
+ self.value += amount
+ return self.value
+
+ def get(self):
+ return self.value
+
+
+class TestFunctionDecorator(unittest.TestCase):
+ def test_repr_without_args(self):
+ decorator = FunctionDecorator(Counter.increment)
+ self.assertEqual(repr(decorator), 'increment')
+
+ def test_repr_with_single_arg(self):
+ decorator = FunctionDecorator(Counter.add, args=5)
+ self.assertEqual(repr(decorator), 'add[5]')
+
+ def test_repr_with_multiple_args(self):
+ decorator = FunctionDecorator(Counter.add, args=[1, 2])
+ self.assertEqual(repr(decorator), 'add[1, 2]')
+
+ def test_no_args_when_none_given(self):
+ decorator = FunctionDecorator(Counter.increment, args=None)
+ self.assertIsNone(decorator.args)
+
+
+class TestPyClassSUL(unittest.TestCase):
+ def test_pre_creates_a_fresh_instance(self):
+ sul = PyClassSUL(Counter)
+ sul.pre()
+ first_instance = sul.sul
+ sul.step(FunctionDecorator(Counter.increment))
+ sul.pre()
+ second_instance = sul.sul
+
+ self.assertIsNot(first_instance, second_instance)
+ self.assertEqual(second_instance.value, 0)
+
+ def test_step_calls_method_without_args(self):
+ sul = PyClassSUL(Counter)
+ sul.pre()
+ result = sul.step(FunctionDecorator(Counter.increment))
+ self.assertEqual(result, 1)
+
+ def test_step_calls_method_with_args(self):
+ sul = PyClassSUL(Counter)
+ sul.pre()
+ result = sul.step(FunctionDecorator(Counter.add, args=5))
+ self.assertEqual(result, 5)
+
+ def test_state_persists_across_steps_within_one_pre(self):
+ sul = PyClassSUL(Counter)
+ sul.pre()
+ sul.step(FunctionDecorator(Counter.increment))
+ sul.step(FunctionDecorator(Counter.increment))
+ result = sul.step(FunctionDecorator(Counter.get))
+ self.assertEqual(result, 2)
+
+ def test_query_runs_sequence_of_function_calls(self):
+ sul = PyClassSUL(Counter)
+ result = sul.query((FunctionDecorator(Counter.increment), FunctionDecorator(Counter.add, args=3)))
+ self.assertEqual(result, [1, 4])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/SULs/test_regex_sul.py b/tests/SULs/test_regex_sul.py
new file mode 100644
index 00000000000..28631c2b59a
--- /dev/null
+++ b/tests/SULs/test_regex_sul.py
@@ -0,0 +1,56 @@
+import unittest
+
+from aalpy.SULs import RegexSUL
+
+
+class TestRegexSUL(unittest.TestCase):
+ def test_appends_trailing_dollar_if_missing(self):
+ sul = RegexSUL('ab*')
+ self.assertEqual(sul.regex, 'ab*$')
+
+ def test_does_not_duplicate_trailing_dollar(self):
+ sul = RegexSUL('ab*$')
+ self.assertEqual(sul.regex, 'ab*$')
+
+ def test_pre_resets_accumulated_string(self):
+ sul = RegexSUL('a$')
+ sul.pre()
+ sul.step('a')
+ self.assertEqual(sul.string, 'a')
+ sul.pre()
+ self.assertEqual(sul.string, '')
+
+ def test_post_resets_accumulated_string(self):
+ sul = RegexSUL('a$')
+ sul.pre()
+ sul.step('a')
+ sul.post()
+ self.assertEqual(sul.string, '')
+
+ def test_step_none_does_not_change_string(self):
+ sul = RegexSUL('a$')
+ sul.pre()
+ sul.step(None)
+ self.assertEqual(sul.string, '')
+
+ def test_exact_match_required(self):
+ sul = RegexSUL('ab')
+ self.assertEqual(sul.query(('a', 'b')), [False, True])
+
+ def test_query_empty_word(self):
+ sul = RegexSUL('a*')
+ # empty string matches 'a*$'
+ self.assertEqual(sul.query(()), [True])
+
+ def test_rejects_once_pattern_cannot_match(self):
+ sul = RegexSUL('ab$')
+ self.assertEqual(sul.query(('b',)), [False])
+
+ def test_accepts_intermediate_and_final_prefixes(self):
+ sul = RegexSUL('a+b')
+ result = sul.query(('a', 'a', 'b'))
+ self.assertEqual(result, [False, False, True])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/SULs/test_tomita_sul.py b/tests/SULs/test_tomita_sul.py
new file mode 100644
index 00000000000..44f234cdcbd
--- /dev/null
+++ b/tests/SULs/test_tomita_sul.py
@@ -0,0 +1,78 @@
+import unittest
+
+from aalpy.SULs import TomitaSUL
+
+
+class TestTomitaSUL(unittest.TestCase):
+ def test_invalid_level_raises(self):
+ with self.assertRaises(AssertionError):
+ TomitaSUL(0)
+
+ def test_pre_resets_accumulated_string(self):
+ sul = TomitaSUL(1)
+ sul.pre()
+ sul.step('0')
+ self.assertEqual(sul.string, '0')
+ sul.pre()
+ self.assertEqual(sul.string, '')
+
+ def test_post_resets_accumulated_string(self):
+ sul = TomitaSUL(1)
+ sul.pre()
+ sul.step('1')
+ sul.post()
+ self.assertEqual(sul.string, '')
+
+ def test_step_none_does_not_corrupt_accumulated_string(self):
+ # regression test: step() used to check `if input` (the builtin, always truthy) instead of
+ # `if letter is not None`, so step(None) appended the literal text "None" to the string
+ sul = TomitaSUL(1)
+ sul.pre()
+ result = sul.step(None)
+ self.assertEqual(sul.string, '')
+ self.assertTrue(result)
+
+ def test_query_empty_word_on_tomita_1(self):
+ # tomita_1 accepts the empty word (no '0's in it)
+ sul = TomitaSUL(1)
+ self.assertEqual(sul.query(()), [True])
+
+ def test_tomita_1_accepts_only_words_without_zero(self):
+ sul = TomitaSUL(1)
+ self.assertEqual(sul.query(('1', '1', '1')), [True, True, True])
+ self.assertEqual(sul.query(('1', '0', '1')), [True, False, False])
+
+ def test_tomita_2_accepts_only_repeated_10(self):
+ sul = TomitaSUL(2)
+ self.assertEqual(sul.query(('1', '0', '1', '0')), [False, True, False, True])
+
+ def test_tomita_3_and_its_negation_are_complementary(self):
+ sul3 = TomitaSUL(3)
+ sul_not3 = TomitaSUL(-3)
+ word = ('1', '0', '0', '1', '1', '0')
+ result3 = sul3.query(word)
+ result_not3 = sul_not3.query(word)
+ self.assertEqual(result3, [not r for r in result_not3])
+
+ def test_tomita_4_rejects_three_consecutive_zeros(self):
+ sul = TomitaSUL(4)
+ self.assertEqual(sul.query(('0', '0', '0')), [True, True, False])
+
+ def test_tomita_5_even_counts_of_both_symbols(self):
+ sul = TomitaSUL(5)
+ self.assertEqual(sul.query(()), [True])
+ self.assertEqual(sul.query(('0', '0', '1', '1')), [False, True, False, True])
+
+ def test_tomita_6_difference_divisible_by_three(self):
+ sul = TomitaSUL(6)
+ self.assertEqual(sul.query(()), [True])
+ self.assertEqual(sul.query(('0', '0', '0')), [False, False, True])
+
+ def test_tomita_7_at_most_one_descent(self):
+ sul = TomitaSUL(7)
+ self.assertEqual(sul.query(('1', '1', '0', '0', '1', '1')), [True, True, True, True, True, True])
+ self.assertEqual(sul.query(('1', '0', '1', '0')), [True, True, True, False])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/utils/test_automaton_generators.py b/tests/utils/test_automaton_generators.py
new file mode 100644
index 00000000000..c0e376b2f87
--- /dev/null
+++ b/tests/utils/test_automaton_generators.py
@@ -0,0 +1,254 @@
+import random
+import unittest
+
+from aalpy.automata import Dfa, MealyMachine, MooreMachine, Mdp, Onfsm, MarkovChain, Sevpa, StochasticMealyMachine
+from aalpy.utils import (
+ generate_random_deterministic_automata,
+ generate_random_dfa,
+ generate_random_mealy_machine,
+ generate_random_moore_machine,
+ generate_random_mdp,
+ generate_random_smm,
+ generate_random_ONFSM,
+ generate_random_markov_chain,
+ generate_random_sevpa,
+)
+
+
+def all_states_reachable(automaton):
+ reached = set()
+ to_visit = [automaton.initial_state]
+ while to_visit:
+ state = to_visit.pop()
+ if state.state_id in reached:
+ continue
+ reached.add(state.state_id)
+ for target in _successors(state):
+ to_visit.append(target)
+ return reached == {s.state_id for s in automaton.states}
+
+
+def _successors(state):
+ transitions = getattr(state, 'transitions', None)
+ if transitions is None:
+ return []
+ if isinstance(transitions, list):
+ return [target for target, _ in transitions]
+ successors = []
+ for value in transitions.values():
+ if value is None:
+ continue
+ if isinstance(value, list):
+ for entry in value:
+ if hasattr(entry, 'target_state'):
+ successors.append(entry.target_state)
+ elif isinstance(entry, tuple):
+ successors.append(entry[0] if not isinstance(entry[0], str) else entry[1])
+ else:
+ successors.append(value)
+ return successors
+
+
+class TestGenerateRandomDeterministicAutomata(unittest.TestCase):
+ def test_dfa_structure(self):
+ random.seed(1)
+ dfa = generate_random_deterministic_automata('dfa', num_states=5, input_alphabet_size=3)
+ self.assertIsInstance(dfa, Dfa)
+ self.assertEqual(len(dfa.states), 5)
+ self.assertEqual(len(dfa.get_input_alphabet()), 3)
+ self.assertTrue(all_states_reachable(dfa))
+ for state in dfa.states:
+ self.assertEqual(set(state.transitions.keys()), set(dfa.get_input_alphabet()))
+
+ def test_mealy_structure(self):
+ random.seed(2)
+ mealy = generate_random_deterministic_automata('mealy', num_states=4, input_alphabet_size=2,
+ output_alphabet_size=3)
+ self.assertIsInstance(mealy, MealyMachine)
+ self.assertEqual(len(mealy.states), 4)
+ for state in mealy.states:
+ for i in mealy.get_input_alphabet():
+ self.assertIn(i, state.output_fun)
+
+ def test_moore_structure(self):
+ random.seed(3)
+ moore = generate_random_deterministic_automata('moore', num_states=4, input_alphabet_size=2,
+ output_alphabet_size=3)
+ self.assertIsInstance(moore, MooreMachine)
+ for state in moore.states:
+ self.assertIsNotNone(state.output)
+
+ def test_invalid_automaton_type_raises(self):
+ with self.assertRaises(AssertionError):
+ generate_random_deterministic_automata('not_a_type', num_states=3, input_alphabet_size=2)
+
+ def test_custom_input_alphabet_wrong_length_raises(self):
+ with self.assertRaises(AssertionError):
+ generate_random_deterministic_automata('dfa', num_states=3, input_alphabet_size=2,
+ custom_input_alphabet=['a', 'b', 'c'])
+
+ def test_custom_output_alphabet_wrong_length_raises(self):
+ with self.assertRaises(AssertionError):
+ generate_random_deterministic_automata('mealy', num_states=3, input_alphabet_size=2,
+ output_alphabet_size=2, custom_output_alphabet=['a'])
+
+ def test_num_accepting_states_respected(self):
+ random.seed(4)
+ dfa = generate_random_deterministic_automata('dfa', num_states=6, input_alphabet_size=2,
+ ensure_minimality=False, num_accepting_states=2)
+ num_accepting = sum(1 for s in dfa.states if s.is_accepting)
+ self.assertEqual(num_accepting, 2)
+
+ def test_ensure_minimality_false_still_valid_automaton(self):
+ random.seed(5)
+ dfa = generate_random_deterministic_automata('dfa', num_states=4, input_alphabet_size=2,
+ ensure_minimality=False)
+ self.assertEqual(len(dfa.states), 4)
+ self.assertTrue(all_states_reachable(dfa))
+
+ def test_ensure_minimality_true_produces_minimal_automaton(self):
+ random.seed(6)
+ for _ in range(5):
+ dfa = generate_random_deterministic_automata('dfa', num_states=4, input_alphabet_size=2)
+ self.assertTrue(dfa.is_minimal())
+ self.assertEqual(dfa.size, 4)
+
+
+class TestGenerateRandomDfaMealyMoore(unittest.TestCase):
+ def test_generate_random_dfa(self):
+ random.seed(7)
+ dfa = generate_random_dfa(num_states=5, alphabet=['a', 'b'], num_accepting_states=2)
+ self.assertEqual(len(dfa.states), 5)
+ self.assertEqual(set(dfa.get_input_alphabet()), {'a', 'b'})
+
+ def test_generate_random_dfa_too_many_accepting_states_is_corrected(self):
+ random.seed(8)
+ dfa = generate_random_dfa(num_states=4, alphabet=['a', 'b'], num_accepting_states=10,
+ ensure_minimality=False)
+ num_accepting = sum(1 for s in dfa.states if s.is_accepting)
+ self.assertLessEqual(num_accepting, 4)
+
+ def test_generate_random_mealy_machine(self):
+ random.seed(9)
+ mealy = generate_random_mealy_machine(num_states=4, input_alphabet=['a', 'b'],
+ output_alphabet=['x', 'y', 'z'])
+ self.assertIsInstance(mealy, MealyMachine)
+ self.assertEqual(set(mealy.get_input_alphabet()), {'a', 'b'})
+
+ def test_generate_random_moore_machine(self):
+ random.seed(10)
+ moore = generate_random_moore_machine(num_states=4, input_alphabet=['a', 'b'],
+ output_alphabet=['x', 'y', 'z'])
+ self.assertIsInstance(moore, MooreMachine)
+ self.assertEqual(set(moore.get_input_alphabet()), {'a', 'b'})
+
+ def test_compute_prefixes_flag(self):
+ random.seed(11)
+ mealy = generate_random_mealy_machine(num_states=3, input_alphabet=['a', 'b'],
+ output_alphabet=['x', 'y'], compute_prefixes=True)
+ for state in mealy.states:
+ self.assertIsNotNone(state.prefix)
+
+
+class TestGenerateRandomMdpSmm(unittest.TestCase):
+ def test_generate_random_mdp_structure(self):
+ random.seed(12)
+ mdp = generate_random_mdp(num_states=5, input_size=2, output_size=3)
+ self.assertIsInstance(mdp, Mdp)
+ self.assertEqual(len(mdp.states), 5)
+ for state in mdp.states:
+ for i in mdp.get_input_alphabet():
+ probs = [p for _, p in state.transitions[i]]
+ self.assertAlmostEqual(sum(probs), 1.0, places=5)
+
+ def test_generate_random_mdp_deterministic_labeling(self):
+ random.seed(13)
+ mdp = generate_random_mdp(num_states=6, input_size=2, output_size=4)
+ for state in mdp.states:
+ for i in mdp.get_input_alphabet():
+ outputs = [s.output for s, _ in state.transitions[i]]
+ self.assertEqual(len(outputs), len(set(outputs)))
+
+ def test_generate_random_smm_structure(self):
+ random.seed(14)
+ smm = generate_random_smm(num_states=5, input_size=2, output_size=3)
+ self.assertIsInstance(smm, StochasticMealyMachine)
+ for state in smm.states:
+ for i in smm.get_input_alphabet():
+ probs = [p for _, _, p in state.transitions[i]]
+ self.assertAlmostEqual(sum(probs), 1.0, places=5)
+
+
+class TestGenerateRandomOnfsm(unittest.TestCase):
+ def test_structure(self):
+ random.seed(15)
+ onfsm = generate_random_ONFSM(num_states=5, num_inputs=3, num_outputs=3)
+ self.assertIsInstance(onfsm, Onfsm)
+ self.assertEqual(len(onfsm.states), 5)
+ for state in onfsm.states:
+ self.assertEqual(set(state.transitions.keys()), set(onfsm.get_input_alphabet()))
+
+ def test_multiple_out_prob_zero_gives_single_output_per_transition(self):
+ random.seed(16)
+ onfsm = generate_random_ONFSM(num_states=4, num_inputs=2, num_outputs=3, multiple_out_prob=0.0)
+ for state in onfsm.states:
+ for i in state.transitions:
+ self.assertEqual(len(state.transitions[i]), 1)
+
+
+class TestGenerateRandomMarkovChain(unittest.TestCase):
+ def test_structure(self):
+ random.seed(17)
+ mc = generate_random_markov_chain(num_states=5)
+ self.assertIsInstance(mc, MarkovChain)
+ self.assertEqual(len(mc.states), 5)
+
+ def test_too_few_states_raises(self):
+ with self.assertRaises(AssertionError):
+ generate_random_markov_chain(num_states=2)
+
+ def test_transition_probabilities_sum_to_one(self):
+ random.seed(18)
+ mc = generate_random_markov_chain(num_states=6)
+ for state in mc.states[:-1]:
+ probs = [p for _, p in state.transitions]
+ self.assertAlmostEqual(sum(probs), 1.0, places=5)
+
+ def test_last_state_has_no_outgoing_transitions(self):
+ random.seed(19)
+ mc = generate_random_markov_chain(num_states=5)
+ self.assertEqual(mc.states[-1].transitions, [])
+
+
+class TestGenerateRandomSevpa(unittest.TestCase):
+ def test_structure(self):
+ random.seed(20)
+ sevpa = generate_random_sevpa(num_states=5, internal_alphabet_size=2, call_alphabet_size=2,
+ return_alphabet_size=2, acceptance_prob=0.5, return_transition_prob=0.5)
+ self.assertIsInstance(sevpa, Sevpa)
+ self.assertEqual(len(sevpa.states), 5)
+
+ def test_all_internal_letters_defined_for_all_states(self):
+ # regression test: the completeness pass used to check `transitions[letter] is None`, but
+ # transitions is a defaultdict(list), so missing letters resolve to `[]`, never `None` -
+ # meaning some states could be left without a transition on some internal letters.
+ random.seed(21)
+ sevpa = generate_random_sevpa(num_states=4, internal_alphabet_size=2, call_alphabet_size=2,
+ return_alphabet_size=2, acceptance_prob=0.3, return_transition_prob=0.3)
+ for state in sevpa.states:
+ for internal_letter in sevpa.input_alphabet.internal_alphabet:
+ self.assertIsNotNone(state.transitions[internal_letter])
+ self.assertGreater(len(state.transitions[internal_letter]), 0)
+
+ def test_return_transitions_defined_for_all_stack_states(self):
+ random.seed(22)
+ sevpa = generate_random_sevpa(num_states=3, internal_alphabet_size=1, call_alphabet_size=2,
+ return_alphabet_size=2, acceptance_prob=0.5, return_transition_prob=0.7)
+ for state in sevpa.states:
+ for return_letter in sevpa.input_alphabet.return_alphabet:
+ self.assertIsNotNone(state.transitions[return_letter])
+ self.assertGreaterEqual(len(state.transitions[return_letter]), len(sevpa.states) * len(sevpa.input_alphabet.call_alphabet))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/utils/test_char_set.py b/tests/utils/test_char_set.py
new file mode 100644
index 00000000000..91faa8db563
--- /dev/null
+++ b/tests/utils/test_char_set.py
@@ -0,0 +1,73 @@
+import unittest
+from pathlib import Path
+
+from aalpy.utils import get_Angluin_dfa, load_automaton_from_file
+from aalpy.utils.HelperFunctions import all_suffixes
+
+DOT_MODELS_DIR = Path(__file__).resolve().parent.parent.parent / 'DotModels'
+
+
+class TestCharSet(unittest.TestCase):
+
+ def get_test_automata(self):
+ return {"angluin_dfa": get_Angluin_dfa(),
+ "angluin_mealy": load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Mealy.dot',
+ automaton_type='mealy'),
+ "angluin_moore": load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Moore.dot',
+ automaton_type='moore'),
+ "mqtt": load_automaton_from_file(DOT_MODELS_DIR / 'MQTT/emqtt__two_client_will_retain.dot',
+ automaton_type='mealy'),
+ "openssl": load_automaton_from_file(DOT_MODELS_DIR / 'TLS/OpenSSL_1.0.2_server_regular.dot',
+ automaton_type='mealy'),
+ "tcp_server": load_automaton_from_file(DOT_MODELS_DIR / 'TCP/TCP_Linux_Client.dot',
+ automaton_type='mealy')}
+
+ def test_can_differentiate(self):
+ automata = self.get_test_automata()
+ for init_with_alphabet in [True, False]:
+ for (online_suffix_closure, split_all_blocks) in [(False, False), (False, True), (True, False),
+ (True, True)]:
+ for test_aut_name in automata:
+ print(f"Testing with {test_aut_name}")
+ test_aut = automata[test_aut_name]
+ char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \
+ if init_with_alphabet else None
+ if "dfa" in test_aut_name or "moore" in test_aut_name:
+ char_set_init = [] if char_set_init is None else char_set_init
+ char_set_init.append(())
+ char_set = test_aut.compute_characterization_set(char_set_init=char_set_init,
+ online_suffix_closure=online_suffix_closure,
+ split_all_blocks=split_all_blocks)
+ print(f"Char. set {char_set}")
+ all_responses = set()
+ for s in test_aut.states:
+ responses_from_s = []
+ for c in char_set:
+ responses_from_s.append(tuple(test_aut.compute_output_seq(s, c)))
+ all_responses.add(tuple(responses_from_s))
+
+ # every state must have a unique response to the whole characterization set
+ assert len(all_responses) == len(test_aut.states)
+
+ def test_suffix_closed(self):
+ automata = self.get_test_automata()
+ for init_with_alphabet in [True, False]:
+ online_suffix_closure = True
+ for split_all_blocks in [True, False]:
+ for test_aut_name in automata:
+ print(f"Testing with {test_aut_name}")
+ test_aut = automata[test_aut_name]
+ char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \
+ if init_with_alphabet else None
+ if "dfa" in test_aut_name or "moore" in test_aut_name:
+ char_set_init = [] if char_set_init is None else char_set_init
+ char_set_init.append(())
+ char_set = test_aut.compute_characterization_set(char_set_init=char_set_init,
+ online_suffix_closure=online_suffix_closure,
+ split_all_blocks=split_all_blocks)
+ print(f"Char. set {char_set}")
+ for s in char_set:
+ for suffix in all_suffixes(s):
+ if suffix not in char_set:
+ print(suffix)
+ assert suffix in char_set
diff --git a/tests/utils/test_data_handler.py b/tests/utils/test_data_handler.py
new file mode 100644
index 00000000000..0a15bf942b1
--- /dev/null
+++ b/tests/utils/test_data_handler.py
@@ -0,0 +1,83 @@
+import tempfile
+import unittest
+from pathlib import Path
+
+from aalpy.utils.DataHandler import CharacterTokenizer, DelimiterTokenizer, IODelimiterTokenizer, try_int
+
+
+class TestTryInt(unittest.TestCase):
+ def test_digit_string_converted(self):
+ self.assertEqual(try_int('42'), 42)
+ self.assertIsInstance(try_int('42'), int)
+
+ def test_non_digit_string_unchanged(self):
+ self.assertEqual(try_int('abc'), 'abc')
+
+ def test_negative_number_not_converted(self):
+ self.assertEqual(try_int('-1'), '-1')
+
+
+class TestCharacterTokenizer(unittest.TestCase):
+ def test_tokenizes_each_line_into_characters(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('abc\nde\n')
+ data = CharacterTokenizer().tokenize_data(str(path))
+ self.assertEqual(data, [['a', 'b', 'c'], ['d', 'e']])
+
+ def test_empty_file_produces_empty_list(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('')
+ data = CharacterTokenizer().tokenize_data(str(path))
+ self.assertEqual(data, [])
+
+
+class TestDelimiterTokenizer(unittest.TestCase):
+ def test_default_comma_delimiter(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('a,b,c\nx,y\n')
+ data = DelimiterTokenizer().tokenize_data(str(path))
+ self.assertEqual(data, [['a', 'b', 'c'], ['x', 'y']])
+
+ def test_custom_delimiter(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('a;b;c\n')
+ data = DelimiterTokenizer().tokenize_data(str(path), delimiter=';')
+ self.assertEqual(data, [['a', 'b', 'c']])
+
+
+class TestIODelimiterTokenizer(unittest.TestCase):
+ def test_tokenizes_initial_output_and_io_pairs(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('out0,i1/o1,i2/o2\n')
+ data = IODelimiterTokenizer().tokenize_data(str(path))
+ self.assertEqual(data, [['out0', ('i1', 'o1'), ('i2', 'o2')]])
+
+ def test_digit_inputs_outputs_converted_to_int(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('0,1/2,3/4\n')
+ data = IODelimiterTokenizer().tokenize_data(str(path))
+ self.assertEqual(data, [['0', (1, 2), (3, 4)]])
+
+ def test_custom_delimiters(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('out0|i1:o1|i2:o2\n')
+ data = IODelimiterTokenizer().tokenize_data(str(path), io_delimiter=':', word_delimiter='|')
+ self.assertEqual(data, [['out0', ('i1', 'o1'), ('i2', 'o2')]])
+
+ def test_malformed_io_word_exits(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'data.txt'
+ path.write_text('out0,badword\n')
+ with self.assertRaises(SystemExit):
+ IODelimiterTokenizer().tokenize_data(str(path))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/utils/test_file_handler_extra.py b/tests/utils/test_file_handler_extra.py
new file mode 100644
index 00000000000..b8a5ef7aaa0
--- /dev/null
+++ b/tests/utils/test_file_handler_extra.py
@@ -0,0 +1,154 @@
+import tempfile
+import time
+import unittest
+from pathlib import Path
+
+from aalpy.automata import NDMooreMachine, NDMooreState
+from aalpy.utils import save_automaton_to_file, load_automaton_from_file, visualize_automaton
+from aalpy.utils.BenchmarkSULs import get_Angluin_dfa, get_benchmark_ONFSM, get_faulty_coffee_machine_SMM
+from aalpy.utils.BenchmarkSevpaModels import sevpa_for_L1
+from aalpy.utils.BenchmarkVpaModels import vpa_L1
+
+
+def ndmoore_machine():
+ q0 = NDMooreState('q0', output='x')
+ q1 = NDMooreState('q1', output='y')
+ q0.transitions['a'].append(q0)
+ q0.transitions['a'].append(q1)
+ q1.transitions['a'].append(q1)
+ return NDMooreMachine(q0, [q0, q1])
+
+
+class TestSaveAutomatonToFileEdgeCases(unittest.TestCase):
+ def test_unsupported_file_type_raises(self):
+ dfa = get_Angluin_dfa()
+ with self.assertRaises(AssertionError):
+ save_automaton_to_file(dfa, path='irrelevant', file_type='bogus')
+
+ def test_string_file_type_returns_dot_string_without_writing_file(self):
+ dfa = get_Angluin_dfa()
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'model'
+ result = save_automaton_to_file(dfa, path=str(path), file_type='string')
+ self.assertIsInstance(result, str)
+ self.assertIn('digraph', result)
+ self.assertFalse(path.with_suffix('.string').exists())
+
+ def test_string_output_contains_all_state_ids(self):
+ dfa = get_Angluin_dfa()
+ result = save_automaton_to_file(dfa, file_type='string')
+ for state in dfa.states:
+ self.assertIn(state.state_id, result)
+
+
+class TestSaveLoadAdditionalAutomatonTypes(unittest.TestCase):
+ def test_ndmoore_roundtrip(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'ndmoore'
+ model = ndmoore_machine()
+ model.save(str(path))
+ loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='ndmoore')
+ self.assertEqual(loaded.size, model.size)
+ self.assertEqual(set(loaded.get_input_alphabet()), set(model.get_input_alphabet()))
+ for state in loaded.states:
+ self.assertIsNotNone(state.output)
+
+ def test_sevpa_roundtrip_preserves_alphabet_and_acceptance(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'sevpa'
+ model = sevpa_for_L1()
+ model.save(str(path))
+ loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='sevpa')
+ self.assertEqual(loaded.size, model.size)
+ self.assertEqual(set(model.get_input_alphabet().get_merged_alphabet()),
+ set(loaded.get_input_alphabet().get_merged_alphabet()))
+ self.assertEqual({s.state_id for s in model.states if s.is_accepting},
+ {s.state_id for s in loaded.states if s.is_accepting})
+
+ def test_vpa_roundtrip_skips_error_sink_state(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'vpa'
+ model = vpa_L1()
+ non_sink_states = [s for s in model.states if s.state_id != 'ErrorSinkState']
+ model.save(str(path))
+ loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='vpa')
+ self.assertEqual(loaded.size, len(non_sink_states))
+ self.assertEqual(set(model.get_input_alphabet().get_merged_alphabet()),
+ set(loaded.get_input_alphabet().get_merged_alphabet()))
+
+ def test_onfsm_roundtrip_preserves_transitions_shape(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'onfsm'
+ model = get_benchmark_ONFSM()
+ model.save(str(path))
+ loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='onfsm')
+ self.assertEqual(loaded.size, model.size)
+ for state in loaded.states:
+ for i in loaded.get_input_alphabet():
+ self.assertGreater(len(state.transitions[i]), 0)
+
+ def test_smm_roundtrip_preserves_probabilities(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'smm'
+ model = get_faulty_coffee_machine_SMM()
+ model.save(str(path))
+ loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='smm')
+ for orig_state, loaded_state in zip(model.states, loaded.states):
+ for i in model.get_input_alphabet():
+ orig_probs = sorted(p for _, _, p in orig_state.transitions[i])
+ loaded_probs = sorted(p for _, _, p in loaded_state.transitions[i])
+ self.assertEqual(orig_probs, loaded_probs)
+
+
+class TestLoadMalformedDotFile(unittest.TestCase):
+ def test_missing_start_state_raises(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'bad.dot'
+ path.write_text('digraph g {\nq0 [label="q0"];\nq0 -> q0 [label="a"];\n}\n')
+ with self.assertRaises(AssertionError):
+ load_automaton_from_file(path, automaton_type='dfa')
+
+ def test_start_state_pointing_to_undefined_state_raises(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'bad.dot'
+ path.write_text(
+ 'digraph g {\n'
+ '__start0 [label="", shape=none];\n'
+ '__start0 -> qX;\n'
+ 'q0 [label="q0"];\n'
+ 'q0 -> q0 [label="a"];\n'
+ '}\n'
+ )
+ with self.assertRaises(AssertionError):
+ load_automaton_from_file(path, automaton_type='dfa')
+
+
+class TestVisualizeAutomaton(unittest.TestCase):
+ def test_visualize_writes_file_in_background_thread(self):
+ dfa = get_Angluin_dfa()
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'viz'
+ visualize_automaton(dfa, path=str(path), file_type='dot')
+ deadline = time.time() + 5
+ while time.time() < deadline and not path.with_suffix('.dot').exists():
+ time.sleep(0.05)
+ self.assertTrue(path.with_suffix('.dot').exists())
+
+ def test_visualize_large_automaton_prints_warning(self):
+ # automaton_types with >= 25 states trigger an extra warning print; just make sure it does
+ # not raise and still produces the file.
+ from aalpy.utils import generate_random_dfa
+ import random
+ random.seed(42)
+ dfa = generate_random_dfa(num_states=26, alphabet=['a', 'b'], ensure_minimality=False)
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / 'viz_large'
+ visualize_automaton(dfa, path=str(path), file_type='dot')
+ deadline = time.time() + 5
+ while time.time() < deadline and not path.with_suffix('.dot').exists():
+ time.sleep(0.05)
+ self.assertTrue(path.with_suffix('.dot').exists())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/utils/test_file_operations.py b/tests/utils/test_file_operations.py
new file mode 100644
index 00000000000..1e753de8077
--- /dev/null
+++ b/tests/utils/test_file_operations.py
@@ -0,0 +1,86 @@
+import tempfile
+import unittest
+from pathlib import Path
+
+from aalpy.utils import generate_random_markov_chain, load_automaton_from_file
+from aalpy.utils.BenchmarkSULs import get_Angluin_dfa, get_benchmark_ONFSM, get_faulty_coffee_machine_SMM, \
+ get_small_pomdp
+from aalpy.utils.ModelChecking import bisimilar
+
+DOT_MODELS_DIR = Path(__file__).resolve().parent.parent.parent / 'DotModels'
+
+
+def dump_and_load(model, automaton_type, directory):
+ path = directory / 'model'
+ model.save(str(path))
+ return load_automaton_from_file(path.with_suffix('.dot'), automaton_type=automaton_type)
+
+
+class TestFileHandler(unittest.TestCase):
+
+ def test_saving_loading_roundtrip_preserves_alphabet_and_char_set(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tmp_dir = Path(tmp_dir)
+ type_model_pairs = [
+ ("dfa", get_Angluin_dfa()),
+ ("mealy", load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Mealy.dot', automaton_type='mealy')),
+ ("moore", load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Moore.dot', automaton_type='moore')),
+ ("onfsm", get_benchmark_ONFSM()),
+ ("mdp", get_small_pomdp()),
+ ("mdp", load_automaton_from_file(DOT_MODELS_DIR / 'MDPs/first_grid.dot', automaton_type='mdp')),
+ ("smm", get_faulty_coffee_machine_SMM()),
+ ("mc", generate_random_markov_chain(num_states=10)),
+ ]
+
+ for automaton_type, model in type_model_pairs:
+ loaded_model = dump_and_load(model, automaton_type, tmp_dir)
+ loaded_model_twice = dump_and_load(loaded_model, automaton_type, tmp_dir)
+
+ if automaton_type != 'mc':
+ self.assertEqual(set(model.get_input_alphabet()), set(loaded_model.get_input_alphabet()),
+ msg=f'{automaton_type}: alphabet changed after one dump/load cycle')
+ self.assertEqual(set(model.get_input_alphabet()), set(loaded_model_twice.get_input_alphabet()),
+ msg=f'{automaton_type}: alphabet changed after two dump/load cycles')
+ else:
+ self.assertEqual(model.size, loaded_model.size)
+ self.assertEqual(model.size, loaded_model_twice.size)
+
+ if automaton_type in {'dfa', 'moore', 'mealy'}:
+ self.assertEqual(model.compute_characterization_set(),
+ loaded_model_twice.compute_characterization_set(),
+ msg=f'{automaton_type}: characterization set changed after dump/load cycles')
+
+ def test_dfa_dump_load_dump_load_is_bisimilar_to_original(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tmp_dir = Path(tmp_dir)
+ model = get_Angluin_dfa()
+ loaded_once = dump_and_load(model, 'dfa', tmp_dir)
+ loaded_twice = dump_and_load(loaded_once, 'dfa', tmp_dir)
+
+ self.assertTrue(bisimilar(model, loaded_once))
+ self.assertTrue(bisimilar(model, loaded_twice))
+ self.assertTrue(bisimilar(loaded_once, loaded_twice))
+
+ def test_mealy_dump_load_dump_load_is_bisimilar_to_original(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tmp_dir = Path(tmp_dir)
+ model = load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Mealy.dot', automaton_type='mealy')
+ loaded_once = dump_and_load(model, 'mealy', tmp_dir)
+ loaded_twice = dump_and_load(loaded_once, 'mealy', tmp_dir)
+
+ self.assertTrue(bisimilar(model, loaded_once))
+ self.assertTrue(bisimilar(model, loaded_twice))
+
+ def test_moore_dump_load_dump_load_is_bisimilar_to_original(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tmp_dir = Path(tmp_dir)
+ model = load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Moore.dot', automaton_type='moore')
+ loaded_once = dump_and_load(model, 'moore', tmp_dir)
+ loaded_twice = dump_and_load(loaded_once, 'moore', tmp_dir)
+
+ self.assertTrue(bisimilar(model, loaded_once))
+ self.assertTrue(bisimilar(model, loaded_twice))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/utils/test_helper_functions.py b/tests/utils/test_helper_functions.py
new file mode 100644
index 00000000000..d34971c3571
--- /dev/null
+++ b/tests/utils/test_helper_functions.py
@@ -0,0 +1,353 @@
+import random
+import unittest
+
+from aalpy.automata import (Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState, Mdp, MdpState,
+ MarkovChain, Vpa, VpaState, VpaAlphabet, VpaTransition)
+from aalpy.utils.HelperFunctions import (
+ extend_set,
+ all_prefixes,
+ all_suffixes,
+ random_string_generator,
+ is_suffix_of,
+ get_cex_prefixes,
+ make_input_complete,
+ convert_i_o_traces_for_RPNI,
+ is_balanced,
+ product_with_possible_empty_iterable,
+ dfa_from_moore,
+ mc_from_mdp,
+ mc_format_to_mdp,
+ generate_input_output_data_from_automata,
+ generate_input_output_data_from_vpa,
+)
+
+
+def parity_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+def incomplete_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0}
+ return Dfa(q0, [q0, q1])
+
+
+def incomplete_moore():
+ q0 = MooreState('q0', output='x')
+ q1 = MooreState('q1', output='y')
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0}
+ return MooreMachine(q0, [q0, q1])
+
+
+def incomplete_mealy():
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'o1', 'b': 'o2'}
+ q1.transitions = {'a': q0}
+ q1.output_fun = {'a': 'o2'}
+ return MealyMachine(q0, [q0, q1])
+
+
+class TestExtendSet(unittest.TestCase):
+ def test_adds_only_new_elements(self):
+ base = [1, 2, 3]
+ added = extend_set(base, [2, 3, 4, 5])
+ self.assertEqual(added, [4, 5])
+ self.assertEqual(base, [1, 2, 3, 4, 5])
+
+ def test_no_new_elements(self):
+ base = [1, 2]
+ added = extend_set(base, [1, 2])
+ self.assertEqual(added, [])
+ self.assertEqual(base, [1, 2])
+
+
+class TestAllPrefixesSuffixes(unittest.TestCase):
+ def test_all_prefixes(self):
+ self.assertEqual(all_prefixes(['a', 'b', 'c']), [('a',), ('a', 'b'), ('a', 'b', 'c')])
+
+ def test_all_prefixes_empty(self):
+ self.assertEqual(all_prefixes([]), [])
+
+ def test_all_suffixes(self):
+ self.assertEqual(all_suffixes(['a', 'b', 'c']), [('c',), ('b', 'c'), ('a', 'b', 'c')])
+
+ def test_all_suffixes_empty(self):
+ self.assertEqual(all_suffixes([]), [])
+
+
+class TestRandomStringGenerator(unittest.TestCase):
+ def test_default_length(self):
+ s = random_string_generator()
+ self.assertEqual(len(s), 10)
+
+ def test_custom_length_and_chars(self):
+ s = random_string_generator(size=5, chars='x')
+ self.assertEqual(s, 'xxxxx')
+
+ def test_zero_length(self):
+ self.assertEqual(random_string_generator(size=0), '')
+
+
+class TestIsSuffixOf(unittest.TestCase):
+ def test_true_case(self):
+ self.assertTrue(is_suffix_of(('b', 'c'), ('a', 'b', 'c')))
+
+ def test_false_case(self):
+ self.assertFalse(is_suffix_of(('a', 'c'), ('a', 'b', 'c')))
+
+ def test_suffix_longer_than_trace(self):
+ self.assertFalse(is_suffix_of(('a', 'b', 'c'), ('b', 'c')))
+
+ def test_empty_suffix_always_matches(self):
+ # regression test: trace[-len(suffix):] with len(suffix) == 0 slices with `-0`, which Python
+ # treats the same as `0` (the whole trace) rather than an empty slice - so an empty suffix
+ # used to compare the entire trace against () instead of always matching.
+ self.assertTrue(is_suffix_of((), ('a', 'b')))
+
+ def test_empty_suffix_of_empty_trace(self):
+ self.assertTrue(is_suffix_of((), ()))
+
+
+class TestGetCexPrefixes(unittest.TestCase):
+ def test_mdp_prefixes(self):
+ cex = ('i1', 'o1', 'i2', 'o2')
+ prefixes = get_cex_prefixes(cex, 'mdp')
+ self.assertEqual(prefixes, [('i1',), ('i1', 'o1', 'i2')])
+
+ def test_smm_prefixes(self):
+ cex = ('i1', 'o1', 'i2', 'o2')
+ prefixes = get_cex_prefixes(cex, 'smm')
+ self.assertEqual(prefixes, [(), ('i1', 'o1'), ('i1', 'o1', 'i2', 'o2')])
+
+
+class TestMakeInputComplete(unittest.TestCase):
+ def test_already_complete_dfa_returned_unchanged(self):
+ dfa = parity_dfa()
+ result = make_input_complete(dfa)
+ self.assertIs(result, dfa)
+
+ def test_dfa_self_loop(self):
+ dfa = incomplete_dfa()
+ make_input_complete(dfa, missing_transition_go_to='self_loop')
+ for state in dfa.states:
+ self.assertEqual(set(state.transitions.keys()), {'a', 'b'})
+ self.assertIs(dfa.states[1].transitions['b'], dfa.states[1])
+
+ def test_dfa_sink_state(self):
+ dfa = incomplete_dfa()
+ make_input_complete(dfa, missing_transition_go_to='sink_state')
+ sink_states = [s for s in dfa.states if s.state_id == 'sink']
+ self.assertEqual(len(sink_states), 1)
+ sink = sink_states[0]
+ self.assertFalse(sink.is_accepting)
+ q1 = next(s for s in dfa.states if s.state_id == 'q1')
+ self.assertIs(q1.transitions['b'], sink)
+
+ def test_moore_self_loop(self):
+ moore = incomplete_moore()
+ make_input_complete(moore, missing_transition_go_to='self_loop')
+ for state in moore.states:
+ self.assertEqual(set(state.transitions.keys()), {'a', 'b'})
+
+ def test_mealy_self_loop_epsilon_output(self):
+ mealy = incomplete_mealy()
+ make_input_complete(mealy, missing_transition_go_to='self_loop')
+ self.assertIs(mealy.states[1].transitions['b'], mealy.states[1])
+ self.assertEqual(mealy.states[1].output_fun['b'], 'epsilon')
+
+ def test_invalid_missing_transition_strategy_raises(self):
+ dfa = incomplete_dfa()
+ with self.assertRaises(AssertionError):
+ make_input_complete(dfa, missing_transition_go_to='not_a_strategy')
+
+
+class TestConvertIOTracesForRPNI(unittest.TestCase):
+ def test_mealy_conversion(self):
+ sequences = [[(1, 'a'), (2, 'b'), (3, 'c')], [(6, 'e'), (4, 'e'), (3, 'c')]]
+ result = convert_i_o_traces_for_RPNI(sequences, automaton_type='mealy')
+ self.assertEqual(result, [
+ ((1,), 'a'), ((1, 2), 'b'), ((1, 2, 3), 'c'),
+ ((6,), 'e'), ((6, 4), 'e'), ((6, 4, 3), 'c'),
+ ])
+
+ def test_dfa_conversion_includes_initial_output(self):
+ sequences = [[True, (1, False)]]
+ result = convert_i_o_traces_for_RPNI(sequences, automaton_type='dfa')
+ self.assertEqual(result, [((), True), ((1,), False)])
+
+ def test_invalid_automaton_type_raises(self):
+ with self.assertRaises(ValueError):
+ convert_i_o_traces_for_RPNI([[(1, 'a')]], automaton_type='bogus')
+
+ def test_deduplicates_repeated_prefixes(self):
+ sequences = [[(1, 'a')], [(1, 'a')]]
+ result = convert_i_o_traces_for_RPNI(sequences, automaton_type='mealy')
+ self.assertEqual(result, [((1,), 'a')])
+
+
+class TestIsBalanced(unittest.TestCase):
+ def alphabet(self):
+ return VpaAlphabet(internal_alphabet=['i'], call_alphabet=['c'], return_alphabet=['r'])
+
+ def test_balanced_sequence(self):
+ self.assertTrue(is_balanced(['c', 'i', 'r'], self.alphabet()))
+
+ def test_empty_sequence_is_balanced(self):
+ self.assertTrue(is_balanced([], self.alphabet()))
+
+ def test_unbalanced_more_returns_than_calls(self):
+ self.assertFalse(is_balanced(['r'], self.alphabet()))
+
+ def test_unbalanced_unclosed_call(self):
+ self.assertFalse(is_balanced(['c', 'c', 'r'], self.alphabet()))
+
+
+class TestProductWithPossibleEmptyIterable(unittest.TestCase):
+ def test_all_nonempty_behaves_like_normal_product(self):
+ result = list(product_with_possible_empty_iterable([1, 2], ['a']))
+ self.assertEqual(result, [(1, 'a'), (2, 'a')])
+
+ def test_one_empty_iterable_ignored(self):
+ result = list(product_with_possible_empty_iterable([1, 2], []))
+ self.assertEqual(result, [(1,), (2,)])
+
+ def test_all_empty_iterables_gives_empty_tuple(self):
+ result = list(product_with_possible_empty_iterable([], []))
+ self.assertEqual(result, [()])
+
+
+class TestDfaFromMoore(unittest.TestCase):
+ def test_boolean_output_moore_converts(self):
+ q0 = MooreState('q0', output=True)
+ q1 = MooreState('q1', output=False)
+ q0.transitions = {'a': q1}
+ q1.transitions = {'a': q0}
+ moore = MooreMachine(q0, [q0, q1])
+
+ dfa = dfa_from_moore(moore)
+ self.assertIsInstance(dfa, Dfa)
+ self.assertTrue(dfa.initial_state.is_accepting)
+ self.assertFalse(dfa.initial_state.transitions['a'].is_accepting)
+
+ def test_none_output_treated_as_non_accepting(self):
+ q0 = MooreState('q0', output=None)
+ q0.transitions = {'a': q0}
+ moore = MooreMachine(q0, [q0])
+ dfa = dfa_from_moore(moore)
+ self.assertFalse(dfa.initial_state.is_accepting)
+
+ def test_non_boolean_output_raises(self):
+ q0 = MooreState('q0', output='not_boolean')
+ q0.transitions = {'a': q0}
+ moore = MooreMachine(q0, [q0])
+ with self.assertRaises(ValueError):
+ dfa_from_moore(moore)
+
+
+class TestMcFromMdp(unittest.TestCase):
+ def make_mdp(self):
+ s0 = MdpState('s0', output='o0')
+ s1 = MdpState('s1', output='o1')
+ s0.transitions['i'].append((s1, 1.0))
+ s1.transitions['i'].append((s0, 1.0))
+ return Mdp(s0, [s0, s1])
+
+ def test_single_input_conversion(self):
+ mdp = self.make_mdp()
+ mc = mc_from_mdp(mdp)
+ self.assertIsInstance(mc, MarkovChain)
+ self.assertEqual(mc.initial_state.state_id, 's0')
+ self.assertEqual(mc.initial_state.transitions, [(mc.states[1] if mc.states[1].state_id == 's1' else mc.states[0], 1.0)])
+
+ def test_explicit_input_symbol(self):
+ mdp = self.make_mdp()
+ mc = mc_from_mdp(mdp, input_symbol='i')
+ self.assertIsInstance(mc, MarkovChain)
+
+ def test_multiple_inputs_without_symbol_raises(self):
+ s0 = MdpState('s0', output='o0')
+ s1 = MdpState('s1', output='o1')
+ s0.transitions['i1'].append((s1, 1.0))
+ s0.transitions['i2'].append((s1, 1.0))
+ s1.transitions['i1'].append((s0, 1.0))
+ s1.transitions['i2'].append((s0, 1.0))
+ mdp = Mdp(s0, [s0, s1])
+ with self.assertRaises(ValueError):
+ mc_from_mdp(mdp)
+
+
+class TestMcFormatToMdp(unittest.TestCase):
+ def test_wraps_non_initial_elements_with_input_label(self):
+ data = [['out0', 'a', 'b']]
+ result = mc_format_to_mdp(data)
+ self.assertEqual(result, [['out0', ('Input', 'a'), ('Input', 'b')]])
+
+ def test_empty_data(self):
+ self.assertEqual(mc_format_to_mdp([]), [])
+
+
+class TestGenerateInputOutputDataFromAutomata(unittest.TestCase):
+ def test_io_traces_format(self):
+ random.seed(1)
+ dfa = parity_dfa()
+ data = generate_input_output_data_from_automata(dfa, num_sequences=5, min_seq_len=1, max_seq_len=3)
+ self.assertEqual(len(data), 5)
+ for trace in data:
+ for i, o in trace:
+ self.assertIn(i, {'a', 'b'})
+ self.assertIn(o, {True, False})
+
+ def test_labeled_sequences_format(self):
+ random.seed(2)
+ dfa = parity_dfa()
+ data = generate_input_output_data_from_automata(dfa, num_sequences=5, min_seq_len=1, max_seq_len=3,
+ sequance_type='labeled_sequences')
+ self.assertEqual(len(data), 5)
+ for seq, label in data:
+ self.assertIsInstance(seq, list)
+ self.assertIn(label, {True, False})
+
+ def test_invalid_sequence_type_raises(self):
+ dfa = parity_dfa()
+ with self.assertRaises(AssertionError):
+ generate_input_output_data_from_automata(dfa, num_sequences=1, sequance_type='bogus')
+
+
+class TestGenerateInputOutputDataFromVpa(unittest.TestCase):
+ def make_simple_vpa(self):
+ q0 = VpaState('q0', is_accepting=True)
+ q1 = VpaState('q1', is_accepting=False)
+ q0.transitions['i'].append(VpaTransition(q0, q0, 'i', None, None))
+ q0.transitions['c'].append(VpaTransition(q0, q1, 'c', 'push', 'c'))
+ q1.transitions['r'].append(VpaTransition(q1, q0, 'r', 'pop', 'c'))
+ return Vpa(q0, [q0, q1])
+
+ def test_generates_at_least_requested_number_of_sequences(self):
+ # the generation loop only re-checks its stopping condition after a full inner sequence of
+ # length max_seq_len is generated, so it can slightly overshoot num_sequences.
+ random.seed(3)
+ vpa = self.make_simple_vpa()
+ data = generate_input_output_data_from_vpa(vpa, num_sequences=10, max_seq_len=4)
+ self.assertGreaterEqual(len(data), 10)
+ self.assertLessEqual(len(data), 10 + 4)
+ for seq, output in data:
+ self.assertIsInstance(seq, tuple)
+
+ def test_respects_max_attempts(self):
+ random.seed(4)
+ vpa = self.make_simple_vpa()
+ data = generate_input_output_data_from_vpa(vpa, num_sequences=1000, max_seq_len=2, max_attempts=5)
+ self.assertLessEqual(len(data), 1000)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/utils/test_model_checking.py b/tests/utils/test_model_checking.py
new file mode 100644
index 00000000000..7f68f8793d5
--- /dev/null
+++ b/tests/utils/test_model_checking.py
@@ -0,0 +1,160 @@
+import unittest
+
+from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState, Onfsm, OnfsmState
+from aalpy.utils.BenchmarkSULs import get_Angluin_dfa
+from aalpy.utils.ModelChecking import bisimilar, compare_automata
+
+
+def parity_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+def non_bisimilar_dfa():
+ # accepts everything (always accepting), differs from parity_dfa on 'a'
+ q0 = DfaState('q0', is_accepting=True)
+ q0.transitions = {'a': q0, 'b': q0}
+ return Dfa(q0, [q0])
+
+
+def renamed_parity_dfa():
+ r0 = DfaState('r0', is_accepting=True)
+ r1 = DfaState('r1', is_accepting=False)
+ r0.transitions = {'a': r1, 'b': r0}
+ r1.transitions = {'a': r0, 'b': r1}
+ return Dfa(r0, [r0, r1])
+
+
+def parity_moore():
+ q0 = MooreState('q0', output=True)
+ q1 = MooreState('q1', output=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return MooreMachine(q0, [q0, q1])
+
+
+def parity_mealy():
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'x', 'b': 'y'}
+ q1.transitions = {'a': q0, 'b': q1}
+ q1.output_fun = {'a': 'y', 'b': 'x'}
+ return MealyMachine(q0, [q0, q1])
+
+
+class TestBisimilar(unittest.TestCase):
+ def test_identical_automaton_is_bisimilar_to_itself_copy(self):
+ dfa = parity_dfa()
+ self.assertTrue(bisimilar(dfa, dfa))
+
+ def test_isomorphic_automata_with_different_state_ids_are_bisimilar(self):
+ dfa1 = parity_dfa()
+ dfa2 = renamed_parity_dfa()
+ self.assertTrue(bisimilar(dfa1, dfa2))
+
+ def test_non_bisimilar_dfa_returns_false(self):
+ dfa1 = parity_dfa()
+ dfa2 = non_bisimilar_dfa()
+ self.assertFalse(bisimilar(dfa1, dfa2))
+
+ def test_non_bisimilar_dfa_counterexample(self):
+ dfa1 = parity_dfa()
+ dfa2 = non_bisimilar_dfa()
+ cex = bisimilar(dfa1, dfa2, return_cex=True)
+ self.assertIsNotNone(cex)
+ dfa1.reset_to_initial()
+ dfa2.reset_to_initial()
+ out1 = [dfa1.step(i) for i in cex]
+ out2 = [dfa2.step(i) for i in cex]
+ self.assertNotEqual(out1[-1], out2[-1])
+
+ def test_bisimilar_automata_return_cex_gives_none(self):
+ dfa1 = parity_dfa()
+ dfa2 = renamed_parity_dfa()
+ self.assertIsNone(bisimilar(dfa1, dfa2, return_cex=True))
+
+ def test_moore_bisimilarity(self):
+ moore1 = parity_moore()
+ moore2 = parity_moore()
+ self.assertTrue(bisimilar(moore1, moore2))
+
+ def test_mealy_bisimilarity(self):
+ mealy1 = parity_mealy()
+ mealy2 = parity_mealy()
+ self.assertTrue(bisimilar(mealy1, mealy2))
+
+ def test_mealy_output_mismatch_detected(self):
+ mealy1 = parity_mealy()
+ mealy2 = parity_mealy()
+ mealy2.initial_state.output_fun['a'] = 'different'
+ self.assertFalse(bisimilar(mealy1, mealy2))
+
+ def test_different_automaton_types_raises(self):
+ dfa = parity_dfa()
+ moore = parity_moore()
+ with self.assertRaises(ValueError):
+ bisimilar(dfa, moore)
+
+ def test_unsupported_automaton_type_raises(self):
+ onfsm = Onfsm(OnfsmState('q0'), [OnfsmState('q0')])
+ with self.assertRaises(NotImplementedError):
+ bisimilar(onfsm, onfsm)
+
+ def test_different_enabled_inputs_not_bisimilar(self):
+ q0 = DfaState('q0', is_accepting=True)
+ q0.transitions = {'a': q0}
+ dfa1 = Dfa(q0, [q0])
+
+ r0 = DfaState('r0', is_accepting=True)
+ r0.transitions = {'a': r0, 'b': r0}
+ dfa2 = Dfa(r0, [r0])
+
+ self.assertFalse(bisimilar(dfa1, dfa2))
+
+
+class TestCompareAutomata(unittest.TestCase):
+ def test_identical_automata_no_counterexamples(self):
+ dfa1 = get_Angluin_dfa()
+ dfa2 = get_Angluin_dfa()
+ cexs = compare_automata(dfa1, dfa2)
+ self.assertEqual(cexs, [])
+
+ def test_same_object_no_counterexamples(self):
+ dfa = get_Angluin_dfa()
+ cexs = compare_automata(dfa, dfa)
+ self.assertEqual(cexs, [])
+
+ def test_different_alphabets_raises(self):
+ dfa1 = get_Angluin_dfa()
+ q0 = DfaState('q0', is_accepting=True)
+ q0.transitions = {'x': q0, 'y': q0}
+ dfa2 = Dfa(q0, [q0])
+ with self.assertRaises(AssertionError):
+ compare_automata(dfa1, dfa2)
+
+ def test_finds_counterexample_for_differing_automata(self):
+ dfa1 = parity_dfa()
+ dfa2 = non_bisimilar_dfa()
+ cexs = compare_automata(dfa1, dfa2, num_cex=5)
+ self.assertGreater(len(cexs), 0)
+ for cex in cexs:
+ dfa1.reset_to_initial()
+ dfa2.reset_to_initial()
+ out1 = [dfa1.step(i) for i in cex][-1]
+ out2 = [dfa2.step(i) for i in cex][-1]
+ self.assertNotEqual(out1, out2)
+
+ def test_counterexamples_sorted_by_length(self):
+ dfa1 = parity_dfa()
+ dfa2 = non_bisimilar_dfa()
+ cexs = compare_automata(dfa1, dfa2, num_cex=8)
+ lengths = [len(c) for c in cexs]
+ self.assertEqual(lengths, sorted(lengths))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/utils/test_sampling.py b/tests/utils/test_sampling.py
new file mode 100644
index 00000000000..5659eed3953
--- /dev/null
+++ b/tests/utils/test_sampling.py
@@ -0,0 +1,157 @@
+import random
+import unittest
+
+from aalpy.automata import Dfa, DfaState, MealyMachine, MealyState
+from aalpy.utils.Sampling import (
+ get_io_traces,
+ get_labeled_sequences,
+ get_data_from_input_sequence,
+ sample_with_length_limits,
+ sample_with_term_prob,
+ get_complete_sample,
+)
+
+
+def parity_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ dfa = Dfa(q0, [q0, q1])
+ dfa.compute_prefixes()
+ return dfa
+
+
+def parity_mealy():
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'x', 'b': 'y'}
+ q1.transitions = {'a': q0, 'b': q1}
+ q1.output_fun = {'a': 'y', 'b': 'x'}
+ mealy = MealyMachine(q0, [q0, q1])
+ mealy.compute_prefixes()
+ return mealy
+
+
+class TestGetIoTraces(unittest.TestCase):
+ def test_dfa_traces_prefixed_with_initial_output(self):
+ dfa = parity_dfa()
+ traces = get_io_traces(dfa, [['a', 'a'], ['b']])
+ self.assertEqual(traces[0][0], True)
+ self.assertEqual(traces[0][1:], [('a', False), ('a', True)])
+ self.assertEqual(traces[1], [True, ('b', True)])
+
+ def test_mealy_traces_not_prefixed(self):
+ mealy = parity_mealy()
+ traces = get_io_traces(mealy, [['a', 'b']])
+ self.assertEqual(traces[0], [('a', 'x'), ('b', 'x')])
+
+ def test_empty_input_trace(self):
+ # regression test: Dfa.execute_sequence (like MooreMachine's) returns a bare output value
+ # rather than a list for an empty sequence, which used to make zip(input_trace, output_trace)
+ # crash with "'bool' object is not iterable".
+ dfa = parity_dfa()
+ traces = get_io_traces(dfa, [[]])
+ self.assertEqual(traces[0], [True])
+
+
+class TestGetLabeledSequences(unittest.TestCase):
+ def test_dfa_labels(self):
+ dfa = parity_dfa()
+ data = get_labeled_sequences(dfa, [['a'], ['a', 'a']])
+ self.assertEqual(data, [(['a'], False), (['a', 'a'], True)])
+
+ def test_dfa_empty_sequence_returns_initial_output(self):
+ dfa = parity_dfa()
+ data = get_labeled_sequences(dfa, [[]])
+ self.assertEqual(data, [([], True)])
+
+ def test_mealy_empty_sequence_raises(self):
+ mealy = parity_mealy()
+ with self.assertRaises(ValueError):
+ get_labeled_sequences(mealy, [[]])
+
+
+class TestGetDataFromInputSequence(unittest.TestCase):
+ def test_io_sequences_format(self):
+ dfa = parity_dfa()
+ data = get_data_from_input_sequence(dfa, [['a']], data_format='io_sequences')
+ self.assertEqual(data, get_io_traces(dfa, [['a']]))
+
+ def test_labeled_sequences_format(self):
+ dfa = parity_dfa()
+ data = get_data_from_input_sequence(dfa, [['a']], data_format='labeled_sequences')
+ self.assertEqual(data, get_labeled_sequences(dfa, [['a']]))
+
+ def test_invalid_format_raises(self):
+ dfa = parity_dfa()
+ with self.assertRaises(ValueError):
+ get_data_from_input_sequence(dfa, [['a']], data_format='bogus')
+
+
+class TestSampleWithLengthLimits(unittest.TestCase):
+ def test_alphabet_argument(self):
+ random.seed(1)
+ samples = sample_with_length_limits(['a', 'b'], nr_samples=10, min_len=2, max_len=5)
+ self.assertEqual(len(samples), 10)
+ for s in samples:
+ self.assertTrue(2 <= len(s) <= 5)
+ self.assertTrue(all(x in {'a', 'b'} for x in s))
+
+ def test_automaton_argument_uses_input_alphabet(self):
+ random.seed(2)
+ dfa = parity_dfa()
+ samples = sample_with_length_limits(dfa, nr_samples=5, min_len=1, max_len=3)
+ for s in samples:
+ self.assertTrue(all(x in {'a', 'b'} for x in s))
+
+ def test_include_outputs_requires_automaton(self):
+ with self.assertRaises(ValueError):
+ sample_with_length_limits(['a', 'b'], nr_samples=1, min_len=1, max_len=1, include_outputs=True)
+
+ def test_include_outputs_with_automaton(self):
+ random.seed(3)
+ dfa = parity_dfa()
+ samples = sample_with_length_limits(dfa, nr_samples=3, min_len=1, max_len=2, include_outputs=True)
+ for trace in samples:
+ self.assertEqual(trace[0], True)
+ for i, o in trace[1:]:
+ self.assertIn(i, {'a', 'b'})
+ self.assertIn(o, {True, False})
+
+
+class TestSampleWithTermProb(unittest.TestCase):
+ def test_generates_requested_number_of_samples(self):
+ random.seed(4)
+ samples = sample_with_term_prob(['a', 'b'], nr_samples=8, term_prob=0.5)
+ self.assertEqual(len(samples), 8)
+ for s in samples:
+ self.assertTrue(all(x in {'a', 'b'} for x in s))
+
+ def test_term_prob_one_gives_empty_sequences(self):
+ random.seed(5)
+ samples = sample_with_term_prob(['a', 'b'], nr_samples=5, term_prob=1.0)
+ for s in samples:
+ self.assertEqual(s, [])
+
+
+class TestGetCompleteSample(unittest.TestCase):
+ def test_complete_sample_covers_alphabet_states_and_char_set(self):
+ dfa = parity_dfa()
+ sample = get_complete_sample(dfa)
+ self.assertTrue(len(sample) > 0)
+ char_set = dfa.compute_characterization_set()
+ for seq in sample:
+ self.assertIsInstance(seq, tuple)
+ self.assertEqual(len(sample), len(dfa.states) * len(char_set) * (len(dfa.get_input_alphabet()) + 1))
+
+ def test_accepts_automaton_arg_via_decorator(self):
+ random.seed(6)
+ dfa = parity_dfa()
+ sample = get_complete_sample(dfa)
+ self.assertTrue(all(isinstance(s, tuple) for s in sample))
+
+
+if __name__ == '__main__':
+ unittest.main()
From 9c56f7fb201cd88c3b7f17676fc01d81dab443f7 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:17:04 +0200
Subject: [PATCH 15/25] Add tests for learning_algs package
---
.../adaptive/test_adaptive_lsharp.py | 134 ++++++++
.../test_adaptive_observation_tree.py | 232 +++++++++++++
.../adaptive/test_state_matching.py | 157 +++++++++
tests/learning_algs/deterministic/test_ads.py | 141 ++++++++
.../deterministic/test_apartness.py | 156 +++++++++
.../deterministic/test_classification_tree.py | 169 ++++++++++
.../test_counter_example_processing.py | 133 ++++++++
.../deterministic/test_learning_algorithms.py | 143 ++++++++
.../deterministic/test_observation_table.py | 315 ++++++++++++++++++
.../deterministic/test_observation_tree.py | 172 ++++++++++
.../test_random_learning_runs.py | 59 ++++
.../test_random_learning_runs_exhaustive.py | 76 +++++
.../deterministic_passive/test_active_rpni.py | 113 +++++++
.../test_classic_rpni.py | 85 +++++
.../deterministic_passive/test_gsm_rpni.py | 116 +++++++
.../deterministic_passive/test_papni.py | 75 +++++
.../test_rpni_helper_functions.py | 198 +++++++++++
.../test_run_rpni_integration.py | 105 ++++++
.../test_generalized_state_merging.py | 192 +++++++++++
.../general_passive/test_gsm_algorithms.py | 196 +++++++++++
.../general_passive/test_gsm_node.py | 294 ++++++++++++++++
.../general_passive/test_instrumentation.py | 79 +++++
.../test_score_functions_gsm.py | 260 +++++++++++++++
.../test_abstracted_onfsm_lstar.py | 67 ++++
...test_abstracted_onfsm_observation_table.py | 149 +++++++++
.../test_non_deterministic_sul_wrapper.py | 121 +++++++
.../non_deterministic/test_onfsm_lstar.py | 64 ++++
.../test_onfsm_lstar_exhaustive.py | 37 ++
.../test_onfsm_observation_table.py | 174 ++++++++++
.../non_deterministic/test_trace_tree.py | 205 ++++++++++++
tests/learning_algs/resetless/test_hW.py | 146 ++++++++
.../resetless/test_hW_exhaustive.py | 160 +++++++++
.../resetless/test_hw_datastructures.py | 130 ++++++++
.../resetless/test_resetless_oracles.py | 279 ++++++++++++++++
.../stochastic/test_difference_checker.py | 140 ++++++++
.../test_sampling_based_observation_table.py | 233 +++++++++++++
.../test_stochastic_cex_processing.py | 93 ++++++
.../test_stochastic_lstar_integration.py | 104 ++++++
...stochastic_lstar_integration_exhaustive.py | 59 ++++
.../stochastic/test_stochastic_teacher.py | 181 ++++++++++
.../test_alergia_integration.py | 206 ++++++++++++
.../test_compatibility_checker.py | 93 ++++++
.../stochastic_passive/test_fpta.py | 125 +++++++
43 files changed, 6366 insertions(+)
create mode 100644 tests/learning_algs/adaptive/test_adaptive_lsharp.py
create mode 100644 tests/learning_algs/adaptive/test_adaptive_observation_tree.py
create mode 100644 tests/learning_algs/adaptive/test_state_matching.py
create mode 100644 tests/learning_algs/deterministic/test_ads.py
create mode 100644 tests/learning_algs/deterministic/test_apartness.py
create mode 100644 tests/learning_algs/deterministic/test_classification_tree.py
create mode 100644 tests/learning_algs/deterministic/test_counter_example_processing.py
create mode 100644 tests/learning_algs/deterministic/test_learning_algorithms.py
create mode 100644 tests/learning_algs/deterministic/test_observation_table.py
create mode 100644 tests/learning_algs/deterministic/test_observation_tree.py
create mode 100644 tests/learning_algs/deterministic/test_random_learning_runs.py
create mode 100644 tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py
create mode 100644 tests/learning_algs/deterministic_passive/test_active_rpni.py
create mode 100644 tests/learning_algs/deterministic_passive/test_classic_rpni.py
create mode 100644 tests/learning_algs/deterministic_passive/test_gsm_rpni.py
create mode 100644 tests/learning_algs/deterministic_passive/test_papni.py
create mode 100644 tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py
create mode 100644 tests/learning_algs/deterministic_passive/test_run_rpni_integration.py
create mode 100644 tests/learning_algs/general_passive/test_generalized_state_merging.py
create mode 100644 tests/learning_algs/general_passive/test_gsm_algorithms.py
create mode 100644 tests/learning_algs/general_passive/test_gsm_node.py
create mode 100644 tests/learning_algs/general_passive/test_instrumentation.py
create mode 100644 tests/learning_algs/general_passive/test_score_functions_gsm.py
create mode 100644 tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py
create mode 100644 tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py
create mode 100644 tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py
create mode 100644 tests/learning_algs/non_deterministic/test_onfsm_lstar.py
create mode 100644 tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py
create mode 100644 tests/learning_algs/non_deterministic/test_onfsm_observation_table.py
create mode 100644 tests/learning_algs/non_deterministic/test_trace_tree.py
create mode 100644 tests/learning_algs/resetless/test_hW.py
create mode 100644 tests/learning_algs/resetless/test_hW_exhaustive.py
create mode 100644 tests/learning_algs/resetless/test_hw_datastructures.py
create mode 100644 tests/learning_algs/resetless/test_resetless_oracles.py
create mode 100644 tests/learning_algs/stochastic/test_difference_checker.py
create mode 100644 tests/learning_algs/stochastic/test_sampling_based_observation_table.py
create mode 100644 tests/learning_algs/stochastic/test_stochastic_cex_processing.py
create mode 100644 tests/learning_algs/stochastic/test_stochastic_lstar_integration.py
create mode 100644 tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py
create mode 100644 tests/learning_algs/stochastic/test_stochastic_teacher.py
create mode 100644 tests/learning_algs/stochastic_passive/test_alergia_integration.py
create mode 100644 tests/learning_algs/stochastic_passive/test_compatibility_checker.py
create mode 100644 tests/learning_algs/stochastic_passive/test_fpta.py
diff --git a/tests/learning_algs/adaptive/test_adaptive_lsharp.py b/tests/learning_algs/adaptive/test_adaptive_lsharp.py
new file mode 100644
index 00000000000..29a8543f299
--- /dev/null
+++ b/tests/learning_algs/adaptive/test_adaptive_lsharp.py
@@ -0,0 +1,134 @@
+import random
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.learning_algs import run_adaptive_Lsharp
+from aalpy.oracles import WpMethodEqOracle
+from aalpy.utils import generate_random_deterministic_automata
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def two_state_mealy():
+ """
+ 2-state Mealy machine over {a, b}.
+ s0 --a/x--> s1 s0 --b/y--> s0
+ s1 --a/y--> s0 s1 --b/x--> s1
+ """
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'a': s1, 'b': s0}
+ s0.output_fun = {'a': 'x', 'b': 'y'}
+ s1.transitions = {'a': s0, 'b': s1}
+ s1.output_fun = {'a': 'y', 'b': 'x'}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm
+
+
+def three_state_mealy_variant():
+ """
+ 3-state variant of two_state_mealy: s1's 'b' output changed and a new state s2 introduced,
+ simulating a slightly changed system that reuses most of the original behaviour.
+ s0 --a/x--> s1 s0 --b/y--> s0
+ s1 --a/y--> s2 s1 --b/z--> s1
+ s2 --a/y--> s0 s2 --b/x--> s2
+ """
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s2 = MealyState('s2')
+ s0.transitions = {'a': s1, 'b': s0}
+ s0.output_fun = {'a': 'x', 'b': 'y'}
+ s1.transitions = {'a': s2, 'b': s1}
+ s1.output_fun = {'a': 'y', 'b': 'z'}
+ s2.transitions = {'a': s0, 'b': s2}
+ s2.output_fun = {'a': 'y', 'b': 'x'}
+ mm = MealyMachine(s0, [s0, s1, s2])
+ mm.compute_prefixes()
+ return mm
+
+
+def learn_with_adaptive(target, references, state_matching='Approximate', rebuilding=True):
+ alphabet = target.get_input_alphabet()
+ sul = AutomatonSUL(target)
+ eq_oracle = WpMethodEqOracle(alphabet, sul, max_number_of_states=len(target.states) + 1)
+ return run_adaptive_Lsharp(alphabet, sul, references, eq_oracle, automaton_type='mealy',
+ extension_rule=None, separation_rule='SepSeq',
+ rebuilding=rebuilding, state_matching=state_matching,
+ print_level=0, return_data=True)
+
+
+class TestColdStartFallback(unittest.TestCase):
+ def test_empty_references_falls_back_to_plain_lsharp(self):
+ target = two_state_mealy()
+ learned, info = learn_with_adaptive(target, [], state_matching=None, rebuilding=True)
+
+ self.assertTrue(bisimilar(learned, target))
+
+
+class TestLearnsWithPerfectReference(unittest.TestCase):
+ def test_learns_minimal_bisimilar_model_with_self_as_reference(self):
+ for matching in (None, 'Total', 'Approximate'):
+ with self.subTest(matching=matching):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ learned, info = learn_with_adaptive(target, [reference], state_matching=matching)
+
+ self.assertTrue(bisimilar(learned, target))
+ self.assertEqual(learned.size, len(target.states))
+
+ def test_random_automata_learned_correctly_with_perfect_reference(self):
+ random.seed(42)
+ for i in range(3):
+ num_states = random.randint(3, 6)
+ target = generate_random_deterministic_automata(
+ 'mealy', num_states=num_states, input_alphabet_size=3, output_alphabet_size=3)
+ reference = target.copy()
+
+ learned, info = learn_with_adaptive(target, [reference], state_matching='Approximate')
+ self.assertTrue(bisimilar(learned, target), f'run {i} with {num_states} states failed')
+
+
+class TestAdaptiveReuseAcrossRelatedSystems(unittest.TestCase):
+ def test_reference_from_one_system_helps_learn_a_related_system(self):
+ # A represents a previously learned/known model; B is a related system whose behaviour has
+ # diverged (state added, an output changed). Adaptive learning should still converge correctly.
+ model_a = two_state_mealy()
+ model_b = three_state_mealy_variant()
+
+ learned_b, info = learn_with_adaptive(model_b, [model_a], state_matching='Approximate')
+
+ self.assertTrue(bisimilar(learned_b, model_b))
+ self.assertFalse(bisimilar(learned_b, model_a))
+
+ def test_reused_reference_reduces_or_matches_plain_learning_effort(self):
+ model_a = two_state_mealy()
+ model_b = three_state_mealy_variant()
+
+ _, info_with_reference = learn_with_adaptive(model_b, [model_a], state_matching='Approximate')
+ _, info_without_reference = learn_with_adaptive(model_b, [], state_matching=None, rebuilding=True)
+
+ self.assertLessEqual(info_with_reference['queries_learning'],
+ info_without_reference['queries_learning'] + 5)
+
+ def test_random_pairs_of_related_automata(self):
+ random.seed(7)
+ for i in range(3):
+ num_states = random.randint(3, 5)
+ base = generate_random_deterministic_automata(
+ 'mealy', num_states=num_states, input_alphabet_size=3, output_alphabet_size=3)
+
+ # simulate a slightly changed system: mutate a single transition's output
+ mutated = base.copy()
+ mutated_state = mutated.states[0]
+ an_input = mutated.get_input_alphabet()[0]
+ current_output = mutated_state.output_fun[an_input]
+ other_outputs = [o for o in {'o1', 'o2', 'o3'} if o != current_output]
+ mutated_state.output_fun[an_input] = other_outputs[0]
+
+ learned, info = learn_with_adaptive(mutated, [base], state_matching='Approximate')
+ self.assertTrue(bisimilar(learned, mutated), f'run {i} with {num_states} states failed')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/adaptive/test_adaptive_observation_tree.py b/tests/learning_algs/adaptive/test_adaptive_observation_tree.py
new file mode 100644
index 00000000000..436986cffa7
--- /dev/null
+++ b/tests/learning_algs/adaptive/test_adaptive_observation_tree.py
@@ -0,0 +1,232 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.learning_algs.adaptive.AdaptiveObservationTree import AdaptiveObservationTree
+
+
+def two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x'):
+ """
+ 2-state Mealy machine over {a, b}.
+ s0 --a/out_a1--> s1 s0 --b/out_b1--> s0
+ s1 --a/out_a2--> s0 s1 --b/out_b2--> s1
+ """
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'a': s1, 'b': s0}
+ s0.output_fun = {'a': out_a1, 'b': out_b1}
+ s1.transitions = {'a': s0, 'b': s1}
+ s1.output_fun = {'a': out_a2, 'b': out_b2}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm
+
+
+def three_state_mealy_extra_letter():
+ """
+ 3-state Mealy machine over {a, b, c}, used as a reference with only partial input overlap tests.
+ s0 --a/x--> s1 s0 --b/y--> s0 s0 --c/z--> s2
+ s1 --a/y--> s0 s1 --b/x--> s1 s1 --c/z--> s2
+ s2 --a/z--> s2 s2 --b/z--> s2 s2 --c/z--> s2
+ """
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s2 = MealyState('s2')
+ s0.transitions = {'a': s1, 'b': s0, 'c': s2}
+ s0.output_fun = {'a': 'x', 'b': 'y', 'c': 'z'}
+ s1.transitions = {'a': s0, 'b': s1, 'c': s2}
+ s1.output_fun = {'a': 'y', 'b': 'x', 'c': 'z'}
+ s2.transitions = {'a': s2, 'b': s2, 'c': s2}
+ s2.output_fun = {'a': 'z', 'b': 'z', 'c': 'z'}
+ mm = MealyMachine(s0, [s0, s1, s2])
+ mm.compute_prefixes()
+ return mm
+
+
+def make_tree(alphabet, sul_automaton, references, rebuilding=True, state_matching='Approximate'):
+ sul = AutomatonSUL(sul_automaton)
+ return AdaptiveObservationTree(alphabet, sul, references, 'mealy', None, 'SepSeq',
+ rebuilding=rebuilding, state_matching=state_matching)
+
+
+class TestNoReferences(unittest.TestCase):
+ def test_empty_references_disables_state_matching(self):
+ target = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [], rebuilding=True, state_matching='Approximate')
+ self.assertIsNone(tree.state_matching)
+ self.assertEqual(tree.rebuild_states, 0)
+ self.assertEqual(tree.matching_states, 0)
+ self.assertEqual(tree.basis, [tree.root])
+
+ def test_reference_with_no_overlapping_inputs_is_dropped(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ # reference only defined over 'c', no overlap with tree alphabet {'a', 'b'}
+ s0 = MealyState('r0')
+ s0.transitions = {'c': s0}
+ s0.output_fun = {'c': 'z'}
+ reference_no_overlap = MealyMachine(s0, [s0])
+ reference_no_overlap.compute_prefixes()
+
+ tree = make_tree(['a', 'b'], target, [reference_no_overlap], rebuilding=False, state_matching=None)
+ self.assertIsNone(tree.state_matching)
+ self.assertEqual(tree.references, [])
+
+
+class TestCombinedModel(unittest.TestCase):
+ def test_combined_model_contains_states_of_all_references(self):
+ target = two_state_mealy()
+ ref1 = two_state_mealy()
+ ref2 = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [ref1, ref2], rebuilding=False, state_matching=None)
+
+ self.assertEqual(len(tree.combined_model.states), 4)
+ state_ids = {s.state_id for s in tree.combined_model.states}
+ self.assertEqual(state_ids, {'s(0,0)', 's(0,1)', 's(1,0)', 's(1,1)'})
+
+ def test_combined_model_preserves_output_function(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None)
+
+ combined_initial = tree.combined_model.states[0]
+ self.assertEqual(combined_initial.output_fun['a'], 'x')
+ self.assertEqual(combined_initial.output_fun['b'], 'y')
+
+ def test_combined_model_restricted_to_shared_alphabet(self):
+ target = two_state_mealy()
+ reference = three_state_mealy_extra_letter()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None)
+
+ for state in tree.combined_model.states:
+ self.assertEqual(set(state.transitions.keys()), {'a', 'b'})
+
+ def test_prefix_map_contains_shortest_access_sequences(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None)
+
+ prefixes = tree.prefixes_map[0]
+ self.assertIn((), prefixes)
+ self.assertEqual(len(prefixes), 2)
+
+ def test_characterization_map_has_entry_per_state(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None)
+
+ self.assertEqual(len(tree.characterization_map), len(tree.combined_model.states))
+ for identifiers in tree.characterization_map.values():
+ self.assertTrue(len(identifiers) >= 1)
+
+ def test_all_references_dropped_yields_no_combined_model(self):
+ target = two_state_mealy()
+ s0 = MealyState('r0')
+ s0.transitions = {'c': s0}
+ s0.output_fun = {'c': 'z'}
+ reference_no_overlap = MealyMachine(s0, [s0])
+ reference_no_overlap.compute_prefixes()
+
+ sul = AutomatonSUL(target)
+ tree = AdaptiveObservationTree(['a', 'b'], sul, [reference_no_overlap], 'mealy', None, 'SepSeq',
+ rebuilding=False, state_matching='Approximate')
+ self.assertIsNone(tree.combined_model)
+ self.assertIsNone(tree.state_matching)
+
+
+class TestFindDistinguishingSeqPartial(unittest.TestCase):
+ def test_finds_witness_between_distinct_states(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None)
+
+ combined = tree.combined_model
+ s0, s1 = combined.states[0], combined.states[1]
+ witness = tree.find_distinguishing_seq_partial(combined, s0, s1, ['a', 'b'])
+ self.assertIsNotNone(witness)
+
+ out_from_s0 = combined.execute_sequence(s0, witness)
+ out_from_s1 = combined.execute_sequence(s1, witness)
+ self.assertNotEqual(out_from_s0, out_from_s1)
+
+ def test_no_witness_for_identical_states(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None)
+
+ combined = tree.combined_model
+ s0 = combined.states[0]
+ witness = tree.find_distinguishing_seq_partial(combined, s0, s0, ['a', 'b'])
+ self.assertIsNone(witness)
+
+
+class TestRebuildObsTree(unittest.TestCase):
+ def test_perfect_reference_match_rebuilds_states(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=True, state_matching=None)
+
+ self.assertGreaterEqual(tree.rebuild_states, 1)
+ self.assertGreater(len(tree.basis), 1)
+
+ def test_no_rebuilding_keeps_root_only_basis(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None)
+
+ self.assertEqual(tree.rebuild_states, 0)
+ self.assertEqual(tree.basis, [tree.root])
+
+ def test_unrelated_reference_does_not_force_bogus_rebuild(self):
+ target = two_state_mealy(out_a1='p', out_b1='q', out_a2='q', out_b2='p')
+ reference = two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x')
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=True, state_matching=None)
+
+ # basis states found via rebuilding must still be pairwise apart in the real SUL
+ for i, s1 in enumerate(tree.basis):
+ for s2 in tree.basis[i + 1:]:
+ from aalpy.learning_algs.deterministic.Apartness import Apartness
+ self.assertTrue(Apartness.states_are_apart(s1, s2, tree))
+
+
+class TestInsertObservationAndMatching(unittest.TestCase):
+ def test_insert_observation_without_matching_extends_tree(self):
+ target = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [], rebuilding=False, state_matching=None)
+ tree.insert_observation(['a', 'b'], ['x', 'x'])
+ self.assertEqual(tree.get_observation(['a', 'b']), ['x', 'x'])
+
+ def test_insert_observation_mismatched_lengths_raises(self):
+ target = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [], rebuilding=False, state_matching=None)
+ with self.assertRaises(ValueError):
+ tree.insert_observation(['a', 'b'], ['x'])
+
+ def test_insert_observation_with_matching_updates_best_match(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching='Approximate')
+
+ self.assertIn(tree.root, tree.state_matcher.matchings)
+ self.assertNotIn(tree.root, tree.state_matcher.best_match)
+
+ tree.insert_observation(['a', 'b'], ['x', 'x'])
+ self.assertIn(tree.root, tree.state_matcher.best_match)
+ best = tree.state_matcher.best_match[tree.root]
+ self.assertEqual(len(best), 1)
+ self.assertEqual(best[0].state_id, 's(0,0)')
+
+ def test_promote_frontier_state_updates_matching_for_new_basis(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching='Approximate')
+
+ tree.insert_observation(['a', 'a'], ['x', 'y'])
+ tree.update_frontier_and_basis()
+
+ for basis_state in tree.basis:
+ self.assertIn(basis_state, tree.state_matcher.best_match)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/adaptive/test_state_matching.py b/tests/learning_algs/adaptive/test_state_matching.py
new file mode 100644
index 00000000000..7febf999811
--- /dev/null
+++ b/tests/learning_algs/adaptive/test_state_matching.py
@@ -0,0 +1,157 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.learning_algs.adaptive.AdaptiveObservationTree import AdaptiveObservationTree
+from aalpy.learning_algs.adaptive.StateMatching import ApproximateStateMatching, TotalStateMatching
+
+
+def two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x'):
+ """
+ 2-state Mealy machine over {a, b}.
+ s0 --a/out_a1--> s1 s0 --b/out_b1--> s0
+ s1 --a/out_a2--> s0 s1 --b/out_b2--> s1
+ """
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'a': s1, 'b': s0}
+ s0.output_fun = {'a': out_a1, 'b': out_b1}
+ s1.transitions = {'a': s0, 'b': s1}
+ s1.output_fun = {'a': out_a2, 'b': out_b2}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm
+
+
+def make_tree(target, reference, matching_type):
+ sul = AutomatonSUL(target)
+ return AdaptiveObservationTree(['a', 'b'], sul, [reference], 'mealy', None, 'SepSeq',
+ rebuilding=False, state_matching=matching_type)
+
+
+class TestPureHelpers(unittest.TestCase):
+ def setUp(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ self.tree = make_tree(target, reference, 'Approximate')
+ self.matcher = self.tree.state_matcher
+
+ def test_is_prefix_of_true(self):
+ self.assertTrue(self.matcher.is_prefix_of(('a',), ('a', 'b')))
+ self.assertTrue(self.matcher.is_prefix_of((), ('a', 'b')))
+ self.assertTrue(self.matcher.is_prefix_of(('a', 'b'), ('a', 'b')))
+
+ def test_is_prefix_of_false(self):
+ self.assertFalse(self.matcher.is_prefix_of(('a', 'b'), ('a',)))
+ self.assertFalse(self.matcher.is_prefix_of(('b',), ('a', 'b')))
+
+ def test_find_longest_common_part_full_match(self):
+ common, rest = self.matcher.find_longest_common_part(('a', 'b'), ('a', 'b'))
+ self.assertEqual(common, ('a', 'b'))
+ self.assertEqual(rest, ())
+
+ def test_find_longest_common_part_partial_match(self):
+ common, rest = self.matcher.find_longest_common_part(('a', 'a'), ('a', 'b', 'a'))
+ self.assertEqual(common, ('a',))
+ self.assertEqual(rest, ('b', 'a'))
+
+ def test_validate_reference_input(self):
+ ref_state = self.tree.combined_model.states[0]
+ self.assertTrue(self.matcher.validate_reference_input((), ref_state))
+ self.assertTrue(self.matcher.validate_reference_input(('a', 'b'), ref_state))
+ self.assertFalse(self.matcher.validate_reference_input(('z',), ref_state))
+
+
+class TestApproximateStateMatchingScoring(unittest.TestCase):
+ def test_perfect_match_scores_one_for_matching_reference_state(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(target, reference, 'Approximate')
+
+ tree.insert_observation(['a', 'b'], ['x', 'x'])
+
+ matcher = tree.state_matcher
+ best = matcher.best_match[tree.root]
+ self.assertEqual(matcher.best_score[tree.root], 1.0)
+ self.assertEqual([s.state_id for s in best], ['s(0,0)'])
+
+ def test_worse_match_still_ranks_below_perfect_one(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(target, reference, 'Approximate')
+
+ tree.insert_observation(['a', 'b'], ['x', 'x'])
+ matcher = tree.state_matcher
+
+ r0, r1 = tree.combined_model.states
+ self.assertEqual(matcher.get_score(tree.root, r0), 1.0)
+ self.assertEqual(matcher.get_score(tree.root, r1), 0.0)
+
+ def test_completely_unrelated_sul_yields_empty_best_match(self):
+ target = two_state_mealy(out_a1='p', out_b1='q', out_a2='q', out_b2='p')
+ reference = two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x')
+ tree = make_tree(target, reference, 'Approximate')
+
+ tree.insert_observation(['a', 'b'], ['p', 'p'])
+ matcher = tree.state_matcher
+
+ self.assertEqual(matcher.best_score[tree.root], 0)
+ self.assertEqual(matcher.best_match[tree.root], [])
+
+ def test_get_score_is_zero_when_no_observations_made(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(target, reference, 'Approximate')
+ matcher = tree.state_matcher
+
+ for ref_state in tree.combined_model.states:
+ self.assertEqual(matcher.get_score(tree.root, ref_state), 0)
+
+
+class TestTotalStateMatchingScoring(unittest.TestCase):
+ def test_perfect_match_scores_one_and_excludes_mismatching_state(self):
+ target = two_state_mealy()
+ reference = two_state_mealy()
+ tree = make_tree(target, reference, 'Total')
+
+ tree.insert_observation(['a', 'b'], ['x', 'x'])
+ matcher = tree.state_matcher
+
+ best = matcher.best_match[tree.root]
+ self.assertEqual([s.state_id for s in best], ['s(0,0)'])
+
+ def test_single_mismatch_zeroes_out_total_match_permanently(self):
+ target = two_state_mealy(out_a1='p', out_b1='q', out_a2='q', out_b2='p')
+ reference = two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x')
+ tree = make_tree(target, reference, 'Total')
+
+ tree.insert_observation(['a'], ['p'])
+ matcher = tree.state_matcher
+
+ for ref_state in tree.combined_model.states:
+ self.assertEqual(matcher.matchings[tree.root][ref_state], 0)
+ self.assertEqual(matcher.best_match[tree.root], [])
+
+ def test_add_entry_basis_dfa_style_output_matching(self):
+ # Total matching for dfa/moore compares the state's own output at initialization time.
+ from aalpy.automata import DfaState, Dfa
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1}
+ q1.transitions = {'a': q0}
+ dfa = Dfa(q0, [q0, q1])
+ dfa.compute_prefixes()
+
+ combined_accept = DfaState('ref_accept', is_accepting=True)
+ combined_reject = DfaState('ref_reject', is_accepting=False)
+ matcher = TotalStateMatching(['a'], None)
+ matcher.combined_model = type('C', (), {'states': [combined_accept, combined_reject]})()
+
+ basis_state = type('B', (), {'output': True})()
+ matcher.add_entry_basis(basis_state, 'dfa')
+ self.assertEqual(matcher.matchings[basis_state][combined_accept], 1)
+ self.assertEqual(matcher.matchings[basis_state][combined_reject], 0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_ads.py b/tests/learning_algs/deterministic/test_ads.py
new file mode 100644
index 00000000000..03473d4c81e
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_ads.py
@@ -0,0 +1,141 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs.deterministic.ADS import Ads
+from aalpy.learning_algs.deterministic.ObservationTree import ObservationTree
+from aalpy.utils import get_Angluin_dfa
+
+
+def dfa_tree():
+ sul = AutomatonSUL(get_Angluin_dfa())
+ return ObservationTree(['a', 'b'], sul, 'dfa', 'ADS', 'ADS')
+
+
+def mealy_tree():
+ sul = AutomatonSUL(get_Angluin_dfa())
+ return ObservationTree(['a', 'b'], sul, 'mealy', 'ADS', 'ADS')
+
+
+class TestAdsSingleNodeBlock(unittest.TestCase):
+ def test_single_node_block_is_a_leaf_with_zero_score(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [True])
+ node_a = tree.get_successor(['a'])
+ ads = Ads(tree, [node_a])
+ self.assertEqual(ads.get_score(), 0)
+ self.assertIsNone(ads.next_input(None))
+
+
+class TestAdsMooreDfa(unittest.TestCase):
+ def test_immediate_own_output_difference_gets_maximal_score(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [True])
+ tree.insert_observation(['b'], [False])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+
+ ads = Ads(tree, [node_a, node_b])
+ self.assertEqual(ads.get_score(), 1.0)
+ # The block splits purely on the states' own (already-known) output; the root of the ADS
+ # uses the tuple() sentinel to represent this (no real input is sent).
+ self.assertEqual(ads.next_input(None), tuple())
+
+ def test_ads_correctly_separates_states_needing_one_step_lookahead(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.insert_observation(['b'], [False])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ tree.insert_observation(['a', 'a'], [False, True])
+ tree.insert_observation(['b', 'a'], [False, False])
+
+ ads = Ads(tree, [node_a, node_b])
+ self.assertEqual(ads.get_score(), 1.0)
+
+ for node, expected_final_output in [(node_a, True), (node_b, False)]:
+ ads.reset_to_root()
+ first_input = ads.next_input(None)
+ self.assertEqual(first_input, tuple())
+ second_input = ads.next_input(node.output)
+ self.assertEqual(second_input, 'a')
+ successor_output = node.get_successor(second_input).output
+ self.assertEqual(successor_output, expected_final_output)
+ self.assertIsNone(ads.next_input(successor_output))
+
+ def test_no_information_available_falls_back_to_a_zero_score_guess(self):
+ """
+ construct_ads_rec's "no successor anywhere in the block" guard (see its docstring/comment)
+ is meant to raise when it truly cannot pick a next input. In practice it never fires: it
+ checks `node.successors is not None`, but successors is always a dict (defaulting to {},
+ never None), so the condition is always true regardless of whether any real successor was
+ recorded. Confirmed here: when node_a/node_b share the same own output and neither has any
+ recorded successor, construction does not raise -- it falls back to picking the first
+ alphabet symbol with score 0 and an empty (non-distinguishing) child map. This fallback
+ actually matters in practice (e.g. for run_Lsharp(..., 'moore', extension_rule='ADS',
+ separation_rule='ADS')): it lets the algorithm still send a real input to the SUL to gather
+ more information instead of hard failing when the tree does not yet have enough data, so
+ this is intentionally left as-is rather than "fixed".
+ """
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [True])
+ tree.insert_observation(['b'], [True])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+
+ ads = Ads(tree, [node_a, node_b])
+ self.assertEqual(ads.get_score(), 0.0)
+ # First step just splits on the (identical) own output via the tuple() sentinel; the actual
+ # zero-score fallback input is one level down, in the subtree for that shared output.
+ self.assertEqual(ads.next_input(None), tuple())
+ self.assertEqual(ads.next_input(True), tree.alphabet[0])
+
+
+class TestAdsMealy(unittest.TestCase):
+ def test_single_input_perfectly_separates_three_state_block(self):
+ tree = mealy_tree()
+ tree.insert_observation(['a'], [1])
+ tree.insert_observation(['b'], [2])
+ tree.insert_observation(['a', 'b'], [1, 9])
+ node_1 = tree.get_successor(['a'])
+ node_2 = tree.get_successor(['b'])
+ node_3 = tree.get_successor(['a', 'b'])
+
+ tree.insert_observation(['a', 'a'], [1, 5])
+ tree.insert_observation(['b', 'a'], [2, 6])
+ tree.insert_observation(['a', 'b', 'a'], [1, 9, 7])
+
+ ads = Ads(tree, [node_1, node_2, node_3])
+ # 3 states perfectly separated by one input reaches the maximal possible score, len(block)-1.
+ self.assertEqual(ads.get_score(), 2.0)
+
+ outputs_seen = set()
+ for node in [node_1, node_2, node_3]:
+ ads.reset_to_root()
+ first_input = ads.next_input(None)
+ self.assertEqual(first_input, 'a')
+ output = node.get_output(first_input)
+ outputs_seen.add(output)
+ self.assertIsNone(ads.next_input(output))
+ # each state produces a distinct output on the chosen input, so the ADS fully identifies them
+ self.assertEqual(len(outputs_seen), 3)
+
+ def test_maximal_base_input_prefers_the_more_discriminating_input(self):
+ tree = mealy_tree()
+ tree.insert_observation(['a'], [1])
+ tree.insert_observation(['b'], [2])
+ node_1 = tree.get_successor(['a'])
+ node_2 = tree.get_successor(['b'])
+ # 'a' does not distinguish the two nodes (same output on 'a'), 'b' does.
+ tree.insert_observation(['a', 'a'], [1, 0])
+ tree.insert_observation(['b', 'a'], [2, 0])
+ tree.insert_observation(['a', 'b'], [1, 3])
+ tree.insert_observation(['b', 'b'], [2, 4])
+
+ ads = Ads(tree, [node_1, node_2])
+ best_input, best_score = ads.maximal_base_input(['a', 'b'], [node_1, node_2], 'mealy')
+ self.assertEqual(best_input, 'b')
+ self.assertEqual(best_score, 1.0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_apartness.py b/tests/learning_algs/deterministic/test_apartness.py
new file mode 100644
index 00000000000..57477abb463
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_apartness.py
@@ -0,0 +1,156 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Dfa, DfaState, MealyState
+from aalpy.learning_algs.deterministic.Apartness import Apartness
+from aalpy.learning_algs.deterministic.ObservationTree import ObservationTree
+from aalpy.utils import get_Angluin_dfa
+
+
+def dfa_tree():
+ sul = AutomatonSUL(get_Angluin_dfa())
+ return ObservationTree(['a', 'b'], sul, 'dfa', None, 'SepSeq')
+
+
+def mealy_tree():
+ sul = AutomatonSUL(get_Angluin_dfa())
+ return ObservationTree(['a', 'b'], sul, 'mealy', None, 'SepSeq')
+
+
+class TestStatesAreApartMoore(unittest.TestCase):
+ def test_identical_node_is_never_apart_from_itself(self):
+ tree = dfa_tree()
+ self.assertFalse(Apartness.states_are_apart(tree.root, tree.root, tree))
+
+ def test_nodes_with_same_output_and_no_observed_difference_are_not_apart(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.insert_observation(['b'], [False])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree))
+
+ def test_nodes_apart_via_immediate_output_difference(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.insert_observation(['b'], [True])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree))
+ self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), [])
+
+ def test_nodes_apart_via_one_step_successor_difference(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.insert_observation(['b'], [False])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree))
+
+ # Both have output False, but diverge one step further via input 'a'.
+ tree.insert_observation(['a', 'a'], [False, True])
+ tree.insert_observation(['b', 'a'], [False, False])
+ self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree))
+ self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), ['a'])
+
+ def test_nodes_apart_only_via_a_longer_suffix(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [True])
+ tree.insert_observation(['b'], [True])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+
+ tree.insert_observation(['a', 'a'], [True, True])
+ tree.insert_observation(['b', 'a'], [True, True])
+ self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree))
+
+ # Only diverge two steps deep, via 'a', 'a'.
+ tree.insert_observation(['a', 'a', 'a'], [True, True, True])
+ tree.insert_observation(['b', 'a', 'a'], [True, True, False])
+ self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree))
+ self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), ['a', 'a'])
+
+ def test_no_witness_when_not_apart(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.insert_observation(['b'], [False])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ self.assertIsNone(Apartness.compute_witness(node_a, node_b, tree))
+
+
+class TestStatesAreApartMealy(unittest.TestCase):
+ def test_apart_via_differing_output_on_same_input(self):
+ tree = mealy_tree()
+ tree.insert_observation(['a'], [1])
+ tree.insert_observation(['b'], [0])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ # node_a/node_b themselves have no outgoing observations yet, so they only become apart once
+ # their own successors disagree on some input.
+ tree.insert_observation(['a', 'a'], [1, 5])
+ tree.insert_observation(['b', 'a'], [0, 7])
+ self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree))
+ self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), ['a'])
+
+ def test_not_apart_when_undetermined_inputs_produce_no_conflict(self):
+ tree = mealy_tree()
+ tree.insert_observation(['a'], [1])
+ tree.insert_observation(['b'], [1])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ # Neither node has an observed 'a'/'b'-successor yet, so no output pair can conflict.
+ self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree))
+
+
+class TestComputeWitnessInTreeAndHypothesisStates(unittest.TestCase):
+ def test_dfa_root_matching_hypothesis_state_returns_none(self):
+ tree = dfa_tree()
+ h0 = DfaState('h0', is_accepting=True)
+ h0.transitions = {'a': h0, 'b': h0}
+ self.assertIsNone(Apartness.compute_witness_in_tree_and_hypothesis_states(tree, tree.root, h0))
+
+ def test_dfa_immediate_output_mismatch_returns_empty_witness(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ node_a = tree.get_successor(['a'])
+ h0 = DfaState('h0', is_accepting=True)
+ h0.transitions = {'a': h0, 'b': h0}
+ # node_a.output is False but h0.is_accepting is True: they already differ at this node.
+ witness = Apartness.compute_witness_in_tree_and_hypothesis_states(tree, node_a, h0)
+ self.assertEqual(witness, [])
+
+ def test_dfa_mismatch_found_one_step_deeper(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [True])
+ tree.insert_observation(['a', 'b'], [True, False])
+ node_a = tree.get_successor(['a'])
+ h0 = DfaState('h0', is_accepting=True)
+ h0.transitions = {'a': h0, 'b': h0}
+ # node_a agrees with h0 (both True); its 'b'-successor (False) disagrees with h0 (True).
+ witness = Apartness.compute_witness_in_tree_and_hypothesis_states(tree, node_a, h0)
+ self.assertEqual(witness, ['b'])
+
+ def test_mealy_output_mismatch_detected(self):
+ tree = mealy_tree()
+ m0 = MealyState('m0')
+ m0.transitions = {'a': m0, 'b': m0}
+ m0.output_fun = {'a': 1, 'b': 0}
+ # root's observed 'a'-output (0) disagrees with m0.output_fun['a'] (1); the witness is the
+ # single input that exposes the mismatch, i.e. the transfer sequence to root's 'a'-successor.
+ tree.insert_observation(['a'], [0])
+ witness = Apartness.compute_witness_in_tree_and_hypothesis_states(tree, tree.root, m0)
+ self.assertEqual(witness, ['a'])
+
+ def test_mealy_no_mismatch_returns_none(self):
+ tree = mealy_tree()
+ m0 = MealyState('m0')
+ m0.transitions = {'a': m0, 'b': m0}
+ m0.output_fun = {'a': 1, 'b': 0}
+ tree.insert_observation(['a'], [1])
+ tree.insert_observation(['b'], [0])
+ self.assertIsNone(Apartness.compute_witness_in_tree_and_hypothesis_states(tree, tree.root, m0))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_classification_tree.py b/tests/learning_algs/deterministic/test_classification_tree.py
new file mode 100644
index 00000000000..68b33f18a86
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_classification_tree.py
@@ -0,0 +1,169 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs.deterministic.ClassificationTree import ClassificationTree, CTInternalNode, CTLeafNode
+from aalpy.utils import get_Angluin_dfa
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def find_counterexample(sul, hypothesis, max_length=6):
+ import itertools
+ alphabet = ['a', 'b']
+ for length in range(1, max_length + 1):
+ for word in itertools.product(alphabet, repeat=length):
+ hypothesis.reset_to_initial()
+ hyp_out = hypothesis.execute_sequence(hypothesis.initial_state, list(word))
+ sul_out = sul.query(word)
+ if hyp_out[-1] != sul_out[-1]:
+ return tuple(word)
+ raise AssertionError('no counterexample found within max_length')
+
+
+class TestClassificationTreeInitDfa(unittest.TestCase):
+ def test_root_has_two_leaves_for_initial_state_and_cex(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ self.assertIsInstance(ct.root, CTInternalNode)
+ self.assertEqual(ct.root.distinguishing_string, tuple())
+ self.assertIn(tuple(), ct.leaf_nodes)
+ self.assertIn(('a',), ct.leaf_nodes)
+ self.assertEqual(len(ct.root.children), 2)
+
+ def test_initial_hypothesis_has_two_states(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ hyp = ct.update_hypothesis()
+ self.assertEqual(len(hyp.states), 2)
+ prefixes = {s.prefix for s in hyp.states}
+ self.assertEqual(prefixes, {tuple(), ('a',)})
+
+
+class TestClassificationTreeInitMealy(unittest.TestCase):
+ def test_root_distinguishing_string_is_last_symbol_of_cex(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'mealy', cex=('a', 'a'))
+ self.assertEqual(ct.root.distinguishing_string, ('a',))
+
+ def test_initial_hypothesis_output_fun_matches_sul(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'mealy', cex=('a', 'a'))
+ hyp = ct.update_hypothesis()
+ self.assertEqual(len(hyp.states), 2)
+ initial = next(s for s in hyp.states if s.prefix == tuple())
+ self.assertEqual(initial.output_fun['a'], sul.query(('a',))[-1])
+ self.assertEqual(initial.output_fun['b'], sul.query(('b',))[-1])
+
+
+class TestSift(unittest.TestCase):
+ def test_sift_routes_known_access_strings_to_themselves(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ ct.update_hypothesis()
+ self.assertEqual(ct._sift(tuple()).access_string, tuple())
+ self.assertEqual(ct._sift(('a',)).access_string, ('a',))
+
+ def test_sift_routes_equivalent_word_to_existing_leaf(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ ct.update_hypothesis()
+ # 'b' (-> q2, non-accepting) is indistinguishable from ('a',) (-> q1, non-accepting) under
+ # the root's current distinguishing string (the empty word), so it sifts to the same leaf.
+ self.assertEqual(ct._sift(('b',)).access_string, ('a',))
+
+ def test_sift_of_equivalent_word_does_not_grow_the_tree(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ ct.update_hypothesis()
+ leaves_before = set(ct.leaf_nodes)
+ # ('b', 'b') reaches q0 (accepting) again via a different path than any known access string,
+ # but since the tree currently only distinguishes accepting/non-accepting, sifting it does
+ # not introduce a new leaf -- it just routes to the existing empty-word-labelled leaf.
+ leaf = ct._sift(('b', 'b'))
+ self.assertIsInstance(leaf, CTLeafNode)
+ self.assertEqual(leaf.access_string, tuple())
+ self.assertEqual(set(ct.leaf_nodes), leaves_before)
+
+ def test_sift_creates_a_new_leaf_the_first_time_a_branch_is_taken(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ ct.update_hypothesis()
+
+ # Replace the tree with a hand-built root discriminated by suffix ('a',) that only has the
+ # True branch populated, to directly control and observe _sift's leaf-creation branch.
+ new_root = CTInternalNode(distinguishing_string=('a',), parent=None, path_to_node=None)
+ leaf_true = CTLeafNode(access_string=tuple(), parent=new_root, path_to_node=True)
+ new_root.children[True] = leaf_true
+ ct.root = new_root
+ ct.leaf_nodes = {tuple(): leaf_true}
+
+ # query(('b',) + ('a',)) = query(('b', 'a')): q0 -b-> q2 -a-> q3, output False -- a key not
+ # yet present among new_root's children, so sifting 'b' must create and register a new leaf.
+ leaf = ct._sift(('b',))
+ self.assertEqual(leaf.access_string, ('b',))
+ self.assertEqual(leaf.path_to_node, False)
+ self.assertIn(('b',), ct.leaf_nodes)
+ self.assertIs(new_root.children[False], leaf)
+
+
+class TestProcessCounterexampleRs(unittest.TestCase):
+ def test_process_counterexample_grows_the_tree_and_hypothesis(self):
+ dfa = get_Angluin_dfa()
+ sul = AutomatonSUL(dfa)
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ hyp = ct.update_hypothesis()
+ self.assertEqual(len(hyp.states), 2)
+
+ cex = find_counterexample(sul, hyp)
+ ct.process_counterexample(cex, hyp, 'rs')
+ hyp = ct.update_hypothesis()
+ self.assertEqual(len(hyp.states), 3)
+
+ def test_learning_converges_to_ground_truth_after_enough_counterexamples(self):
+ dfa = get_Angluin_dfa()
+ sul = AutomatonSUL(dfa)
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ hyp = ct.update_hypothesis()
+
+ for _ in range(10):
+ try:
+ cex = find_counterexample(sul, hyp)
+ except AssertionError:
+ break
+ ct.process_counterexample(cex, hyp, 'rs')
+ hyp = ct.update_hypothesis()
+
+ self.assertEqual(len(hyp.states), len(dfa.states))
+ self.assertTrue(bisimilar(dfa, hyp))
+
+
+class TestLegacyUpdateMethod(unittest.TestCase):
+ """
+ ClassificationTree.update() (distinct from process_counterexample()) implements a second,
+ unparametrized counterexample-processing strategy. It is not called anywhere in KV.py (which
+ always uses process_counterexample), but it is still part of the class's public surface, so it
+ is exercised directly here.
+ """
+
+ def test_update_grows_the_tree_and_hypothesis(self):
+ dfa = get_Angluin_dfa()
+ sul = AutomatonSUL(dfa)
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ hyp = ct.update_hypothesis()
+ self.assertEqual(len(hyp.states), 2)
+
+ cex = find_counterexample(sul, hyp)
+ ct.update(cex, hyp)
+ hyp = ct.update_hypothesis()
+ self.assertEqual(len(hyp.states), 3)
+
+
+class TestLeastCommonAncestor(unittest.TestCase):
+ def test_lca_of_siblings_is_the_root(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',))
+ ct.update_hypothesis()
+ self.assertEqual(ct._least_common_ancestor(tuple(), ('a',)), ct.root.distinguishing_string)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_counter_example_processing.py b/tests/learning_algs/deterministic/test_counter_example_processing.py
new file mode 100644
index 00000000000..40ddd4af674
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_counter_example_processing.py
@@ -0,0 +1,133 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Dfa, DfaState
+from aalpy.learning_algs.deterministic.CounterExampleProcessing import (
+ counterexample_successfully_processed, exponential_cex_processing, linear_cex_processing,
+ longest_prefix_cex_processing, rs_cex_processing)
+from aalpy.utils import get_Angluin_dfa
+
+
+def wrong_two_state_hypothesis():
+ """
+ A deliberately wrong 2-state hypothesis for get_Angluin_dfa() that conflates the ground truth's
+ q2 and q3 states with q1 (both non-accepting), used to produce genuine counterexamples for the
+ cex-processing strategies below.
+ h0 (accepting, prefix=()) --a--> h1 --b--> h0
+ h1 (non-accepting, prefix=('a',)) --a--> h0 --b--> h1
+ """
+ h0 = DfaState('h0', is_accepting=True)
+ h1 = DfaState('h1', is_accepting=False)
+ h0.transitions = {'a': h1, 'b': h0}
+ h1.transitions = {'a': h0, 'b': h1}
+ h0.prefix = tuple()
+ h1.prefix = ('a',)
+ return Dfa(h0, [h0, h1])
+
+
+class TestCounterexampleSuccessfullyProcessed(unittest.TestCase):
+ def test_returns_false_while_still_a_counterexample(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ self.assertFalse(counterexample_successfully_processed(sul, ('b', 'b', 'b'), hyp))
+
+ def test_returns_true_once_outputs_agree(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ # 'a' alone: dfa q0 --a--> q1 (False); hyp h0 --a--> h1 (False). Outputs already agree.
+ self.assertTrue(counterexample_successfully_processed(sul, ('a',), hyp))
+
+
+class TestLongestPrefixCexProcessing(unittest.TestCase):
+ def test_trims_longest_matching_prefix_and_returns_suffixes(self):
+ prefixes = [tuple(), ('a',), ('b',)]
+ cex = ('a', 'b', 'a')
+ # Longest matching prefix of cex among `prefixes` is ('a',); remaining trimmed suffix is
+ # ('b', 'a'), whose own (reversed) suffixes are [('b', 'a'), ('a',)].
+ result = longest_prefix_cex_processing(list(prefixes), cex, closedness='suffix')
+ self.assertEqual(result, [('b', 'a'), ('a',)])
+
+ def test_prefix_closedness_returns_prefixes_of_trimmed_suffix(self):
+ prefixes = [tuple(), ('a',), ('b',)]
+ cex = ('a', 'b', 'a')
+ result = longest_prefix_cex_processing(list(prefixes), cex, closedness='prefix')
+ self.assertEqual(result, [('b', 'a'), ('b',)])
+
+ def test_no_matching_prefix_uses_whole_counterexample(self):
+ prefixes = [('c',)]
+ cex = ('a', 'b')
+ result = longest_prefix_cex_processing(list(prefixes), cex, closedness='suffix')
+ self.assertEqual(result, [('a', 'b'), ('b',)])
+
+
+class TestRsCexProcessing(unittest.TestCase):
+ def test_finds_single_distinguishing_suffix(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ cex = ('b', 'b', 'b')
+ suffix = rs_cex_processing(sul, cex, hyp, suffix_closedness=False)
+ self.assertEqual(suffix, [('b', 'b')])
+
+ def test_suffix_closedness_adds_all_suffixes(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ cex = ('b', 'b', 'b')
+ suffixes = rs_cex_processing(sul, cex, hyp, suffix_closedness=True)
+ self.assertEqual(suffixes, [('b', 'b'), ('b',)])
+
+ def test_result_is_a_genuine_suffix_of_the_counterexample(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ cex = ('a', 'b', 'a')
+ suffix = rs_cex_processing(sul, cex, hyp, suffix_closedness=False)[0]
+ self.assertEqual(cex[len(cex) - len(suffix):], suffix)
+
+
+class TestLinearCexProcessing(unittest.TestCase):
+ def test_forward_and_backward_scans_can_find_different_witnesses(self):
+ """
+ Regression test: linear_cex_processing used to unconditionally overwrite its `direction`
+ parameter with 'fwd' right after validating it (a leftover from development, visible in git
+ history but never cleaned up for this function even though the analogous line was removed
+ from exponential_cex_processing). This silently made cex_processing='linear_bwd' behave
+ exactly like 'linear_fwd'. Fixed by deleting the stray override in CounterExampleProcessing.py.
+ """
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ cex = ('b', 'b', 'b')
+
+ forward = linear_cex_processing(sul, cex, hyp, direction='fwd', suffix_closedness=False)
+ backward = linear_cex_processing(sul, cex, hyp, direction='bwd', suffix_closedness=False)
+
+ self.assertEqual(forward, [('b', 'b')])
+ self.assertEqual(backward, [('b',)])
+ self.assertNotEqual(forward, backward)
+
+ def test_invalid_direction_raises(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ with self.assertRaises(AssertionError):
+ linear_cex_processing(sul, ('a', 'b'), hyp, direction='sideways')
+
+
+class TestExponentialCexProcessing(unittest.TestCase):
+ def test_forward_scan_finds_a_valid_suffix(self):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ cex = ('b', 'b', 'b')
+ suffix = exponential_cex_processing(sul, cex, hyp, direction='fwd', suffix_closedness=False)
+ self.assertEqual(suffix, [('b', 'b')])
+
+ def test_forward_result_agrees_with_rs_processing(self):
+ # Exponential search falls back to Rivest-Schapire binary search once it has bracketed the
+ # divergence point, so on a fixed cex/hypothesis pair both should settle on the same suffix.
+ sul = AutomatonSUL(get_Angluin_dfa())
+ hyp = wrong_two_state_hypothesis()
+ cex = ('a', 'b', 'a')
+ rs_suffix = rs_cex_processing(sul, cex, hyp, suffix_closedness=False)
+ exp_suffix = exponential_cex_processing(sul, cex, hyp, direction='fwd', suffix_closedness=False)
+ self.assertEqual(rs_suffix, exp_suffix)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_learning_algorithms.py b/tests/learning_algs/deterministic/test_learning_algorithms.py
new file mode 100644
index 00000000000..c7869037623
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_learning_algorithms.py
@@ -0,0 +1,143 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Dfa, MealyMachine, MooreMachine
+from aalpy.learning_algs import run_KV, run_Lsharp, run_Lstar
+from aalpy.oracles import RandomWalkEqOracle, RandomWMethodEqOracle, WMethodEqOracle
+from aalpy.utils import get_Angluin_dfa, load_automaton_from_file
+from aalpy.utils.ModelChecking import bisimilar
+from pathlib import Path
+
+DOT_MODELS_DIR = Path(__file__).resolve().parents[3] / 'DotModels'
+
+
+def reference_automaton(automaton_type):
+ if automaton_type == 'dfa':
+ return get_Angluin_dfa()
+ if automaton_type == 'mealy':
+ return load_automaton_from_file(str(DOT_MODELS_DIR / 'Angluin_Mealy.dot'), automaton_type='mealy')
+ if automaton_type == 'moore':
+ return load_automaton_from_file(str(DOT_MODELS_DIR / 'Angluin_Moore.dot'), automaton_type='moore')
+ raise ValueError(automaton_type)
+
+
+def learns_correctly(learning_alg, automaton_type, **kwargs):
+ """
+ Builds a fresh SUL/oracle around the reference Angluin automaton of the requested type, runs the
+ given learning algorithm with the given config, and asserts the learned hypothesis is minimal and
+ bisimilar to the ground truth (which itself is already known to be minimal for all three types).
+ """
+ ground_truth = reference_automaton(automaton_type)
+ alphabet = ground_truth.get_input_alphabet()
+ sul = AutomatonSUL(ground_truth)
+ eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=50, walk_len=20)
+
+ learned_model = learning_alg(alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level=0, **kwargs)
+
+ assert learned_model.is_minimal(), f'{learning_alg.__name__}/{automaton_type}: learned model is not minimal'
+ assert bisimilar(ground_truth, learned_model), \
+ f'{learning_alg.__name__}/{automaton_type}: learned model is not bisimilar to ground truth'
+ return learned_model
+
+
+class TestRunLstarConfigurations(unittest.TestCase):
+ AUTOMATON_TYPES = ['dfa', 'mealy', 'moore']
+
+ def test_default_configuration(self):
+ for automaton_type in self.AUTOMATON_TYPES:
+ learns_correctly(run_Lstar, automaton_type)
+
+ def test_closing_strategies(self):
+ for closing in ['shortest_first', 'longest_first', 'single']:
+ learns_correctly(run_Lstar, 'dfa', closing_strategy=closing)
+
+ def test_cex_processing_strategies(self):
+ for cex_processing in [None, 'rs', 'longest_prefix', 'linear_fwd', 'linear_bwd',
+ 'exponential_fwd', 'exponential_bwd']:
+ learns_correctly(run_Lstar, 'mealy', cex_processing=cex_processing)
+
+ def test_suffix_closedness_options(self):
+ for suffix_closed in [True, False]:
+ learns_correctly(run_Lstar, 'moore', all_prefixes_in_obs_table=True, e_set_suffix_closed=suffix_closed)
+
+ def test_without_caching_and_non_det_check(self):
+ learns_correctly(run_Lstar, 'dfa', cache_and_non_det_check=False)
+
+ def test_with_a_different_equivalence_oracle(self):
+ ground_truth = reference_automaton('dfa')
+ alphabet = ground_truth.get_input_alphabet()
+ sul = AutomatonSUL(ground_truth)
+ eq_oracle = WMethodEqOracle(alphabet, sul, max_number_of_states=len(ground_truth.states) + 1)
+ learned_model = run_Lstar(alphabet, sul, eq_oracle, automaton_type='dfa', print_level=0)
+ self.assertTrue(learned_model.is_minimal())
+ self.assertTrue(bisimilar(ground_truth, learned_model))
+
+ def test_return_data_reports_consistent_learning_rounds(self):
+ ground_truth = reference_automaton('dfa')
+ alphabet = ground_truth.get_input_alphabet()
+ sul = AutomatonSUL(ground_truth)
+ eq_oracle = RandomWalkEqOracle(alphabet, sul, 1000)
+ learned_model, info = run_Lstar(alphabet, sul, eq_oracle, automaton_type='dfa', print_level=0,
+ return_data=True)
+ self.assertTrue(bisimilar(ground_truth, learned_model))
+ self.assertGreaterEqual(info['learning_rounds'], 1)
+ self.assertEqual(info['automaton_size'], len(learned_model.states))
+
+
+class TestRunKvConfigurations(unittest.TestCase):
+ AUTOMATON_TYPES = ['dfa', 'mealy', 'moore']
+
+ def test_default_configuration(self):
+ for automaton_type in self.AUTOMATON_TYPES:
+ learns_correctly(run_KV, automaton_type)
+
+ def test_cex_processing_strategies(self):
+ for cex_processing in ['rs', 'linear_fwd', 'linear_bwd', 'exponential_fwd', 'exponential_bwd']:
+ learns_correctly(run_KV, 'mealy', cex_processing=cex_processing)
+
+ def test_without_caching_and_non_det_check(self):
+ learns_correctly(run_KV, 'dfa', cache_and_non_det_check=False)
+
+ def test_return_data_reports_consistent_learning_rounds(self):
+ ground_truth = reference_automaton('moore')
+ alphabet = ground_truth.get_input_alphabet()
+ sul = AutomatonSUL(ground_truth)
+ eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=50, walk_len=20)
+ learned_model, info = run_KV(alphabet, sul, eq_oracle, automaton_type='moore', print_level=0,
+ return_data=True)
+ self.assertTrue(bisimilar(ground_truth, learned_model))
+ self.assertEqual(info['automaton_size'], len(learned_model.states))
+
+
+class TestRunLsharpConfigurations(unittest.TestCase):
+ AUTOMATON_TYPES = ['dfa', 'mealy', 'moore']
+
+ def test_default_configuration(self):
+ for automaton_type in self.AUTOMATON_TYPES:
+ learns_correctly(run_Lsharp, automaton_type)
+
+ def test_extension_and_separation_rule_combinations(self):
+ for extension_rule in [None, 'SepSeq', 'ADS']:
+ for separation_rule in ['SepSeq', 'ADS']:
+ learns_correctly(run_Lsharp, 'dfa', extension_rule=extension_rule, separation_rule=separation_rule)
+
+ def test_extension_and_separation_rules_on_mealy_and_moore(self):
+ for automaton_type in ['mealy', 'moore']:
+ learns_correctly(run_Lsharp, automaton_type, extension_rule='ADS', separation_rule='ADS')
+
+ def test_without_caching_and_non_det_check(self):
+ learns_correctly(run_Lsharp, 'dfa', cache_and_non_det_check=False)
+
+ def test_return_data_reports_consistent_learning_rounds(self):
+ ground_truth = reference_automaton('dfa')
+ alphabet = ground_truth.get_input_alphabet()
+ sul = AutomatonSUL(ground_truth)
+ eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=50, walk_len=20)
+ learned_model, info = run_Lsharp(alphabet, sul, eq_oracle, automaton_type='dfa', print_level=0,
+ return_data=True)
+ self.assertTrue(bisimilar(ground_truth, learned_model))
+ self.assertEqual(info['automaton_size'], len(learned_model.states))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_observation_table.py b/tests/learning_algs/deterministic/test_observation_table.py
new file mode 100644
index 00000000000..0b5f11c2ffd
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_observation_table.py
@@ -0,0 +1,315 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Dfa, DfaState, MealyMachine, MealyState, MooreMachine, MooreState
+from aalpy.learning_algs.deterministic.ObservationTable import ObservationTable
+from aalpy.utils import get_Angluin_dfa
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def three_state_dfa():
+ """
+ 3-state minimal DFA over {a, b}. q0 is accepting; q1 and q2 are only
+ distinguishable by the suffix 'b' (both go to a non-accepting state on 'a').
+ q0 --a--> q1 q0 --b--> q0
+ q1 --a--> q2 q1 --b--> q0
+ q2 --a--> q1 q2 --b--> q2
+ """
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q2 = DfaState('q2', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q2, 'b': q0}
+ q2.transitions = {'a': q1, 'b': q2}
+ dfa = Dfa(q0, [q0, q1, q2])
+ dfa.compute_prefixes()
+ return dfa
+
+
+def two_state_mealy():
+ """
+ 2-state Mealy machine over {a, b} where a self loop is only reachable from s1.
+ s0 --a/1--> s1 s0 --b/0--> s0
+ s1 --a/0--> s0 s1 --b/1--> s1
+ """
+ s0 = MealyState('s0')
+ s1 = MealyState('s1')
+ s0.transitions = {'a': s1, 'b': s0}
+ s0.output_fun = {'a': 1, 'b': 0}
+ s1.transitions = {'a': s0, 'b': s1}
+ s1.output_fun = {'a': 0, 'b': 1}
+ mm = MealyMachine(s0, [s0, s1])
+ mm.compute_prefixes()
+ return mm
+
+
+def two_state_moore():
+ s0 = MooreState('s0', output=1)
+ s1 = MooreState('s1', output=0)
+ s0.transitions = {'a': s1, 'b': s0}
+ s1.transitions = {'a': s0, 'b': s1}
+ moore = MooreMachine(s0, [s0, s1])
+ moore.compute_prefixes()
+ return moore
+
+
+class TestObservationTableInit(unittest.TestCase):
+ def test_dfa_initial_e_set_contains_empty_word(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ self.assertEqual(table.E, [tuple()])
+ self.assertEqual(table.S, [tuple()])
+ self.assertEqual(table.A, [('a',), ('b',)])
+
+ def test_moore_initial_e_set_contains_empty_word(self):
+ moore = two_state_moore()
+ sul = AutomatonSUL(moore)
+ table = ObservationTable(['a', 'b'], sul, 'moore')
+ self.assertEqual(table.E, [tuple()])
+
+ def test_mealy_initial_e_set_is_whole_alphabet(self):
+ mm = two_state_mealy()
+ sul = AutomatonSUL(mm)
+ table = ObservationTable(['a', 'b'], sul, 'mealy')
+ self.assertEqual(table.E, [('a',), ('b',)])
+
+ def test_invalid_automaton_type_raises(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ with self.assertRaises(AssertionError):
+ ObservationTable(['a', 'b'], sul, 'not_a_type')
+
+
+class TestSDotA(unittest.TestCase):
+ def test_s_dot_a_excludes_elements_already_in_s(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ self.assertEqual(set(table.s_dot_a()), {('a',), ('b',)})
+
+ table.S.append(('a',))
+ self.assertEqual(set(table.s_dot_a()), {('b',), ('a', 'a'), ('a', 'b')})
+
+
+class TestUpdateObsTable(unittest.TestCase):
+ def test_update_fills_row_for_empty_prefix(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ self.assertEqual(table.T[tuple()], (True,))
+ self.assertEqual(table.T[('a',)], (False,))
+ self.assertEqual(table.T[('b',)], (True,))
+
+ def test_update_is_idempotent_and_does_not_reissue_queries(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ queries_after_first = sul.num_queries
+ table.update_obs_table()
+ self.assertEqual(sul.num_queries, queries_after_first)
+
+ def test_update_with_explicit_s_and_e_set(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.E.append(('b',))
+ table.update_obs_table(s_set=[tuple()], e_set=[('b',)])
+ self.assertEqual(len(table.T[tuple()]), 1)
+ self.assertEqual(table.T[tuple()], (True,))
+
+ def test_growing_e_set_requires_e_set_argument_for_new_column(self):
+ """
+ update_obs_table() with no arguments re-derives update_S/update_E from S/E, and for a row
+ that is already partially filled it only (re)asks the *first* `len(E) - len(T[s])` columns
+ of E in order -- it does not know which specific column(s) are new. Callers therefore must
+ pass e_set= when the E set grows (as LStar.py always does); calling it
+ bare after extending self.E does not compute the new column correctly.
+ """
+ dfa = get_Angluin_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ table.E.append(('a',))
+ table.update_obs_table()
+ # The new column was (incorrectly, from the bare call's perspective) filled by re-querying
+ # the *old* suffix again instead of the newly appended one.
+ self.assertEqual(table.T[('a',)], (False, False))
+
+ # Using the documented e_set argument computes the new column correctly instead.
+ table.T.clear()
+ table.update_obs_table()
+ table.update_obs_table(e_set=[('a',)])
+ self.assertEqual(table.T[('a',)], (False, True))
+
+
+class TestGetRowsToClose(unittest.TestCase):
+ def test_no_rows_to_close_returns_none_when_table_closed(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ table.S.append(('a',))
+ table.S.append(('a', 'a'))
+ table.update_obs_table()
+ self.assertIsNone(table.get_rows_to_close('longest_first'))
+
+ def test_shortest_first_returns_the_distinguishing_row(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ rows = table.get_rows_to_close('shortest_first')
+ self.assertEqual(rows, [('a',)])
+
+ def test_single_returns_only_one_row(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ rows = table.get_rows_to_close('single')
+ self.assertEqual(rows, [('a',)])
+
+ def _angluin_table_with_two_rows_to_close(self):
+ dfa = get_Angluin_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ table.E.append(('a',))
+ table.update_obs_table(e_set=[('a',)])
+ return table
+
+ def test_longest_first_and_shortest_first_find_both_distinguishing_rows(self):
+ table = self._angluin_table_with_two_rows_to_close()
+ self.assertEqual(set(table.get_rows_to_close('longest_first')), {('a',), ('b',)})
+ self.assertEqual(set(table.get_rows_to_close('shortest_first')), {('a',), ('b',)})
+
+ def test_single_longest_returns_a_single_row(self):
+ table = self._angluin_table_with_two_rows_to_close()
+ rows = table.get_rows_to_close('single_longest')
+ self.assertEqual(len(rows), 1)
+
+ def test_longest_first_orders_rows_by_decreasing_length(self):
+ # Directly populate S/T (rather than driving this through a SUL) to get full control over
+ # row values and prefix lengths, since get_rows_to_close only ever looks at S, A and T.
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.S = [tuple(), ('a',), ('a', 'b')]
+ table.T[tuple()] = (0,)
+ table.T[('a',)] = (1,)
+ table.T[('a', 'b')] = (2,)
+ table.T[('b',)] = (3,)
+ table.T[('a', 'a')] = (4,)
+ table.T[('a', 'b', 'a')] = (5,)
+ table.T[('a', 'b', 'b')] = (6,)
+
+ rows = table.get_rows_to_close('longest_first')
+ self.assertEqual(set(rows), {('b',), ('a', 'a'), ('a', 'b', 'a'), ('a', 'b', 'b')})
+ lengths = [len(r) for r in rows]
+ self.assertEqual(lengths, sorted(lengths, reverse=True))
+ self.assertEqual(len(rows[0]), 3)
+
+
+class TestGetCausesOfInconsistency(unittest.TestCase):
+ def test_consistent_table_returns_none(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ self.assertIsNone(table.get_causes_of_inconsistency())
+
+ def test_inconsistency_detected_and_cause_returned(self):
+ dfa = get_Angluin_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ table.E.append(('a',))
+ table.update_obs_table(e_set=[('a',)])
+ table.S.append(('a',))
+ table.S.append(('b',))
+ table.update_obs_table()
+
+ # ('b', 'a') has the same row, (False, False), as ('b',) under the current E, but the two
+ # prefixes are not actually equivalent (they reach different DFA states); adding the
+ # duplicate-row prefix to S manually is what a cex-processing step would eventually force,
+ # and it makes the table inconsistent since their 'b'-extensions differ.
+ table.S.append(('b', 'a'))
+ table.update_obs_table()
+
+ cause = table.get_causes_of_inconsistency()
+ self.assertEqual(cause, [('b',)])
+
+
+class TestGenHypothesis(unittest.TestCase):
+ def test_gen_hypothesis_dfa_matches_original(self):
+ dfa = get_Angluin_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ table.E.append(('a',))
+ table.update_obs_table(e_set=[('a',)])
+ table.S.append(('a',))
+ table.S.append(('b',))
+ table.update_obs_table()
+ table.E.append(('b',))
+ table.update_obs_table(e_set=[('b',)])
+ table.S.append(('a', 'b'))
+ table.update_obs_table()
+
+ hyp = table.gen_hypothesis()
+ self.assertEqual(len(hyp.states), 4)
+ self.assertTrue(bisimilar(dfa, hyp))
+
+ def test_gen_hypothesis_mealy_output_fun_matches(self):
+ mm = two_state_mealy()
+ sul = AutomatonSUL(mm)
+ table = ObservationTable(['a', 'b'], sul, 'mealy')
+ table.update_obs_table()
+ table.S.append(('a',))
+ table.update_obs_table()
+
+ hyp = table.gen_hypothesis()
+ self.assertEqual(len(hyp.states), 2)
+ for word in [('a',), ('b',), ('a', 'a'), ('b', 'a', 'b')]:
+ expected = mm.execute_sequence(mm.initial_state, word)
+ actual = hyp.execute_sequence(hyp.initial_state, word)
+ self.assertEqual(expected, actual)
+
+ def test_gen_hypothesis_no_cex_processing_deduplicates_rows(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ # ('a',) is genuinely new, but ('b',) is a self-loop back to q0, so its row is identical to
+ # the empty prefix's row; the table is still closed since ('a',) closes the only new row.
+ table.S.append(('a',))
+ table.S.append(('b',))
+ table.update_obs_table()
+ self.assertIsNone(table.get_rows_to_close())
+
+ hyp = table.gen_hypothesis(no_cex_processing_used=True)
+ # () and ('b',) collapse into a single representative state, ('a',) remains distinct.
+ self.assertEqual(len(hyp.states), 2)
+
+
+class TestGetRowRepresentatives(unittest.TestCase):
+ def test_representatives_prefer_shortest_prefix_per_row(self):
+ dfa = three_state_dfa()
+ sul = AutomatonSUL(dfa)
+ table = ObservationTable(['a', 'b'], sul, 'dfa')
+ table.update_obs_table()
+ table.S.append(('b',))
+ table.S.append(('b', 'b'))
+ table.update_obs_table()
+
+ representatives = table._get_row_representatives()
+ # (), ('b',) and ('b', 'b') all share the same row (all lead to accepting q0),
+ # so only the shortest, (), should be kept as representative.
+ self.assertEqual(representatives, [tuple()])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_observation_tree.py b/tests/learning_algs/deterministic/test_observation_tree.py
new file mode 100644
index 00000000000..49db5263ce0
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_observation_tree.py
@@ -0,0 +1,172 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs.deterministic.ObservationTree import MealyNode, MooreNode, ObservationTree
+from aalpy.utils import get_Angluin_dfa
+
+
+def dfa_tree(extension_rule=None, separation_rule='SepSeq'):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ return ObservationTree(['a', 'b'], sul, 'dfa', extension_rule, separation_rule)
+
+
+def mealy_tree(extension_rule=None, separation_rule='SepSeq'):
+ sul = AutomatonSUL(get_Angluin_dfa())
+ return ObservationTree(['a', 'b'], sul, 'mealy', extension_rule, separation_rule)
+
+
+class TestObservationTreeInit(unittest.TestCase):
+ def test_dfa_root_output_is_queried_eagerly(self):
+ tree = dfa_tree()
+ self.assertIsInstance(tree.root, MooreNode)
+ self.assertTrue(tree.root.output)
+
+ def test_mealy_root_has_no_output_attribute_use(self):
+ tree = mealy_tree()
+ self.assertIsInstance(tree.root, MealyNode)
+
+ def test_basis_starts_with_only_the_root(self):
+ tree = dfa_tree()
+ self.assertEqual(tree.basis, [tree.root])
+ self.assertEqual(tree.frontier_to_basis_dict, {})
+
+
+class TestInsertAndGetObservation(unittest.TestCase):
+ def test_insert_and_retrieve_matches(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a', 'b'], [False, True])
+ self.assertEqual(tree.get_observation(['a', 'b']), [False, True])
+ self.assertEqual(tree.get_observation(['a']), [False])
+
+ def test_mismatched_lengths_raise(self):
+ tree = dfa_tree()
+ with self.assertRaises(ValueError):
+ tree.insert_observation(['a', 'b'], [False])
+
+ def test_unseen_sequence_returns_none(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ self.assertIsNone(tree.get_observation(['b']))
+ self.assertIsNone(tree.get_observation(['a', 'b']))
+
+ def test_mealy_reinserting_same_input_with_conflicting_output_raises(self):
+ tree = mealy_tree()
+ tree.insert_observation(['a'], [1])
+ with self.assertRaises(Exception):
+ tree.insert_observation(['a'], [2])
+
+ def test_mealy_reinserting_same_input_output_is_idempotent(self):
+ tree = mealy_tree()
+ tree.insert_observation(['a'], [1])
+ tree.insert_observation(['a'], [1])
+ self.assertEqual(tree.get_observation(['a']), [1])
+
+
+class TestGetOutputsMatchesGetObservation(unittest.TestCase):
+ def test_get_outputs_from_root_agrees_with_get_observation(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a', 'b'], [False, True])
+ self.assertEqual(tree.get_outputs(tree.root, ['a', 'b']), tree.get_observation(['a', 'b']))
+
+ def test_get_outputs_from_non_root_basis_state(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a', 'b'], [False, True])
+ node_a = tree.get_successor(['a'])
+ self.assertEqual(tree.get_outputs(node_a, ['b']), [True])
+
+ def test_get_outputs_mealy_from_root_agrees_with_get_observation(self):
+ tree = mealy_tree()
+ tree.insert_observation(['a', 'b'], [1, 2])
+ self.assertEqual(tree.get_outputs(tree.root, ['a', 'b']), tree.get_observation(['a', 'b']))
+
+
+class TestTraversalHelpers(unittest.TestCase):
+ def test_get_successor_and_transfer_and_access_sequence(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a', 'b'], [False, True])
+ node_ab = tree.get_successor(['a', 'b'])
+ self.assertIsNotNone(node_ab)
+ self.assertEqual(tree.get_transfer_sequence(tree.root, node_ab), ['a', 'b'])
+ self.assertEqual(tree.get_access_sequence(node_ab), ('a', 'b'))
+
+ def test_get_successor_unknown_path_returns_none(self):
+ tree = dfa_tree()
+ self.assertIsNone(tree.get_successor(['a']))
+
+ def test_get_transfer_sequence_returns_none_for_unrelated_node(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.insert_observation(['b'], [False])
+ node_a = tree.get_successor(['a'])
+ node_b = tree.get_successor(['b'])
+ # node_a is not an ancestor of node_b, so there is no transfer sequence between them.
+ self.assertIsNone(tree.get_transfer_sequence(node_a, node_b))
+
+ def test_get_size_counts_all_created_nodes(self):
+ tree = dfa_tree()
+ size_before = tree.get_size()
+ tree.insert_observation(['a', 'b'], [False, True])
+ self.assertEqual(tree.get_size(), size_before + 2)
+
+
+class TestFrontierAndBasisPromotion(unittest.TestCase):
+ def test_new_frontier_state_is_promoted_to_basis_after_two_rounds(self):
+ tree = dfa_tree()
+ # ('a',) is apart from the root (True vs False), so it has zero basis candidates once
+ # check_frontier_consistency notices it; promotion of an isolated frontier state only
+ # happens on the *next* round (promote_frontier_state runs before the dict is (re)populated).
+ tree.insert_observation(['a'], [False])
+
+ tree.update_frontier_and_basis()
+ self.assertEqual(len(tree.basis), 1)
+ self.assertEqual(len(tree.frontier_to_basis_dict), 1)
+ sole_frontier_candidates = next(iter(tree.frontier_to_basis_dict.values()))
+ self.assertEqual(sole_frontier_candidates, [])
+
+ tree.update_frontier_and_basis()
+ self.assertEqual(len(tree.basis), 2)
+ self.assertEqual(tree.frontier_to_basis_dict, {})
+
+ def test_find_basis_candidates_excludes_apart_states(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ node_a = tree.get_successor(['a'])
+ # root.output is True, node_a.output is False: they are apart, so root cannot be a candidate.
+ candidates = tree.find_basis_candidates(node_a)
+ self.assertEqual(candidates, set())
+
+ def test_make_basis_complete_explores_missing_alphabet_symbols(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.update_frontier_and_basis()
+ tree.update_frontier_and_basis()
+ self.assertEqual(len(tree.basis), 2)
+
+ tree.make_basis_complete()
+ for basis_state in tree.basis:
+ for inp in tree.alphabet:
+ self.assertIsNotNone(basis_state.get_successor(inp))
+
+
+class TestConstructHypothesis(unittest.TestCase):
+ def test_construct_hypothesis_reflects_current_tree_knowledge(self):
+ tree = dfa_tree()
+ tree.insert_observation(['a'], [False])
+ tree.update_frontier_and_basis()
+ tree.update_frontier_and_basis()
+ tree.make_basis_complete()
+ tree.make_frontiers_identified()
+ self.assertTrue(tree.is_observation_tree_adequate())
+
+ hyp = tree.construct_hypothesis()
+ self.assertEqual(len(hyp.states), 2)
+
+ ground_truth = get_Angluin_dfa()
+ for word in [tuple(), ('a',), ('b',), ('a', 'a'), ('a', 'b')]:
+ hyp_out = hyp.execute_sequence(hyp.initial_state, list(word))
+ gt_out = ground_truth.execute_sequence(ground_truth.initial_state, list(word))
+ self.assertEqual(hyp_out, gt_out)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic/test_random_learning_runs.py b/tests/learning_algs/deterministic/test_random_learning_runs.py
new file mode 100644
index 00000000000..fcb1c333601
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_random_learning_runs.py
@@ -0,0 +1,59 @@
+import random
+
+import pytest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs import run_KV, run_Lsharp, run_Lstar
+from aalpy.oracles import RandomWMethodEqOracle
+from aalpy.utils import generate_random_deterministic_automata
+from aalpy.utils.ModelChecking import bisimilar
+
+# Trimmed down from the original (50 seeds x 16 sizes x 3 types x 3 algorithms = 7200 cases) sweep:
+# a handful of seeds and small state counts is enough to catch a regression in any of the three
+# algorithms across automaton types, while keeping this whole module comfortably under a second.
+SEEDS = list(range(6))
+MODEL_SIZES = [
+ (2, 2, 2),
+ (3, 2, 3),
+ (4, 3, 2),
+ (6, 2, 3),
+]
+
+TEST_CASES = [
+ pytest.param(
+ learning_alg,
+ automaton_type,
+ seed_val,
+ num_states,
+ input_size,
+ output_size,
+ id=f"{learning_alg.__name__}-{automaton_type}-states={num_states}-seed={seed_val}",
+ )
+ for num_states, input_size, output_size in MODEL_SIZES
+ for seed_val in SEEDS
+ for automaton_type in ['dfa', 'moore', 'mealy']
+ for learning_alg in [run_Lstar, run_Lsharp, run_KV]
+]
+
+
+@pytest.mark.parametrize("learning_alg,automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES)
+def test_learning_algs_on_small_random_automata(learning_alg, automaton_type, seed_val, num_states, input_size,
+ output_size):
+ random.seed(seed_val)
+
+ model = generate_random_deterministic_automata(
+ automaton_type,
+ num_states=num_states,
+ input_alphabet_size=input_size,
+ output_alphabet_size=output_size,
+ )
+
+ sul = AutomatonSUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ eq_oracle = RandomWMethodEqOracle(input_alphabet, sul, walks_per_state=num_states * 10, walk_len=15)
+
+ learned_model = learning_alg(input_alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
diff --git a/tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py b/tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py
new file mode 100644
index 00000000000..30f63d9cd2a
--- /dev/null
+++ b/tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py
@@ -0,0 +1,76 @@
+import random
+
+import pytest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs import run_KV, run_Lsharp, run_Lstar
+from aalpy.oracles import RandomWMethodEqOracle
+from aalpy.utils import generate_random_deterministic_automata
+from aalpy.utils.ModelChecking import bisimilar
+
+pytestmark = pytest.mark.exhaustive
+
+# Full sweep this repo used to run at the root (tests/test_deterministic_learning_runs.py) before it was
+# trimmed down to a fast default (see the sibling test_random_learning_runs.py): 50 seeds x 16 sizes x 3
+# automaton types x 3 algorithms = 7200 cases. Wide sweeps like this are what caught e.g. the
+# linear_cex_processing direction bug and the ObservationTree off-by-one during this test suite's build-out
+# - a handful of seeds can get lucky and never hit the input sequence that triggers a given bug.
+SEEDS = list(range(50))
+MODEL_SIZES = [
+ (2, 2, 2),
+ (2, 2, 3),
+ (3, 2, 2),
+ (3, 2, 3),
+ (3, 3, 2),
+ (4, 2, 3),
+ (4, 3, 2),
+ (5, 3, 3),
+ (6, 2, 3),
+ (6, 3, 2),
+ (10, 2, 3),
+ (10, 2, 4),
+ (10, 2, 2),
+ (10, 2, 3),
+ (20, 5, 5),
+ (30, 3, 4),
+]
+
+TEST_CASES = [
+ pytest.param(
+ learning_alg,
+ automaton_type,
+ seed_val,
+ num_states,
+ input_size,
+ output_size,
+ id=f"{learning_alg.__name__}-{automaton_type}-states={num_states}-seed={seed_val}",
+ )
+ for num_states, input_size, output_size in MODEL_SIZES
+ for seed_val in SEEDS
+ for automaton_type in ['dfa', 'moore', 'mealy']
+ for learning_alg in [run_Lstar, run_Lsharp, run_KV]
+]
+
+
+@pytest.mark.parametrize("learning_alg,automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES)
+@pytest.mark.timeout(5)
+def test_learning_algs_on_small_random_automata_exhaustive(learning_alg, automaton_type, seed_val, num_states,
+ input_size, output_size):
+ random.seed(seed_val)
+
+ model = generate_random_deterministic_automata(
+ automaton_type,
+ num_states=num_states,
+ input_alphabet_size=input_size,
+ output_alphabet_size=output_size,
+ )
+
+ sul = AutomatonSUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ eq_oracle = RandomWMethodEqOracle(input_alphabet, sul, walks_per_state=num_states * 10, walk_len=15)
+
+ learned_model = learning_alg(input_alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
diff --git a/tests/learning_algs/deterministic_passive/test_active_rpni.py b/tests/learning_algs/deterministic_passive/test_active_rpni.py
new file mode 100644
index 00000000000..b7448bc19ce
--- /dev/null
+++ b/tests/learning_algs/deterministic_passive/test_active_rpni.py
@@ -0,0 +1,113 @@
+import random
+import unittest
+
+from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState
+from aalpy.learning_algs.deterministic_passive.active_RPNI import (
+ RandomWordSampler, RpniActiveSampler, run_active_RPNI,
+)
+from aalpy.SULs import AutomatonSUL
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def even_a_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+def three_state_moore():
+ q0 = MooreState('q0', 0)
+ q1 = MooreState('q1', 1)
+ q2 = MooreState('q2', 2)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q2, 'b': q0}
+ q2.transitions = {'a': q2, 'b': q1}
+ return MooreMachine(q0, [q0, q1, q2])
+
+
+class ConstantSampler(RpniActiveSampler):
+ """Deterministic sampler used to exercise the RpniActiveSampler contract with fixed words."""
+
+ def __init__(self, words):
+ self.words = words
+
+ def sample(self, sul, model):
+ samples = []
+ for word in self.words:
+ outputs = sul.query(word)
+ samples.append((word, outputs[-1]))
+ return samples
+
+
+class NoOpSampler(RpniActiveSampler):
+ """Sampler that never queries the SUL, used where only the initial data matters."""
+
+ def sample(self, sul, model):
+ return []
+
+
+def bootstrap_data(ground_truth, sul, initial_output):
+ """
+ Builds a minimal initial data set that includes one transition per alphabet symbol, so that the very
+ first hypothesis learned by run_active_RPNI already has an input alphabet for RandomWordSampler to
+ sample from (it derives its alphabet from the transitions of the current hypothesis).
+ """
+ data = [((), initial_output)]
+ for letter in ground_truth.get_input_alphabet():
+ outputs = sul.query((letter,))
+ data.append(((letter,), outputs[-1]))
+ return data
+
+
+class TestRunActiveRpni(unittest.TestCase):
+ def test_learns_correct_dfa_with_random_word_sampler(self):
+ random.seed(1)
+ ground_truth = even_a_dfa()
+ sul = AutomatonSUL(ground_truth)
+ sampler = RandomWordSampler(num_walks=20, min_walk_len=1, max_walk_len=5)
+ data = bootstrap_data(ground_truth, sul, True)
+
+ learned = run_active_RPNI(data=data, sul=sul, sampler=sampler, n_iter=5,
+ automaton_type='dfa', print_info=False)
+
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_learns_correct_moore_machine_with_enough_iterations(self):
+ random.seed(2)
+ ground_truth = three_state_moore()
+ sul = AutomatonSUL(ground_truth)
+ sampler = RandomWordSampler(num_walks=30, min_walk_len=1, max_walk_len=6)
+ data = bootstrap_data(ground_truth, sul, 0)
+
+ learned = run_active_RPNI(data=data, sul=sul, sampler=sampler, n_iter=6,
+ automaton_type='moore', print_info=False)
+
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_data_grows_with_each_iteration(self):
+ ground_truth = even_a_dfa()
+ sul = AutomatonSUL(ground_truth)
+ sampler = ConstantSampler([('a',), ('b', 'a')])
+ data = [((), True)]
+
+ run_active_RPNI(data=data, sul=sul, sampler=sampler, n_iter=3,
+ automaton_type='dfa', print_info=False)
+
+ # 3 iterations each adding 2 fixed samples on top of the initial one
+ self.assertEqual(len(data), 1 + 3 * 2)
+
+ def test_returns_none_when_data_is_inconsistent(self):
+ ground_truth = even_a_dfa()
+ sul = AutomatonSUL(ground_truth)
+ # conflicting labels for the same (empty) sequence makes the data inconsistent from the start
+ data = [((), True), ((), False)]
+
+ learned = run_active_RPNI(data=data, sul=sul, sampler=NoOpSampler(), n_iter=2,
+ automaton_type='dfa', print_info=False)
+ self.assertIsNone(learned)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic_passive/test_classic_rpni.py b/tests/learning_algs/deterministic_passive/test_classic_rpni.py
new file mode 100644
index 00000000000..c9d91d215b8
--- /dev/null
+++ b/tests/learning_algs/deterministic_passive/test_classic_rpni.py
@@ -0,0 +1,85 @@
+import unittest
+from itertools import product
+
+from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState
+from aalpy.learning_algs.deterministic_passive.ClassicRPNI import ClassicRPNI
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def even_a_dfa():
+ """2-state DFA accepting words with an even number of 'a's over {'a', 'b'}."""
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+def full_sample(automaton, depth=3):
+ data = []
+ if isinstance(automaton, (Dfa, MooreMachine)):
+ data.append(((), automaton.initial_state.output))
+ alphabet = automaton.get_input_alphabet()
+ for level in range(1, depth + 1):
+ for seq in product(alphabet, repeat=level):
+ automaton.reset_to_initial()
+ outputs = automaton.execute_sequence(automaton.initial_state, seq)
+ data.append((seq, outputs[-1]))
+ return data
+
+
+class TestClassicRpniDfa(unittest.TestCase):
+ def test_learns_minimal_dfa_from_complete_sample(self):
+ ground_truth = even_a_dfa()
+ data = full_sample(ground_truth, depth=3)
+ learned = ClassicRPNI(data, 'dfa', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_merges_states_with_identical_output_and_behavior(self):
+ # 'a', 'b', and 'ab'/'ba' should all merge into a single accepting state
+ # since the language only distinguishes the empty word.
+ data = [((), True), (('a',), True), (('b',), True), (('a', 'b'), True), (('b', 'a'), True)]
+ learned = ClassicRPNI(data, 'dfa', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 1)
+
+
+class TestClassicRpniMoore(unittest.TestCase):
+ def test_learns_from_complete_sample(self):
+ q0 = MooreState('q0', 0)
+ q1 = MooreState('q1', 1)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ ground_truth = MooreMachine(q0, [q0, q1])
+
+ data = full_sample(ground_truth, depth=3)
+ learned = ClassicRPNI(data, 'moore', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+
+class TestClassicRpniMealy(unittest.TestCase):
+ def test_learns_from_complete_sample(self):
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'x', 'b': 'y'}
+ q1.transitions = {'a': q0, 'b': q1}
+ q1.output_fun = {'a': 'y', 'b': 'x'}
+ ground_truth = MealyMachine(q0, [q0, q1])
+
+ data = full_sample(ground_truth, depth=3)
+ learned = ClassicRPNI(data, 'mealy', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+
+class TestClassicRpniNonDeterministicData(unittest.TestCase):
+ def test_root_node_none_for_conflicting_data(self):
+ data = [((), True), ((), False)]
+ rpni = ClassicRPNI(data, 'dfa', print_info=False)
+ self.assertIsNone(rpni.root_node)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic_passive/test_gsm_rpni.py b/tests/learning_algs/deterministic_passive/test_gsm_rpni.py
new file mode 100644
index 00000000000..28514a471af
--- /dev/null
+++ b/tests/learning_algs/deterministic_passive/test_gsm_rpni.py
@@ -0,0 +1,116 @@
+import unittest
+from itertools import product
+
+from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState
+from aalpy.learning_algs.deterministic_passive.GsmRPNI import GsmRPNI
+from aalpy.learning_algs.deterministic_passive.ClassicRPNI import ClassicRPNI
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def even_a_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+def full_sample(automaton, depth=3):
+ data = []
+ if isinstance(automaton, (Dfa, MooreMachine)):
+ data.append(((), automaton.initial_state.output))
+ alphabet = automaton.get_input_alphabet()
+ for level in range(1, depth + 1):
+ for seq in product(alphabet, repeat=level):
+ automaton.reset_to_initial()
+ outputs = automaton.execute_sequence(automaton.initial_state, seq)
+ data.append((seq, outputs[-1]))
+ return data
+
+
+class TestGsmRpniDfa(unittest.TestCase):
+ def test_learns_minimal_dfa_from_complete_sample(self):
+ ground_truth = even_a_dfa()
+ data = full_sample(ground_truth, depth=3)
+ learned = GsmRPNI(data, 'dfa', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_merges_states_with_identical_output_and_behavior(self):
+ data = [((), True), (('a',), True), (('b',), True), (('a', 'b'), True), (('b', 'a'), True)]
+ learned = GsmRPNI(data, 'dfa', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 1)
+
+ def test_dfa_internally_uses_moore_representation(self):
+ gsm = GsmRPNI([((), True)], 'dfa', print_info=False)
+ self.assertEqual(gsm.automaton_type, 'moore')
+ self.assertEqual(gsm.final_automaton_type, 'dfa')
+
+
+class TestGsmRpniMoore(unittest.TestCase):
+ def test_learns_from_complete_sample(self):
+ q0 = MooreState('q0', 0)
+ q1 = MooreState('q1', 1)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ ground_truth = MooreMachine(q0, [q0, q1])
+
+ data = full_sample(ground_truth, depth=3)
+ learned = GsmRPNI(data, 'moore', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+
+class TestGsmRpniMealy(unittest.TestCase):
+ def test_learns_from_complete_sample(self):
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'x', 'b': 'y'}
+ q1.transitions = {'a': q0, 'b': q1}
+ q1.output_fun = {'a': 'y', 'b': 'x'}
+ ground_truth = MealyMachine(q0, [q0, q1])
+
+ data = full_sample(ground_truth, depth=3)
+ learned = GsmRPNI(data, 'mealy', print_info=False).run_rpni()
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+
+class TestGsmRpniNonDeterministicData(unittest.TestCase):
+ def test_root_node_none_for_conflicting_data(self):
+ data = [((), True), ((), False)]
+ rpni = GsmRPNI(data, 'dfa', print_info=False)
+ self.assertIsNone(rpni.root_node)
+
+
+class TestGsmAndClassicAgree(unittest.TestCase):
+ """
+ Both underlying RPNI strategies should produce bisimilar (typically identical) results for the
+ same consistent, complete sample, even though they compute compatibility differently
+ (partition-based vs copy-and-fold).
+ """
+
+ def test_agree_on_dfa_sample(self):
+ ground_truth = even_a_dfa()
+ data = full_sample(ground_truth, depth=4)
+ gsm_learned = GsmRPNI(list(data), 'dfa', print_info=False).run_rpni()
+ classic_learned = ClassicRPNI(list(data), 'dfa', print_info=False).run_rpni()
+ self.assertTrue(bisimilar(gsm_learned, classic_learned))
+
+ def test_agree_on_mealy_sample(self):
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'x', 'b': 'y'}
+ q1.transitions = {'a': q0, 'b': q1}
+ q1.output_fun = {'a': 'y', 'b': 'x'}
+ ground_truth = MealyMachine(q0, [q0, q1])
+ data = full_sample(ground_truth, depth=4)
+ gsm_learned = GsmRPNI(list(data), 'mealy', print_info=False).run_rpni()
+ classic_learned = ClassicRPNI(list(data), 'mealy', print_info=False).run_rpni()
+ self.assertTrue(bisimilar(gsm_learned, classic_learned))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic_passive/test_papni.py b/tests/learning_algs/deterministic_passive/test_papni.py
new file mode 100644
index 00000000000..1034c6cd7c3
--- /dev/null
+++ b/tests/learning_algs/deterministic_passive/test_papni.py
@@ -0,0 +1,75 @@
+import random
+import unittest
+from itertools import product
+
+from aalpy.automata.Vpa import Vpa, VpaAlphabet, VpaState, VpaTransition
+from aalpy.learning_algs.deterministic_passive.PAPNI import run_PAPNI
+from aalpy.utils import is_balanced
+
+
+def balanced_parens_vpa_with_internal():
+ """1-state VPA accepting balanced '(' / ')' words, with an 'i' internal symbol self-loop."""
+ q0 = VpaState('q0', is_accepting=True)
+ q0.transitions['('].append(VpaTransition(q0, q0, '(', 'push', '('))
+ q0.transitions[')'].append(VpaTransition(q0, q0, ')', 'pop', '('))
+ q0.transitions['i'].append(VpaTransition(q0, q0, 'i', None, None))
+ vpa = Vpa(q0, [q0])
+ alphabet = VpaAlphabet(internal_alphabet=['i'], call_alphabet=['('], return_alphabet=[')'])
+ return vpa, alphabet
+
+
+def generate_data(vpa, alphabet, depth=4):
+ merged_alphabet = alphabet.get_merged_alphabet()
+ data = []
+ for level in range(0, depth + 1):
+ for seq in product(merged_alphabet, repeat=level):
+ vpa.reset_to_initial()
+ outputs = vpa.execute_sequence(vpa.initial_state, seq, [])
+ label = outputs[-1] if outputs else vpa.initial_state.is_accepting
+ data.append((seq, label))
+ return data
+
+
+class TestRunPapni(unittest.TestCase):
+ def check_learned_model_matches_ground_truth(self, algorithm):
+ vpa, alphabet = balanced_parens_vpa_with_internal()
+ data = generate_data(vpa, alphabet, depth=4)
+
+ learned_model = run_PAPNI(data, alphabet, algorithm=algorithm, print_info=False)
+ self.assertIsNotNone(learned_model)
+
+ random.seed(42)
+ merged_alphabet = alphabet.get_merged_alphabet()
+ for _ in range(200):
+ length = random.randint(0, 6)
+ seq = tuple(random.choice(merged_alphabet) for _ in range(length))
+
+ expected = is_balanced(list(seq), alphabet)
+
+ learned_model.reset_to_initial()
+ outputs = learned_model.execute_sequence(learned_model.initial_state, seq, [])
+ actual = outputs[-1] if outputs else learned_model.initial_state.is_accepting
+
+ self.assertEqual(actual, expected, f'mismatch on sequence {seq} with algorithm {algorithm}')
+
+ def test_edsm_algorithm(self):
+ self.check_learned_model_matches_ground_truth('edsm')
+
+ def test_gsm_algorithm(self):
+ self.check_learned_model_matches_ground_truth('gsm')
+
+ def test_classic_algorithm(self):
+ self.check_learned_model_matches_ground_truth('classic')
+
+ def test_unbalanced_sequences_are_filtered_out_of_data(self):
+ vpa, alphabet = balanced_parens_vpa_with_internal()
+ data = [((')',), False), (('(',), False), ((), True)]
+ # even though the label for the unbalanced sequences is wrong (False for both, which is actually
+ # correct here), run_PAPNI should still work since unbalanced sequences are dropped before learning.
+ learned_model = run_PAPNI(data, alphabet, algorithm='classic', print_info=False)
+ self.assertIsNotNone(learned_model)
+ self.assertTrue(learned_model.initial_state.is_accepting)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py b/tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py
new file mode 100644
index 00000000000..b67bf6ade4c
--- /dev/null
+++ b/tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py
@@ -0,0 +1,198 @@
+import unittest
+
+from aalpy.learning_algs.deterministic_passive.rpni_helper_functions import (
+ RpniNode, check_sequence, createPTA, extract_unique_sequences, to_automaton,
+)
+
+
+class TestRpniNode(unittest.TestCase):
+ def test_default_moore_output_is_none(self):
+ node = RpniNode(automaton_type='moore')
+ self.assertIsNone(node.output)
+ self.assertEqual(node.children, {})
+
+ def test_default_mealy_output_is_empty_dict(self):
+ node = RpniNode(automaton_type='mealy')
+ self.assertEqual(node.output, {})
+
+ def test_shallow_copy_shares_child_nodes_but_new_children_dict(self):
+ child = RpniNode(automaton_type='moore')
+ parent = RpniNode(output=True, children={'a': child}, automaton_type='moore')
+ copy = parent.shallow_copy()
+ self.assertIsNot(copy.children, parent.children)
+ self.assertIs(copy.children['a'], child)
+
+ def test_shallow_copy_mealy_output_is_independent_dict(self):
+ parent = RpniNode(output={'a': 'x'}, automaton_type='mealy')
+ copy = parent.shallow_copy()
+ copy.output['b'] = 'y'
+ self.assertNotIn('b', parent.output)
+
+ def test_deep_copy_duplicates_subtree(self):
+ child = RpniNode(automaton_type='moore')
+ child.prefix = ('a',)
+ parent = RpniNode(output=True, children={'a': child}, automaton_type='moore')
+ parent.prefix = ()
+ copy = parent.copy()
+ self.assertIsNot(copy.children['a'], child)
+ self.assertEqual(copy.children['a'].prefix, ('a',))
+
+ def test_lt_compares_prefix_length(self):
+ short = RpniNode(automaton_type='moore')
+ short.prefix = ('a',)
+ long = RpniNode(automaton_type='moore')
+ long.prefix = ('a', 'b')
+ self.assertTrue(short < long)
+ self.assertFalse(long < short)
+
+ def test_eq_compares_prefix_not_identity(self):
+ n1 = RpniNode(automaton_type='moore')
+ n1.prefix = ('a',)
+ n2 = RpniNode(automaton_type='moore')
+ n2.prefix = ('a',)
+ self.assertEqual(n1, n2)
+ self.assertIsNot(n1, n2)
+
+ def test_compatible_outputs_moore_none_matches_anything(self):
+ n1 = RpniNode(output=None, automaton_type='moore')
+ n2 = RpniNode(output=True, automaton_type='moore')
+ self.assertTrue(n1.compatible_outputs(n2))
+ self.assertTrue(n2.compatible_outputs(n1))
+
+ def test_compatible_outputs_moore_conflicting_values(self):
+ n1 = RpniNode(output=True, automaton_type='moore')
+ n2 = RpniNode(output=False, automaton_type='moore')
+ self.assertFalse(n1.compatible_outputs(n2))
+
+ def test_compatible_outputs_mealy_disjoint_inputs_are_compatible(self):
+ n1 = RpniNode(output={'a': 'x'}, automaton_type='mealy')
+ n2 = RpniNode(output={'b': 'y'}, automaton_type='mealy')
+ self.assertTrue(n1.compatible_outputs(n2))
+
+ def test_compatible_outputs_mealy_conflicting_shared_input(self):
+ n1 = RpniNode(output={'a': 'x'}, automaton_type='mealy')
+ n2 = RpniNode(output={'a': 'z'}, automaton_type='mealy')
+ self.assertFalse(n1.compatible_outputs(n2))
+
+ def test_get_child_by_prefix_follows_transitions(self):
+ leaf = RpniNode(automaton_type='moore')
+ mid = RpniNode(children={'b': leaf}, automaton_type='moore')
+ root = RpniNode(children={'a': mid}, automaton_type='moore')
+ self.assertIs(root.get_child_by_prefix(('a', 'b')), leaf)
+
+ def test_get_child_by_prefix_empty_returns_self(self):
+ root = RpniNode(automaton_type='moore')
+ self.assertIs(root.get_child_by_prefix(()), root)
+
+
+class TestCreatePTA(unittest.TestCase):
+ def test_moore_builds_tree_with_correct_outputs(self):
+ data = [((), True), (('a',), False), (('a', 'a'), True)]
+ root = createPTA(data, 'moore')
+ self.assertEqual(root.output, True)
+ self.assertEqual(root.children['a'].output, False)
+ self.assertEqual(root.children['a'].children['a'].output, True)
+
+ def test_moore_conflicting_labels_returns_none(self):
+ data = [((), True), ((), False)]
+ self.assertIsNone(createPTA(data, 'moore'))
+
+ def test_moore_conflicting_labels_at_leaf_returns_none(self):
+ data = [(('a',), True), (('a',), False)]
+ self.assertIsNone(createPTA(data, 'moore'))
+
+ def test_mealy_builds_tree_with_transition_outputs(self):
+ data = [(('a',), 'x'), (('a', 'b'), 'y')]
+ root = createPTA(data, 'mealy')
+ self.assertEqual(root.output, {'a': 'x'})
+ self.assertEqual(root.children['a'].output, {'b': 'y'})
+
+ def test_mealy_conflicting_labels_returns_none(self):
+ data = [(('a',), 'x'), (('a',), 'z')]
+ self.assertIsNone(createPTA(data, 'mealy'))
+
+ def test_prefixes_are_recorded_on_nodes(self):
+ data = [(('a', 'b'), True)]
+ root = createPTA(data, 'dfa')
+ self.assertEqual(root.prefix, ())
+ self.assertEqual(root.children['a'].prefix, ('a',))
+ self.assertEqual(root.children['a'].children['b'].prefix, ('a', 'b'))
+
+
+class TestCheckSequence(unittest.TestCase):
+ def test_valid_moore_sequence_accepted(self):
+ data = [((), True), (('a',), False)]
+ root = createPTA(data, 'moore')
+ self.assertTrue(check_sequence(root, [True, ('a', False)], 'moore'))
+
+ def test_invalid_moore_sequence_rejected(self):
+ data = [((), True), (('a',), False)]
+ root = createPTA(data, 'moore')
+ self.assertFalse(check_sequence(root, [True, ('a', True)], 'moore'))
+
+ def test_none_output_in_test_sequence_is_ignored(self):
+ data = [((), True), (('a',), False)]
+ root = createPTA(data, 'moore')
+ self.assertTrue(check_sequence(root, [None, ('a', None)], 'moore'))
+
+ def test_valid_mealy_sequence_accepted(self):
+ data = [(('a',), 'x'), (('a', 'b'), 'y')]
+ root = createPTA(data, 'mealy')
+ self.assertTrue(check_sequence(root, [('a', 'x'), ('b', 'y')], 'mealy'))
+
+ def test_invalid_mealy_sequence_rejected(self):
+ data = [(('a',), 'x'), (('a', 'b'), 'y')]
+ root = createPTA(data, 'mealy')
+ self.assertFalse(check_sequence(root, [('a', 'z')], 'mealy'))
+
+
+class TestExtractUniqueSequences(unittest.TestCase):
+ def test_extracts_one_sequence_per_leaf_moore(self):
+ data = [((), True), (('a',), False), (('b',), False)]
+ root = createPTA(data, 'moore')
+ sequences = extract_unique_sequences(root, 'moore')
+ self.assertEqual(len(sequences), 2)
+ self.assertIn([True, ('a', False)], sequences)
+ self.assertIn([True, ('b', False)], sequences)
+
+ def test_extracted_sequences_round_trip_through_check_sequence(self):
+ data = [((), True), (('a', 'b'), False)]
+ root = createPTA(data, 'moore')
+ sequences = extract_unique_sequences(root, 'moore')
+ for seq in sequences:
+ self.assertTrue(check_sequence(root, seq, 'moore'))
+
+
+class TestToAutomaton(unittest.TestCase):
+ def test_dfa_none_outputs_default_to_false(self):
+ data = [(('a',), True)]
+ root = createPTA(data, 'dfa')
+ # root has no explicit label -> output stays None until to_automaton fixes it up
+ red = [root, root.children['a']]
+ dfa = to_automaton(red, 'dfa')
+ self.assertFalse(dfa.initial_state.output)
+ self.assertTrue(dfa.initial_state.transitions['a'].output)
+
+ def test_moore_conversion_preserves_topology(self):
+ data = [((), 1), (('a',), 2), (('a', 'a'), 3)]
+ root = createPTA(data, 'moore')
+ red = [root, root.children['a'], root.children['a'].children['a']]
+ moore = to_automaton(red, 'moore')
+ self.assertEqual(moore.initial_state.output, 1)
+ s1 = moore.initial_state.transitions['a']
+ self.assertEqual(s1.output, 2)
+ s2 = s1.transitions['a']
+ self.assertEqual(s2.output, 3)
+
+ def test_mealy_conversion_sets_output_fun(self):
+ data = [(('a',), 'x'), (('a', 'b'), 'y')]
+ root = createPTA(data, 'mealy')
+ node_a = root.children['a']
+ red = [root, node_a, node_a.children['b']]
+ mealy = to_automaton(red, 'mealy')
+ self.assertEqual(mealy.initial_state.output_fun['a'], 'x')
+ self.assertEqual(mealy.initial_state.transitions['a'].output_fun['b'], 'y')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/deterministic_passive/test_run_rpni_integration.py b/tests/learning_algs/deterministic_passive/test_run_rpni_integration.py
new file mode 100644
index 00000000000..5a9670ae871
--- /dev/null
+++ b/tests/learning_algs/deterministic_passive/test_run_rpni_integration.py
@@ -0,0 +1,105 @@
+import unittest
+from itertools import product
+from pathlib import Path
+
+import aalpy
+from aalpy.automata import Dfa, MooreMachine, MealyMachine
+from aalpy.learning_algs import run_RPNI
+from aalpy.utils import load_automaton_from_file
+from aalpy.utils.ModelChecking import compare_automata
+
+DOT_MODELS_DIR = Path(__file__).resolve().parents[3] / 'DotModels'
+
+correct_automata = {
+ Dfa: load_automaton_from_file(str(DOT_MODELS_DIR / 'SimpleABC' / 'simple_abc_dfa.dot'), automaton_type='dfa'),
+ MooreMachine: load_automaton_from_file(str(DOT_MODELS_DIR / 'SimpleABC' / 'simple_abc_moore.dot'),
+ automaton_type='moore'),
+ MealyMachine: load_automaton_from_file(str(DOT_MODELS_DIR / 'SimpleABC' / 'simple_abc_mealy.dot'),
+ automaton_type='mealy'),
+}
+
+automata_type = {Dfa: 'dfa', MooreMachine: 'moore', MealyMachine: 'mealy'}
+
+
+def prove_equivalence(learned_automaton):
+ correct_automaton = correct_automata[learned_automaton.__class__]
+
+ # only works if the correct automaton is already minimal
+ if len(learned_automaton.states) != len(correct_automaton.states):
+ return False
+
+ return correct_automaton == learned_automaton
+
+
+def generate_data(ground_truth, depth=5, step=1):
+ data = []
+ if isinstance(ground_truth, (aalpy.automata.Dfa, aalpy.automata.MooreMachine)):
+ data.append(((), ground_truth.initial_state.output))
+
+ alphabet = ground_truth.get_input_alphabet()
+ for level in range(1, depth + 1, step):
+ for seq in product(alphabet, repeat=level):
+ ground_truth.reset_to_initial()
+ outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq)
+ data.append((seq, outputs[-1]))
+
+ return data
+
+
+class TestRunRpniAllConfigurations(unittest.TestCase):
+ """
+ Ported from the legacy tests/test_deterministic_passive.py: learns each reference automaton (loaded
+ from DotModels/SimpleABC) via run_RPNI for both the 'gsm' and 'classic' algorithms, on complete and
+ input-incomplete sample data, and checks that the learned model equals the reference.
+ """
+
+ def test_all_configuration_combinations_complete_data(self):
+ algorithms = ['gsm', 'classic']
+
+ for automata_class, correct_automaton in correct_automata.items():
+ data = generate_data(correct_automaton, depth=3)
+ for algorithm in algorithms:
+ learned_model = run_RPNI(data, automaton_type=automata_type[automata_class],
+ algorithm=algorithm, print_info=False)
+
+ if not prove_equivalence(learned_model):
+ cex = compare_automata(learned_model, correct_automaton)
+ self.fail(f'{algorithm}/{automata_type[automata_class]}: learned model does not match '
+ f'reference. Counterexamples: {cex}')
+
+ def test_all_configuration_combinations_input_incomplete_data(self):
+ algorithms = ['gsm', 'classic']
+
+ for automata_class, correct_automaton in correct_automata.items():
+ data = generate_data(correct_automaton, depth=3, step=2)
+ if automata_type[automata_class] == 'moore':
+ data += [(('a', 'a', 'a', 'a'), 1), (('b', 'b', 'b', 'b'), 2), (('c', 'c', 'c', 'c'), 3)]
+ for algorithm in algorithms:
+ learned_model = run_RPNI(data, automaton_type=automata_type[automata_class],
+ algorithm=algorithm, print_info=False)
+
+ if not prove_equivalence(learned_model):
+ cex = compare_automata(learned_model, correct_automaton)
+ self.fail(f'{algorithm}/{automata_type[automata_class]}: learned model does not match '
+ f'reference. Counterexamples: {cex}')
+
+ def test_returns_none_for_nondeterministic_data(self):
+ data = [((), True), ((), False)]
+ learned_model = run_RPNI(data, automaton_type='dfa', algorithm='classic', print_info=False)
+ self.assertIsNone(learned_model)
+
+ def test_input_completeness_sink_state(self):
+ data = [((), True), (('a',), False), (('b',), True)]
+ learned_model = run_RPNI(data, automaton_type='dfa', algorithm='gsm',
+ input_completeness='sink_state', print_info=False)
+ self.assertTrue(learned_model.is_input_complete())
+
+ def test_input_completeness_self_loop(self):
+ data = [((), True), (('a',), False), (('b',), True)]
+ learned_model = run_RPNI(data, automaton_type='dfa', algorithm='gsm',
+ input_completeness='self_loop', print_info=False)
+ self.assertTrue(learned_model.is_input_complete())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/general_passive/test_generalized_state_merging.py b/tests/learning_algs/general_passive/test_generalized_state_merging.py
new file mode 100644
index 00000000000..2b1630fd090
--- /dev/null
+++ b/tests/learning_algs/general_passive/test_generalized_state_merging.py
@@ -0,0 +1,192 @@
+import random
+import unittest
+from itertools import product
+
+from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState
+from aalpy.learning_algs.general_passive.GeneralizedStateMerging import (
+ GeneralizedStateMerging, Instrumentation, run_GSM,
+)
+from aalpy.learning_algs.general_passive.GsmNode import GsmNode, unknown_output
+from aalpy.utils.HelperFunctions import dfa_from_moore
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def alternating_moore(depth=4):
+ """2-state Moore machine over {'a', 'b'}: 'a' toggles state, 'b' stays put."""
+ q0 = MooreState('q0', 0)
+ q1 = MooreState('q1', 1)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return MooreMachine(q0, [q0, q1])
+
+
+def parity_mealy():
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'x', 'b': 'y'}
+ q1.transitions = {'a': q0, 'b': q1}
+ q1.output_fun = {'a': 'y', 'b': 'x'}
+ return MealyMachine(q0, [q0, q1])
+
+
+def labeled_sequence_data(automaton, depth=3):
+ data = []
+ alphabet = automaton.get_input_alphabet()
+ for level in range(0, depth + 1):
+ for seq in product(alphabet, repeat=level):
+ automaton.reset_to_initial()
+ if len(seq) == 0:
+ label = automaton.initial_state.output
+ else:
+ outputs = automaton.execute_sequence(automaton.initial_state, seq)
+ label = outputs[-1]
+ data.append((seq, label))
+ return data
+
+
+class RecordingInstrumentation(Instrumentation):
+ """Instrumentation that records each hook call, used to check that run_GSM actually invokes them."""
+
+ def __init__(self):
+ super().__init__()
+ self.reset_called = False
+ self.pta_done = False
+ self.promotions = []
+ self.merges = []
+ self.learning_done_called = False
+
+ def reset(self, gsm):
+ self.reset_called = True
+
+ def pta_construction_done(self, root):
+ self.pta_done = True
+
+ def log_promote(self, node):
+ self.promotions.append(node)
+
+ def log_merge(self, part):
+ self.merges.append(part)
+
+ def learning_done(self, root):
+ self.learning_done_called = True
+
+
+class TestRunGsmDeterministic(unittest.TestCase):
+ def test_learns_correct_moore_machine(self):
+ ground_truth = alternating_moore()
+ data = labeled_sequence_data(ground_truth, depth=3)
+ learned = run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences')
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_learns_correct_mealy_machine(self):
+ ground_truth = parity_mealy()
+ alphabet = ground_truth.get_input_alphabet()
+ traces = []
+ for level in range(1, 4):
+ for seq in product(alphabet, repeat=level):
+ ground_truth.reset_to_initial()
+ outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq)
+ traces.append(list(zip(seq, outputs)))
+ learned = run_GSM(traces, output_behavior='mealy', transition_behavior='deterministic',
+ data_format='io_traces')
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_dfa_via_moore_and_dfa_from_moore_conversion(self):
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ ground_truth = Dfa(q0, [q0, q1])
+
+ data = labeled_sequence_data(ground_truth, depth=3)
+ learned_moore = run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences')
+ learned_dfa = dfa_from_moore(learned_moore)
+ self.assertEqual(len(learned_dfa.states), 2)
+ self.assertTrue(bisimilar(learned_dfa, ground_truth))
+
+ def test_convert_false_returns_gsm_node(self):
+ data = [((), True)]
+ result = run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', convert=False)
+ self.assertIsInstance(result, GsmNode)
+
+ def test_raises_for_invalid_output_behavior(self):
+ with self.assertRaises(ValueError):
+ GeneralizedStateMerging(output_behavior='invalid')
+
+ def test_raises_for_invalid_transition_behavior(self):
+ with self.assertRaises(ValueError):
+ GeneralizedStateMerging(transition_behavior='invalid')
+
+ def test_raises_for_nondeterministic_data_with_deterministic_behavior(self):
+ gsm = GeneralizedStateMerging(output_behavior='mealy', transition_behavior='deterministic')
+ # two different outputs for the same input from the root is nondeterministic
+ traces = [[('a', 'x')], [('a', 'y')]]
+ with self.assertRaises(ValueError):
+ gsm.run(traces, data_format='io_traces')
+
+ def test_missing_score_calc_for_nondeterministic_behavior_raises(self):
+ with self.assertRaises(ValueError):
+ GeneralizedStateMerging(transition_behavior='nondeterministic')
+
+
+class TestRunGsmInstrumentation(unittest.TestCase):
+ def test_instrumentation_hooks_are_invoked(self):
+ ground_truth = alternating_moore()
+ data = labeled_sequence_data(ground_truth, depth=3)
+ instrumentation = RecordingInstrumentation()
+
+ run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', instrumentation=instrumentation)
+
+ self.assertTrue(instrumentation.reset_called)
+ self.assertTrue(instrumentation.pta_done)
+ self.assertTrue(instrumentation.learning_done_called)
+ # some merges should have happened since the ground truth is only 2 states
+ self.assertGreater(len(instrumentation.merges), 0)
+
+
+class TestRunGsmPreprocessingPostprocessing(unittest.TestCase):
+ def test_preprocessing_and_postprocessing_are_applied(self):
+ calls = []
+
+ def pta_preprocessing(root):
+ calls.append('pre')
+ return root
+
+ def postprocessing(root):
+ calls.append('post')
+ return root
+
+ data = [((), True), (('a',), False)]
+ run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', pta_preprocessing=pta_preprocessing,
+ postprocessing=postprocessing)
+
+ self.assertEqual(calls, ['pre', 'post'])
+
+
+class TestConsiderOnlyMinBlueAndDepthFirst(unittest.TestCase):
+ def test_consider_only_min_blue_still_learns_correct_model(self):
+ ground_truth = alternating_moore()
+ data = labeled_sequence_data(ground_truth, depth=3)
+ learned = run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', consider_only_min_blue=True)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_depth_first_still_learns_correct_model(self):
+ ground_truth = alternating_moore()
+ data = labeled_sequence_data(ground_truth, depth=3)
+ learned = run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', depth_first=True,
+ compatibility_on_futures=True)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/general_passive/test_gsm_algorithms.py b/tests/learning_algs/general_passive/test_gsm_algorithms.py
new file mode 100644
index 00000000000..9e88397a168
--- /dev/null
+++ b/tests/learning_algs/general_passive/test_gsm_algorithms.py
@@ -0,0 +1,196 @@
+import random
+import unittest
+from itertools import product
+
+from aalpy.automata import (
+ Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState, Mdp, MdpState, StochasticMealyMachine,
+ StochasticMealyState,
+)
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs.general_passive.GsmAlgorithms import run_EDSM, run_Alergia_EDSM, run_k_tails
+from aalpy.utils.ModelChecking import bisimilar
+
+
+def even_a_dfa():
+ q0 = DfaState('q0', is_accepting=True)
+ q1 = DfaState('q1', is_accepting=False)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return Dfa(q0, [q0, q1])
+
+
+def alternating_moore():
+ q0 = MooreState('q0', 0)
+ q1 = MooreState('q1', 1)
+ q0.transitions = {'a': q1, 'b': q0}
+ q1.transitions = {'a': q0, 'b': q1}
+ return MooreMachine(q0, [q0, q1])
+
+
+def parity_mealy():
+ q0 = MealyState('q0')
+ q1 = MealyState('q1')
+ q0.transitions = {'a': q1, 'b': q0}
+ q0.output_fun = {'a': 'x', 'b': 'y'}
+ q1.transitions = {'a': q0, 'b': q1}
+ q1.output_fun = {'a': 'y', 'b': 'x'}
+ return MealyMachine(q0, [q0, q1])
+
+
+def labeled_sequence_data(automaton, depth=3):
+ data = []
+ alphabet = automaton.get_input_alphabet()
+ is_mealy = isinstance(automaton, MealyMachine)
+ start_level = 1 if is_mealy else 0
+ for level in range(start_level, depth + 1):
+ for seq in product(alphabet, repeat=level):
+ automaton.reset_to_initial()
+ if len(seq) == 0:
+ label = automaton.initial_state.output
+ else:
+ outputs = automaton.execute_sequence(automaton.initial_state, seq)
+ label = outputs[-1]
+ data.append((seq, label))
+ return data
+
+
+class TestRunEdsm(unittest.TestCase):
+ def test_learns_minimal_dfa(self):
+ ground_truth = even_a_dfa()
+ data = labeled_sequence_data(ground_truth, depth=3)
+ learned = run_EDSM(data, automaton_type='dfa', print_info=False)
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_learns_minimal_moore_machine(self):
+ ground_truth = alternating_moore()
+ data = labeled_sequence_data(ground_truth, depth=3)
+ learned = run_EDSM(data, automaton_type='moore', print_info=False)
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_learns_minimal_mealy_machine(self):
+ ground_truth = parity_mealy()
+ data = labeled_sequence_data(ground_truth, depth=3)
+ learned = run_EDSM(data, automaton_type='mealy', print_info=False)
+ self.assertEqual(len(learned.states), 2)
+ self.assertTrue(bisimilar(learned, ground_truth))
+
+ def test_input_completeness_sink_state(self):
+ data = [((), True), (('a',), False), (('b',), True)]
+ learned = run_EDSM(data, automaton_type='dfa', input_completeness='sink_state', print_info=False)
+ self.assertTrue(learned.is_input_complete())
+
+ def test_input_completeness_self_loop(self):
+ data = [((), True), (('a',), False), (('b',), True)]
+ learned = run_EDSM(data, automaton_type='dfa', input_completeness='self_loop', print_info=False)
+ self.assertTrue(learned.is_input_complete())
+
+
+def nd_moore_output_sequence_is_possible(machine, inputs, expected_outputs):
+ """
+ Checks whether there exists at least one path through the (possibly nondeterministic) Moore machine
+ that reproduces the expected output sequence for the given inputs.
+ """
+ current_states = {machine.initial_state}
+ for in_sym, expected_out in zip(inputs, expected_outputs):
+ next_states = set()
+ for state in current_states:
+ for target in state.transitions.get(in_sym, []):
+ if target.output == expected_out:
+ next_states.add(target)
+ if not next_states:
+ return False
+ current_states = next_states
+ return True
+
+
+class TestRunKTails(unittest.TestCase):
+ def io_traces_for_moore(self, ground_truth, depth=4):
+ alphabet = ground_truth.get_input_alphabet()
+ traces = []
+ for level in range(1, depth + 1):
+ for seq in product(alphabet, repeat=level):
+ ground_truth.reset_to_initial()
+ outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq)
+ traces.append([ground_truth.initial_state.output] + list(zip(seq, outputs)))
+ return traces
+
+ def test_learned_model_reproduces_training_traces_with_large_k(self):
+ ground_truth = alternating_moore()
+ traces = self.io_traces_for_moore(ground_truth, depth=4)
+
+ learned = run_k_tails(traces, automaton_type='moore', k=10, print_info=False)
+
+ for trace in traces:
+ initial_output = trace[0]
+ inputs = [i for i, _ in trace[1:]]
+ outputs = [o for _, o in trace[1:]]
+ self.assertEqual(learned.initial_state.output, initial_output)
+ self.assertTrue(nd_moore_output_sequence_is_possible(learned, inputs, outputs),
+ f'trace {trace} not reproducible by learned k-tails model')
+
+ def test_small_k_merges_more_aggressively_than_large_k(self):
+ # with a small k, compatibility is only checked shallowly, so more (possibly behaviorally
+ # different) states get merged, generally yielding a smaller (or equal) automaton than a
+ # large k that checks compatibility much more thoroughly.
+ ground_truth = alternating_moore()
+ traces = self.io_traces_for_moore(ground_truth, depth=4)
+
+ learned_small_k = run_k_tails(traces, automaton_type='moore', k=0, print_info=False)
+ learned_large_k = run_k_tails(traces, automaton_type='moore', k=10, print_info=False)
+
+ self.assertLessEqual(len(learned_small_k.states), len(learned_large_k.states))
+
+
+class TestRunAlergiaEdsm(unittest.TestCase):
+ def deterministic_mdp(self):
+ """3-state MDP with only probability-1 transitions, so its behavior is effectively deterministic."""
+ q0 = MdpState('q0', output='label')
+ q1 = MdpState('q1', output='label')
+ q2 = MdpState('q2', output='label')
+ q0.transitions['a'] = [(q1, 1.0)]
+ q0.transitions['b'] = [(q0, 1.0)]
+ q1.transitions['a'] = [(q2, 1.0)]
+ q1.transitions['b'] = [(q0, 1.0)]
+ q2.transitions['a'] = [(q2, 1.0)]
+ q2.transitions['b'] = [(q1, 1.0)]
+ return Mdp(q0, [q0, q1, q2])
+
+ def generate_traces(self, mdp, num_traces=200, max_len=6, seed=1):
+ random.seed(seed)
+ sul = AutomatonSUL(mdp)
+ alphabet = mdp.get_input_alphabet()
+ traces = []
+ for _ in range(num_traces):
+ length = random.randint(1, max_len)
+ inputs = [random.choice(alphabet) for _ in range(length)]
+ outputs = sul.query(tuple(inputs))
+ trace = [mdp.initial_state.output]
+ for i, o in zip(inputs, outputs):
+ trace.append((i, o))
+ traces.append(trace)
+ return traces
+
+ def test_learns_model_matching_deterministic_ground_truth_behavior(self):
+ ground_truth = self.deterministic_mdp()
+ traces = self.generate_traces(ground_truth)
+
+ learned = run_Alergia_EDSM(traces, automaton_type='mdp', eps=0.05, print_info=False)
+
+ random.seed(7)
+ alphabet = ground_truth.get_input_alphabet()
+ for _ in range(50):
+ inputs = tuple(random.choice(alphabet) for _ in range(random.randint(1, 6)))
+
+ ground_truth.reset_to_initial()
+ expected = ground_truth.execute_sequence(ground_truth.initial_state, inputs)
+
+ learned.reset_to_initial()
+ actual = learned.execute_sequence(learned.initial_state, inputs)
+
+ self.assertEqual(actual, expected, f'mismatch on {inputs}')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/general_passive/test_gsm_node.py b/tests/learning_algs/general_passive/test_gsm_node.py
new file mode 100644
index 00000000000..55fc5b8ba67
--- /dev/null
+++ b/tests/learning_algs/general_passive/test_gsm_node.py
@@ -0,0 +1,294 @@
+import unittest
+
+from aalpy.learning_algs.general_passive.GsmNode import (
+ GsmNode, TransitionInfo, detect_data_format, intersection_iterator, union_iterator, unknown_output,
+)
+
+
+class TestIterators(unittest.TestCase):
+ def test_intersection_iterator_only_common_keys(self):
+ a = {'x': 1, 'y': 2}
+ b = {'y': 20, 'z': 30}
+ result = list(intersection_iterator(a, b))
+ self.assertEqual(result, [('y', 2, 20)])
+
+ def test_union_iterator_uses_default_for_missing(self):
+ a = {'x': 1}
+ b = {'y': 2}
+ result = sorted(union_iterator(a, b, default=-1))
+ self.assertEqual(result, [('x', 1, -1), ('y', -1, 2)])
+
+
+class TestDetectDataFormat(unittest.TestCase):
+ def test_empty_data_defaults_to_io_traces(self):
+ self.assertEqual(detect_data_format([]), 'io_traces')
+
+ def test_gsm_node_is_tree_format(self):
+ node = GsmNode((None, unknown_output), None)
+ self.assertEqual(detect_data_format(node), 'tree')
+
+ def test_labeled_sequences_detected(self):
+ data = [(('a', 'b'), 1), (('a',), 2)]
+ self.assertEqual(detect_data_format(data), 'labeled_sequences')
+
+ def test_io_traces_detected(self):
+ data = [[('a', 'x'), ('b', 'y')], [('a', 'x')]]
+ self.assertEqual(detect_data_format(data), 'io_traces')
+
+ def test_non_sequence_data_raises(self):
+ with self.assertRaises(ValueError):
+ detect_data_format([1, 2, 3])
+
+ def test_ambiguous_short_traces_default_without_consistency_check(self):
+ # a single 2-tuple could be a labeled_sequence or an io_trace of length 1;
+ # without check_consistency, the format is decided as soon as unambiguous, using the first entry
+ data = [(('a',), 1)]
+ fmt = detect_data_format(data)
+ self.assertIn(fmt, ('labeled_sequences', 'io_traces'))
+
+
+class TestGsmNodeBasics(unittest.TestCase):
+ def test_root_has_no_predecessor_and_zero_prefix_length(self):
+ root = GsmNode((None, unknown_output), None)
+ self.assertIsNone(root.predecessor)
+ self.assertEqual(root.get_prefix_length(), 0)
+ self.assertEqual(root.get_prefix(), [])
+
+ def test_add_trace_builds_chain_with_correct_prefix(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x'), ('b', 'y')])
+ node_a = root.transitions['a']['x'].target
+ node_ab = node_a.transitions['b']['y'].target
+ self.assertEqual(node_a.get_prefix_length(), 1)
+ self.assertEqual(node_ab.get_prefix(), [('a', 'x'), ('b', 'y')])
+ self.assertIs(node_ab.get_root(), root)
+
+ def test_add_trace_increments_count_for_repeated_trace(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ root.add_trace([('a', 'x')])
+ t_info = root.transitions['a']['x']
+ self.assertEqual(t_info.count, 2)
+ self.assertEqual(t_info.original_count, 2)
+
+ def test_get_by_prefix_returns_none_for_undefined_path(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ self.assertIsNone(root.get_by_prefix([('b', 'y')]))
+
+ def test_get_by_prefix_ignores_leading_none_input(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ node = root.get_by_prefix([(None, 'initial'), ('a', 'x')])
+ self.assertIs(node, root.transitions['a']['x'].target)
+
+ def test_get_all_nodes_includes_root_and_children(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x'), ('b', 'y')])
+ nodes = root.get_all_nodes()
+ self.assertEqual(len(nodes), 3)
+
+ def test_is_tree_true_for_pta(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ root.add_trace([('b', 'y')])
+ self.assertTrue(root.is_tree())
+
+ def test_is_tree_false_when_node_shared(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ shared = root.transitions['a']['x'].target
+ # manually introduce a shared target to simulate a merged (non-tree) structure
+ root.transitions['b']['y'] = TransitionInfo(shared, 1, None, None)
+ self.assertFalse(root.is_tree())
+
+ def test_shallow_copy_shares_targets_but_independent_transitions_dict(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ copy = root.shallow_copy()
+ self.assertIsNot(copy.transitions, root.transitions)
+ self.assertIs(copy.transitions['a']['x'].target, root.transitions['a']['x'].target)
+ copy.transitions['b']['y'] = TransitionInfo(copy, 1, None, None)
+ self.assertNotIn('b', root.transitions)
+
+ def test_make_input_complete_adds_self_loops_for_missing_inputs(self):
+ root = GsmNode((None, 'root_out'), None)
+ root.add_trace([('a', 'x')])
+ # 'b' is used elsewhere in the tree but not from root
+ node_a = root.transitions['a']['x'].target
+ node_a.add_trace([('b', 'y')])
+ missing = root.make_input_complete()
+ self.assertIn((root, 'b', 'root_out'), missing)
+ self.assertIs(root.transitions['b']['root_out'].target, root)
+
+
+class TestGsmNodeOrderingAndOutputs(unittest.TestCase):
+ def test_lt_orders_by_prefix_length_then_lexicographically(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 1), ('a', 1)])
+ root.add_trace([('b', 1)])
+ node_a = root.transitions['a'][1].target
+ node_b = root.transitions['b'][1].target
+ node_aa = node_a.transitions['a'][1].target
+ self.assertTrue(node_a < node_aa)
+ self.assertTrue(node_a < node_b) # same length, 'a' < 'b'
+ self.assertFalse(node_b < node_a)
+
+ def test_resolve_unknown_prefix_output_only_updates_if_unknown(self):
+ node = GsmNode(('a', unknown_output), None)
+ node.resolve_unknown_prefix_output('resolved')
+ self.assertEqual(node.get_prefix_output(), 'resolved')
+ node.resolve_unknown_prefix_output('other')
+ self.assertEqual(node.get_prefix_output(), 'resolved')
+
+ def test_add_labeled_sequence_sets_prefix_output_on_final_node(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_labeled_sequence((('a', 'b'), 'label1'))
+ # only the final step's transition dict key is resolved from unknown_output to the real label;
+ # intermediate steps remain keyed by unknown_output.
+ node = root.get_by_prefix([('a', unknown_output), ('b', 'label1')])
+ self.assertIsNotNone(node)
+ self.assertEqual(node.get_prefix_output(), 'label1')
+
+ def test_add_labeled_sequence_raises_on_conflicting_label_for_same_sequence(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_labeled_sequence((('a',), 'out1'))
+ with self.assertRaises(ValueError):
+ root.add_labeled_sequence((('a',), 'out2'))
+
+ def test_is_locally_deterministic_true_for_single_output_per_input(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ self.assertTrue(root.is_locally_deterministic())
+
+ def test_is_locally_deterministic_false_for_two_outputs_same_input(self):
+ root = GsmNode((None, unknown_output), None)
+ root.transitions['a']['x'] = TransitionInfo(GsmNode(('a', 'x'), root), 1, None, None)
+ root.transitions['a']['y'] = TransitionInfo(GsmNode(('a', 'y'), root), 1, None, None)
+ self.assertFalse(root.is_locally_deterministic())
+ self.assertFalse(root.is_deterministic())
+
+ def test_deterministic_compatible_true_when_no_shared_inputs(self):
+ n1 = GsmNode((None, unknown_output), None)
+ n1.add_trace([('a', 'x')])
+ n2 = GsmNode((None, unknown_output), None)
+ n2.add_trace([('b', 'y')])
+ self.assertTrue(n1.deterministic_compatible(n2))
+
+ def test_deterministic_compatible_false_on_output_mismatch_for_shared_input(self):
+ n1 = GsmNode((None, unknown_output), None)
+ n1.add_trace([('a', 'x')])
+ n2 = GsmNode((None, unknown_output), None)
+ n2.add_trace([('a', 'y')])
+ self.assertFalse(n1.deterministic_compatible(n2))
+
+ def test_deterministic_compatible_true_when_unknown_output_present(self):
+ n1 = GsmNode((None, unknown_output), None)
+ n1.transitions['a'][unknown_output] = TransitionInfo(GsmNode(('a', unknown_output), n1), 1, None, None)
+ n2 = GsmNode((None, unknown_output), None)
+ n2.add_trace([('a', 'x')])
+ self.assertTrue(n1.deterministic_compatible(n2))
+
+ def test_is_moore_true_when_child_output_matches_transition_output(self):
+ root = GsmNode((None, 'root_out'), None)
+ root.add_trace([('a', 'child_out')])
+ self.assertTrue(root.is_moore())
+
+ def test_is_moore_false_when_child_output_mismatches(self):
+ root = GsmNode((None, 'root_out'), None)
+ child = GsmNode(('a', 'transition_out'), root)
+ root.transitions['a']['transition_out'] = TransitionInfo(child, 1, None, None)
+ child.prefix_access_pair = ('a', 'different_child_out')
+ self.assertFalse(root.is_moore())
+
+ def test_moore_compatible_true_for_matching_or_unknown_outputs(self):
+ n1 = GsmNode(('a', 'x'), None)
+ n2 = GsmNode(('a', 'x'), None)
+ n3 = GsmNode(('a', unknown_output), None)
+ self.assertTrue(n1.moore_compatible(n2))
+ self.assertTrue(n1.moore_compatible(n3))
+
+ def test_moore_compatible_false_for_conflicting_outputs(self):
+ n1 = GsmNode(('a', 'x'), None)
+ n2 = GsmNode(('a', 'y'), None)
+ self.assertFalse(n1.moore_compatible(n2))
+
+ def test_count_sums_transition_counts(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ root.add_trace([('a', 'x')])
+ root.add_trace([('b', 'y')])
+ self.assertEqual(root.count(), 3)
+
+ def test_local_log_likelihood_contribution_zero_for_single_outcome(self):
+ # a deterministic transition (single outcome for its input) contributes 0 to the log-likelihood,
+ # since n*log(n) - n*log(n) == 0 regardless of count.
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ root.add_trace([('a', 'x')])
+ self.assertAlmostEqual(root.local_log_likelihood_contribution(), 0.0)
+
+ def test_local_log_likelihood_contribution_negative_for_split_outcomes(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ root.add_trace([('a', 'y')])
+ self.assertLess(root.local_log_likelihood_contribution(), 0.0)
+
+
+class TestGsmNodeCreatePTA(unittest.TestCase):
+ def test_labeled_sequences_format(self):
+ data = [(('a', 'b'), 1), (('a', 'c'), 2)]
+ root = GsmNode.createPTA(data, output_behavior='moore', data_format='labeled_sequences')
+ node_a = root.transitions['a'][unknown_output].target
+ self.assertEqual(node_a.get_prefix_length(), 1)
+
+ def test_io_traces_moore_uses_first_output_as_root_output(self):
+ data = [[0, ('a', 1)], [0, ('a', 1)]]
+ root = GsmNode.createPTA(data, output_behavior='moore', data_format='io_traces')
+ self.assertEqual(root.get_prefix_output(), 0)
+
+ def test_io_traces_mealy_has_no_root_output(self):
+ data = [[('a', 'x')]]
+ root = GsmNode.createPTA(data, output_behavior='mealy', data_format='io_traces')
+ self.assertEqual(root.get_prefix_output(), unknown_output)
+
+ def test_tree_format_passthrough_requires_tree_structure(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ result = GsmNode.createPTA(root, output_behavior='mealy', data_format='tree')
+ self.assertIs(result, root)
+
+ def test_tree_format_rejects_non_tree(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ shared = root.transitions['a']['x'].target
+ root.transitions['b']['y'] = TransitionInfo(shared, 1, None, None)
+ with self.assertRaises(ValueError):
+ GsmNode.createPTA(root, output_behavior='mealy', data_format='tree')
+
+
+class TestGsmNodeToAutomaton(unittest.TestCase):
+ def test_to_automaton_deterministic_moore(self):
+ root = GsmNode((None, 0), None)
+ root.add_trace([('a', 1)])
+ automaton = root.to_automaton('moore', 'deterministic')
+ self.assertEqual(automaton.initial_state.output, 0)
+ self.assertEqual(automaton.initial_state.transitions['a'].output, 1)
+
+ def test_to_automaton_raises_on_non_moore_structure_when_moore_requested(self):
+ root = GsmNode((None, 'root_out'), None)
+ child = GsmNode(('a', 'transition_out'), root)
+ root.transitions['a']['transition_out'] = TransitionInfo(child, 1, None, None)
+ child.prefix_access_pair = ('a', 'different_output')
+ with self.assertRaises(ValueError):
+ root.to_automaton('moore', 'deterministic')
+
+ def test_to_automaton_deterministic_mealy(self):
+ root = GsmNode((None, unknown_output), None)
+ root.add_trace([('a', 'x')])
+ automaton = root.to_automaton('mealy', 'deterministic')
+ self.assertEqual(automaton.initial_state.output_fun['a'], 'x')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/general_passive/test_instrumentation.py b/tests/learning_algs/general_passive/test_instrumentation.py
new file mode 100644
index 00000000000..cd7d88d8322
--- /dev/null
+++ b/tests/learning_algs/general_passive/test_instrumentation.py
@@ -0,0 +1,79 @@
+import unittest
+
+from aalpy.learning_algs.general_passive.GeneralizedStateMerging import run_GSM
+from aalpy.learning_algs.general_passive.Instrumentation import MergeViolationDebugger, ProgressReport
+from aalpy.learning_algs.general_passive.GsmNode import GsmNode, TransitionInfo
+
+
+class TestProgressReport(unittest.TestCase):
+ def test_records_pta_size_and_merge_counts_for_a_small_run(self):
+ data = [((), True), (('a',), True), (('b',), True)]
+ instrumentation = ProgressReport(lvl=2)
+
+ run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', instrumentation=instrumentation)
+
+ self.assertEqual(instrumentation.pta_size, 3)
+ # 'a' and 'b' children both have the same (True) output and no further transitions, so they merge
+ self.assertGreaterEqual(instrumentation.nr_merged_states_total, 1)
+ self.assertIn('pta creation time', instrumentation.stats)
+ self.assertIn('learning time', instrumentation.stats)
+ self.assertIn('total time', instrumentation.stats)
+
+ def test_lvl_zero_skips_detailed_tracking(self):
+ instrumentation = ProgressReport(lvl=0)
+ self.assertFalse(hasattr(instrumentation, 'log'))
+
+ data = [((), True), (('a',), False)]
+ # should not raise even though detailed tracking attributes are absent
+ run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', instrumentation=instrumentation)
+
+
+class TestMergeViolationDebugger(unittest.TestCase):
+ def build_matching_ground_truth_tree(self):
+ # data [((), True), (('a',), True), (('b',), True)] is only ever consistent with a single-state
+ # automaton that self-loops on 'a' and 'b'; the ground truth tree must reflect that so that the
+ # actual merges GSM performs (root with 'a', root with 'b') are considered correct.
+ root = GsmNode((None, True), None)
+ root.transitions['a'][True] = TransitionInfo(root, 1, None, None)
+ root.transitions['b'][True] = TransitionInfo(root, 1, None, None)
+ return root
+
+ def test_logs_correct_merges_and_promotions_against_ground_truth(self):
+ ground_truth = self.build_matching_ground_truth_tree()
+ debugger = MergeViolationDebugger(ground_truth)
+
+ data = [((), True), (('a',), True), (('b',), True)]
+ run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', instrumentation=debugger)
+
+ kinds = [entry[0] for entry in debugger.log]
+ self.assertIn('promote', kinds)
+ self.assertNotIn('wrong promote', kinds)
+ self.assertNotIn('wrong merge', kinds)
+ self.assertNotIn('broken merge', kinds)
+
+ def test_flags_wrong_merge_against_mismatched_ground_truth(self):
+ # a ground truth tree where 'a' and 'b' are distinct states never merges them;
+ # comparing against it while the actual run does merge them should be flagged as wrong.
+ mismatched_ground_truth = GsmNode((None, True), None)
+ mismatched_ground_truth.add_trace([('a', True)])
+ mismatched_ground_truth.add_trace([('b', True)])
+ # sabotage: make root.get_by_prefix for 'b' point to a node distinct from 'a's, but give it a
+ # different (non-tree) identity so the debugger's identity check for a real merge fails
+ debugger = MergeViolationDebugger(mismatched_ground_truth)
+
+ # data where 'a' and 'b' children both lead to identical (mergeable) leaves
+ data = [((), True), (('a',), True), (('b',), True)]
+ run_GSM(data, output_behavior='moore', transition_behavior='deterministic',
+ data_format='labeled_sequences', instrumentation=debugger)
+
+ kinds = [entry[0] for entry in debugger.log]
+ # since 'a' and 'b' are genuinely distinct nodes in the ground truth tree, merging them in the
+ # actual run must be flagged as a wrong merge.
+ self.assertIn('wrong merge', kinds)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/general_passive/test_score_functions_gsm.py b/tests/learning_algs/general_passive/test_score_functions_gsm.py
new file mode 100644
index 00000000000..5180f0a256e
--- /dev/null
+++ b/tests/learning_algs/general_passive/test_score_functions_gsm.py
@@ -0,0 +1,260 @@
+import unittest
+
+from aalpy.learning_algs.general_passive.GsmNode import GsmNode, TransitionInfo, unknown_output
+from aalpy.learning_algs.general_passive.ScoreFunctionsGSM import (
+ AIC_score, EDSM_frequency_score, EDSM_score, ScoreCalculation, ScoreCombinator, ScoreWithKTail,
+ ScoreWithSinks, differential_info, hoeffding_compatibility, local_to_global_compatibility, lower_threshold,
+ make_greedy, transform_score,
+)
+
+
+def node_with_counts(counts, prefix_access_pair=(None, unknown_output)):
+ """Builds a node whose single input 'i' has the given {output: count} outgoing transitions."""
+ node = GsmNode(prefix_access_pair, None)
+ for out_sym, count in counts.items():
+ target = GsmNode(('i', out_sym), node)
+ node.transitions['i'][out_sym] = TransitionInfo(target, count, target, count)
+ return node
+
+
+class TestScoreCalculationDefaults(unittest.TestCase):
+ def test_default_local_compatibility_always_true(self):
+ sc = ScoreCalculation()
+ self.assertTrue(sc.local_compatibility(GsmNode((None, None), None), GsmNode((None, None), None)))
+ self.assertFalse(sc.has_local_compatibility())
+
+ def test_default_score_function_always_true(self):
+ sc = ScoreCalculation()
+ self.assertTrue(sc.score_function({}))
+ self.assertFalse(sc.has_score_function())
+
+ def test_custom_functions_are_detected_as_overridden(self):
+ sc = ScoreCalculation(local_compatibility=lambda a, b: False, score_function=lambda p: 42)
+ self.assertTrue(sc.has_local_compatibility())
+ self.assertTrue(sc.has_score_function())
+
+
+class TestHoeffdingCompatibility(unittest.TestCase):
+ def test_identical_distributions_are_compatible(self):
+ a = node_with_counts({'x': 100, 'y': 100})
+ b = node_with_counts({'x': 100, 'y': 100})
+ compat = hoeffding_compatibility(0.05)
+ self.assertTrue(compat(a, b))
+
+ def test_very_different_distributions_are_incompatible(self):
+ a = node_with_counts({'x': 1000, 'y': 0})
+ b = node_with_counts({'x': 0, 'y': 1000})
+ compat = hoeffding_compatibility(0.05)
+ self.assertFalse(compat(a, b))
+
+ def test_zero_total_count_is_ignored(self):
+ a = node_with_counts({})
+ b = node_with_counts({'x': 100})
+ compat = hoeffding_compatibility(0.05)
+ self.assertTrue(compat(a, b))
+
+ def test_disjoint_inputs_are_compatible(self):
+ a = GsmNode((None, None), None)
+ a.transitions['i']['x'] = TransitionInfo(GsmNode(('i', 'x'), a), 100, GsmNode(('i', 'x'), a), 100)
+ b = GsmNode((None, None), None)
+ b.transitions['j']['y'] = TransitionInfo(GsmNode(('j', 'y'), b), 100, GsmNode(('j', 'y'), b), 100)
+ compat = hoeffding_compatibility(0.05)
+ self.assertTrue(compat(a, b))
+
+
+class TestScoreWithKTail(unittest.TestCase):
+ def test_beyond_depth_k_is_always_compatible(self):
+ always_false = ScoreCalculation(local_compatibility=lambda a, b: False)
+ wrapped = ScoreWithKTail(always_false, k=1)
+
+ root = GsmNode((None, None), None)
+ blue_shallow = GsmNode(('a', None), root)
+ blue_shallow_child = GsmNode(('a', None), blue_shallow)
+
+ wrapped.reset()
+ # first call establishes the depth offset at blue_shallow's depth (1)
+ self.assertFalse(wrapped.local_compatibility(root, blue_shallow))
+ # a node one level deeper than the offset (depth 2) is beyond k=1 -> compatible regardless
+ self.assertTrue(wrapped.local_compatibility(root, blue_shallow_child))
+
+ def test_within_depth_k_delegates_to_wrapped_score(self):
+ always_false = ScoreCalculation(local_compatibility=lambda a, b: False)
+ wrapped = ScoreWithKTail(always_false, k=5)
+ root = GsmNode((None, None), None)
+ blue = GsmNode(('a', None), root)
+ wrapped.reset()
+ self.assertFalse(wrapped.local_compatibility(root, blue))
+
+
+class TestScoreWithSinks(unittest.TestCase):
+ def test_rejects_merge_between_sink_and_non_sink(self):
+ always_true = ScoreCalculation(local_compatibility=lambda a, b: True)
+ is_sink = lambda n: n.get_prefix_output() == 'sink'
+ wrapped = ScoreWithSinks(always_true, sink_cond=is_sink)
+ wrapped.reset()
+
+ sink_node = GsmNode((None, 'sink'), None)
+ normal_node = GsmNode((None, 'normal'), None)
+ self.assertFalse(wrapped.local_compatibility(sink_node, normal_node))
+
+ def test_allows_merge_between_two_sinks_by_default(self):
+ always_true = ScoreCalculation(local_compatibility=lambda a, b: True)
+ is_sink = lambda n: n.get_prefix_output() == 'sink'
+ wrapped = ScoreWithSinks(always_true, sink_cond=is_sink)
+ wrapped.reset()
+
+ sink_a = GsmNode((None, 'sink'), None)
+ sink_b = GsmNode((None, 'sink'), None)
+ self.assertTrue(wrapped.local_compatibility(sink_a, sink_b))
+
+ def test_rejects_merge_between_two_sinks_when_disallowed(self):
+ always_true = ScoreCalculation(local_compatibility=lambda a, b: True)
+ is_sink = lambda n: n.get_prefix_output() == 'sink'
+ wrapped = ScoreWithSinks(always_true, sink_cond=is_sink, allow_sink_merge=False)
+ wrapped.reset()
+
+ sink_a = GsmNode((None, 'sink'), None)
+ sink_b = GsmNode((None, 'sink'), None)
+ self.assertFalse(wrapped.local_compatibility(sink_a, sink_b))
+
+ def test_sink_check_only_applies_on_first_call(self):
+ always_true = ScoreCalculation(local_compatibility=lambda a, b: True)
+ is_sink = lambda n: n.get_prefix_output() == 'sink'
+ wrapped = ScoreWithSinks(always_true, sink_cond=is_sink, allow_sink_merge=False)
+ wrapped.reset()
+
+ sink_a = GsmNode((None, 'sink'), None)
+ normal = GsmNode((None, 'normal'), None)
+ # consume the "first call" check with a compatible (non-sink) pair
+ self.assertTrue(wrapped.local_compatibility(normal, normal))
+ # subsequent calls skip the sink check entirely, so this doesn't get rejected
+ self.assertTrue(wrapped.local_compatibility(sink_a, sink_a))
+
+
+class TestScoreCombinator(unittest.TestCase):
+ def test_default_aggregate_compatibility_commits_to_first_non_none(self):
+ s1 = ScoreCalculation(local_compatibility=lambda a, b: None)
+ s2 = ScoreCalculation(local_compatibility=lambda a, b: False)
+ combined = ScoreCombinator([s1, s2])
+ self.assertFalse(combined.local_compatibility(None, None))
+
+ def test_default_aggregate_compatibility_true_when_all_none(self):
+ s1 = ScoreCalculation(local_compatibility=lambda a, b: None)
+ combined = ScoreCombinator([s1])
+ self.assertTrue(combined.local_compatibility(None, None))
+
+ def test_default_aggregate_score_collects_all_scores(self):
+ s1 = ScoreCalculation(score_function=lambda p: 1)
+ s2 = ScoreCalculation(score_function=lambda p: 2)
+ combined = ScoreCombinator([s1, s2])
+ self.assertEqual(combined.score_function({}), [1, 2])
+
+ def test_reset_delegates_to_all_scores(self):
+ calls = []
+
+ class Tracking(ScoreCalculation):
+ def reset(self):
+ calls.append(id(self))
+
+ s1, s2 = Tracking(), Tracking()
+ combined = ScoreCombinator([s1, s2])
+ combined.reset()
+ self.assertEqual(len(calls), 2)
+
+
+class TestLocalToGlobalCompatibility(unittest.TestCase):
+ def test_true_when_all_local_checks_pass(self):
+ fun = local_to_global_compatibility(lambda a, b: True)
+ self.assertTrue(fun({'old': 'new'}))
+
+ def test_false_when_any_local_check_fails(self):
+ fun = local_to_global_compatibility(lambda a, b: a != 'bad_new')
+ self.assertFalse(fun({'old': 'bad_new'}))
+
+
+class TestDifferentialInfo(unittest.TestCase):
+ def test_merging_identical_nodes_does_not_change_likelihood(self):
+ # merging two structurally identical nodes into one should not change the log-likelihood,
+ # but should reduce the number of parameters (fewer distinct transitions after the merge).
+ old1 = node_with_counts({'x': 5, 'y': 5})
+ old2 = node_with_counts({'x': 5, 'y': 5})
+ merged = node_with_counts({'x': 10, 'y': 10})
+ part = {old1: merged, old2: merged}
+ llh_diff, param_diff = differential_info(part)
+ self.assertAlmostEqual(llh_diff, 0.0)
+ self.assertGreater(param_diff, 0)
+
+
+class TestScoreTransforms(unittest.TestCase):
+ def test_transform_score_on_plain_value(self):
+ self.assertEqual(transform_score(5, lambda x: x * 2), 10)
+
+ def test_transform_score_on_callable(self):
+ fun = transform_score(lambda part: 5, lambda x: x * 2)
+ self.assertEqual(fun({}), 10)
+
+ def test_transform_score_on_score_calculation(self):
+ # regression test: transform_score used to reassign score.score_function to a lambda that
+ # referenced score.score_function again, causing infinite recursion on the first call.
+ sc = ScoreCalculation(score_function=lambda part: 5)
+ transformed = transform_score(sc, lambda x: x * 2)
+ self.assertIs(transformed, sc)
+ self.assertEqual(transformed.score_function({}), 10)
+
+ def test_transform_score_on_score_calculation_can_be_applied_twice(self):
+ sc = ScoreCalculation(score_function=lambda part: 5)
+ transform_score(sc, lambda x: x * 2)
+ transform_score(sc, lambda x: x + 1)
+ self.assertEqual(sc.score_function({}), 11)
+
+ def test_make_greedy_rejects_only_false(self):
+ self.assertTrue(make_greedy(0))
+ self.assertTrue(make_greedy('anything'))
+ self.assertFalse(make_greedy(False))
+
+ def test_lower_threshold_rejects_values_at_or_below_threshold(self):
+ self.assertEqual(lower_threshold(5, 3), 5)
+ self.assertFalse(lower_threshold(3, 3))
+ self.assertFalse(lower_threshold(1, 3))
+
+
+class TestBuiltinScoreFunctions(unittest.TestCase):
+ def test_aic_score_rejects_partitions_below_threshold(self):
+ score_fun = AIC_score(alpha=1000)
+ old1 = node_with_counts({'x': 5})
+ merged = node_with_counts({'x': 5})
+ result = score_fun({old1: merged})
+ self.assertFalse(result)
+
+ def test_edsm_frequency_score_counts_contradicted_evidence(self):
+ score_fun = EDSM_frequency_score(min_evidence=-1)
+ old_node = node_with_counts({'x': 5})
+ new_node = node_with_counts({'x': 10}) # count changed by the merge -> contradicted evidence
+ result = score_fun({old_node: new_node})
+ self.assertEqual(result, 5)
+
+ def test_edsm_frequency_score_rejects_below_min_evidence(self):
+ score_fun = EDSM_frequency_score(min_evidence=10)
+ old_node = node_with_counts({'x': 5})
+ new_node = node_with_counts({'x': 10})
+ result = score_fun({old_node: new_node})
+ self.assertFalse(result)
+
+ def test_edsm_score_counts_merged_minus_partitions(self):
+ score_fun = EDSM_score(min_evidence=-1)
+ merged = node_with_counts({})
+ part = {node_with_counts({}): merged, node_with_counts({}): merged, node_with_counts({}): 'other'}
+ result = score_fun(part)
+ # 3 original nodes map to 2 distinct partition representatives -> 3 - 2 = 1
+ self.assertEqual(result, 1)
+
+ def test_edsm_score_rejects_below_min_evidence(self):
+ score_fun = EDSM_score(min_evidence=5)
+ merged = node_with_counts({})
+ part = {node_with_counts({}): merged, node_with_counts({}): merged}
+ result = score_fun(part)
+ self.assertFalse(result)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py
new file mode 100644
index 00000000000..40b93f59e17
--- /dev/null
+++ b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py
@@ -0,0 +1,67 @@
+import random
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs import run_abstracted_ONFSM_Lstar
+from aalpy.oracles import RandomWalkEqOracle, RandomWordEqOracle
+from aalpy.utils import get_benchmark_ONFSM
+
+SEEDS = range(8)
+
+
+class TestRunAbstractedOnfsmLstar(unittest.TestCase):
+ def test_learns_benchmark_onfsm_with_identity_abstraction(self):
+ # An empty abstraction mapping means every output maps to itself (see
+ # AbstractedNonDetObservationTable.get_abstraction), so this should learn a model
+ # equivalent to the plain (non-abstracted) ONFSM learner's result.
+ onfsm = get_benchmark_ONFSM()
+ alphabet = onfsm.get_input_alphabet()
+
+ for seed in SEEDS:
+ random.seed(seed)
+ sul = AutomatonSUL(onfsm)
+ oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5)
+
+ learned_onfsm = run_abstracted_ONFSM_Lstar(alphabet, sul, oracle, abstraction_mapping={},
+ n_sampling=20, print_level=0)
+
+ eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=3000, reset_prob=0.09,
+ reset_after_cex=True)
+ cex = eq_oracle.find_cex(learned_onfsm)
+
+ self.assertIsNone(cex, f'seed {seed}: independent oracle found a counterexample')
+ self.assertEqual(len(learned_onfsm.states), len(onfsm.states),
+ f'seed {seed}: learned model has wrong number of states')
+
+ def test_real_abstraction_yields_a_model_no_larger_than_the_identity_one(self):
+ # Grouping outputs 0 and 2 into the same equivalence class can only merge states, never
+ # split them, so the abstracted model should never have more states than the ground truth.
+ random.seed(0)
+ onfsm = get_benchmark_ONFSM()
+ alphabet = onfsm.get_input_alphabet()
+ sul = AutomatonSUL(onfsm)
+ oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5)
+
+ learned_onfsm = run_abstracted_ONFSM_Lstar(alphabet, sul, oracle,
+ abstraction_mapping={0: 'low', 2: 'low', 3: 'high'},
+ n_sampling=20, print_level=0)
+
+ self.assertLessEqual(len(learned_onfsm.states), len(onfsm.states))
+
+ def test_return_data_reports_consistent_learning_info(self):
+ random.seed(0)
+ onfsm = get_benchmark_ONFSM()
+ alphabet = onfsm.get_input_alphabet()
+ sul = AutomatonSUL(onfsm)
+ oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5)
+
+ learned_onfsm, info = run_abstracted_ONFSM_Lstar(alphabet, sul, oracle, abstraction_mapping={},
+ n_sampling=20, print_level=0, return_data=True)
+
+ self.assertEqual(info['automaton_size'], len(learned_onfsm.states))
+ self.assertGreaterEqual(info['learning_rounds'], 1)
+ self.assertGreaterEqual(info['queries_learning'], 1)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py
new file mode 100644
index 00000000000..72a56613896
--- /dev/null
+++ b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py
@@ -0,0 +1,149 @@
+import random
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs.non_deterministic.AbstractedOnfsmObservationTable import AbstractedNonDetObservationTable
+from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper
+from aalpy.utils import get_benchmark_ONFSM
+
+ABSTRACTION = {0: 'even', 2: 'even', 3: 'odd'}
+
+
+def wrapped_sul():
+ return NonDeterministicSULWrapper(AutomatonSUL(get_benchmark_ONFSM()))
+
+
+def initial_round(at):
+ """Mirrors the first few lines of run_abstracted_ONFSM_Lstar: the initial row must be queried
+ and abstracted before S_dot_A/T are populated enough for get_row_to_close() to work."""
+ at.update_obs_table()
+ new_rows = at.update_extended_S()
+ at.update_obs_table(s_set=new_rows)
+
+
+class TestConstruction(unittest.TestCase):
+ def test_initializes_empty_S_dot_A_and_E(self):
+ at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10)
+ self.assertEqual(at.S, [((), ())])
+ self.assertEqual(at.S_dot_A, [])
+ self.assertEqual(at.E, [])
+ self.assertEqual(at.A, [('a',), ('b',)])
+
+ def test_wraps_a_plain_non_det_observation_table_internally(self):
+ at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10)
+ self.assertEqual(at.observation_table.alphabet, ['a', 'b'])
+
+ def test_asserts_on_missing_alphabet_or_sul(self):
+ with self.assertRaises(AssertionError):
+ AbstractedNonDetObservationTable(None, wrapped_sul(), ABSTRACTION, 10)
+ with self.assertRaises(AssertionError):
+ AbstractedNonDetObservationTable(['a'], None, ABSTRACTION, 10)
+
+
+class TestGetAbstraction(unittest.TestCase):
+ def test_maps_known_outputs_to_their_equivalence_class(self):
+ at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10)
+ self.assertEqual(at.get_abstraction(0), 'even')
+ self.assertEqual(at.get_abstraction(2), 'even')
+ self.assertEqual(at.get_abstraction(3), 'odd')
+
+ def test_falls_back_to_the_original_output_when_unmapped(self):
+ at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10)
+ self.assertEqual(at.get_abstraction('unmapped_output'), 'unmapped_output')
+
+
+class TestAbstractObsTable(unittest.TestCase):
+ def test_T_contains_abstracted_outputs_not_raw_ones(self):
+ random.seed(0)
+ sul = wrapped_sul()
+ at = AbstractedNonDetObservationTable(sul.sul.automaton.get_input_alphabet(), sul, ABSTRACTION, 20)
+ at.update_obs_table()
+
+ raw_traces = sul.cache.get_all_traces(((), ()), ('a',))
+ self.assertTrue(any(t[0] in (0, 2, 3) for t in raw_traces))
+
+ abstracted_cell = at.T[((), ())][('a',)]
+ for value in abstracted_cell:
+ self.assertIn(value, [('even',), ('odd',)])
+
+ def test_raw_cache_keeps_concrete_outputs_alongside_the_abstraction(self):
+ random.seed(0)
+ sul = wrapped_sul()
+ alphabet = sul.sul.automaton.get_input_alphabet()
+ at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20)
+ at.update_obs_table()
+ raw_cell = sul.cache.get_all_traces(((), ()), ('a',))
+ self.assertIn((0,), raw_cell)
+
+
+class TestGetRowToClose(unittest.TestCase):
+ def test_moves_a_row_from_S_dot_A_into_S(self):
+ random.seed(0)
+ sul = wrapped_sul()
+ alphabet = sul.sul.automaton.get_input_alphabet()
+ at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20)
+ initial_round(at)
+
+ s_before = list(at.S)
+ s_dot_a_before = list(at.S_dot_A)
+ row = at.get_row_to_close()
+
+ self.assertIsNotNone(row)
+ self.assertIn(row, at.S)
+ self.assertNotIn(row, at.S_dot_A)
+ self.assertIn(row, s_dot_a_before)
+ self.assertNotIn(row, s_before)
+
+ def test_repeated_calls_do_not_require_re_querying_in_between(self):
+ # Unlike NonDetObservationTable.get_row_to_close, which needs a fresh
+ # query_missing_observations() call between invocations, this abstracted variant works off
+ # S_dot_A directly, so consecutive calls can each pull a different row without re-querying.
+ random.seed(0)
+ sul = wrapped_sul()
+ alphabet = sul.sul.automaton.get_input_alphabet()
+ at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20)
+ initial_round(at)
+
+ first = at.get_row_to_close()
+ second = at.get_row_to_close()
+ self.assertIsNotNone(first)
+ self.assertIsNotNone(second)
+ self.assertNotEqual(first, second)
+
+
+class TestCleanObsTable(unittest.TestCase):
+ def test_removes_duplicate_rows_from_S(self):
+ random.seed(0)
+ sul = wrapped_sul()
+ alphabet = sul.sul.automaton.get_input_alphabet()
+ at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20)
+ initial_round(at)
+
+ row_to_close = at.get_row_to_close()
+ while row_to_close is not None:
+ row_to_close = at.get_row_to_close()
+
+ sizes_before = len(at.S)
+ at.clean_obs_table()
+ self.assertLessEqual(len(at.S), sizes_before)
+
+
+class TestExtendSDotA(unittest.TestCase):
+ def test_only_adds_prefixes_not_already_present(self):
+ at = AbstractedNonDetObservationTable(['a'], wrapped_sul(), ABSTRACTION, 10)
+ cex_prefixes = [(('a',), (0,))]
+ added_first = at.extend_S_dot_A(cex_prefixes)
+ added_second = at.extend_S_dot_A(cex_prefixes)
+ self.assertEqual(added_first, cex_prefixes)
+ self.assertEqual(added_second, [])
+ self.assertEqual(at.S_dot_A.count((('a',), (0,))), 1)
+
+ def test_does_not_add_prefixes_already_in_S(self):
+ at = AbstractedNonDetObservationTable(['a'], wrapped_sul(), ABSTRACTION, 10)
+ added = at.extend_S_dot_A([((), ())])
+ self.assertEqual(added, [])
+ self.assertEqual(at.S_dot_A, [])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py b/tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py
new file mode 100644
index 00000000000..21e7b186e29
--- /dev/null
+++ b/tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py
@@ -0,0 +1,121 @@
+import unittest
+
+from aalpy.base import SUL
+from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper
+from aalpy.learning_algs.non_deterministic.TraceTree import TraceTree
+
+
+class AlternatingNonDetSUL(SUL):
+ """A tiny hand-written non-deterministic SUL: 'a' alternates between 'x' and 'y' outputs,
+ 'b' always returns 'z'. Deterministic alternation (instead of random.choice) keeps the tests
+ reproducible while still exercising the wrapper's handling of multiple observed outputs."""
+
+ def __init__(self):
+ super().__init__()
+ self.pre_calls = 0
+ self.post_calls = 0
+ self.a_step_counter = 0
+
+ def pre(self):
+ self.pre_calls += 1
+
+ def post(self):
+ self.post_calls += 1
+
+ def step(self, letter):
+ if letter == 'a':
+ out = 'x' if self.a_step_counter % 2 == 0 else 'y'
+ self.a_step_counter += 1
+ else:
+ out = 'z'
+ return out
+
+
+class TestConstruction(unittest.TestCase):
+ def test_wraps_given_sul_and_creates_empty_cache(self):
+ raw = AlternatingNonDetSUL()
+ wrapper = NonDeterministicSULWrapper(raw)
+ self.assertIs(wrapper.sul, raw)
+ self.assertIsInstance(wrapper.cache, TraceTree)
+ self.assertIsNone(wrapper.cache.curr_node)
+
+
+class TestPreAndPost(unittest.TestCase):
+ def test_pre_resets_cache_cursor_and_delegates_to_wrapped_sul(self):
+ raw = AlternatingNonDetSUL()
+ wrapper = NonDeterministicSULWrapper(raw)
+ wrapper.pre()
+ self.assertIs(wrapper.cache.curr_node, wrapper.cache.root_node)
+ self.assertEqual(raw.pre_calls, 1)
+
+ def test_post_delegates_to_wrapped_sul(self):
+ raw = AlternatingNonDetSUL()
+ wrapper = NonDeterministicSULWrapper(raw)
+ wrapper.post()
+ self.assertEqual(raw.post_calls, 1)
+
+
+class TestStep(unittest.TestCase):
+ def test_step_returns_wrapped_output(self):
+ raw = AlternatingNonDetSUL()
+ wrapper = NonDeterministicSULWrapper(raw)
+ wrapper.pre()
+ self.assertEqual(wrapper.step('a'), 'x')
+ self.assertEqual(wrapper.step('a'), 'y')
+
+ def test_step_records_input_output_pair_in_cache(self):
+ raw = AlternatingNonDetSUL()
+ wrapper = NonDeterministicSULWrapper(raw)
+ wrapper.pre()
+ wrapper.step('a')
+ self.assertIsNotNone(wrapper.cache.get_to_node(('a',), ('x',)))
+
+ def test_step_moves_cache_cursor_along_the_path(self):
+ raw = AlternatingNonDetSUL()
+ wrapper = NonDeterministicSULWrapper(raw)
+ wrapper.pre()
+ wrapper.step('a')
+ node_after_first = wrapper.cache.curr_node
+ wrapper.step('a')
+ self.assertIs(wrapper.cache.curr_node.parent, node_after_first)
+
+
+class TestQueryAccumulatesTracesInCache(unittest.TestCase):
+ def test_repeated_queries_accumulate_both_branches_of_a_non_det_input(self):
+ wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL())
+ for _ in range(4):
+ wrapper.query(('a',))
+ traces = wrapper.cache.get_all_traces(((), ()), ('a',))
+ self.assertCountEqual(traces, [('x',), ('y',)])
+
+ def test_deterministic_input_only_ever_records_one_output(self):
+ wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL())
+ for _ in range(3):
+ wrapper.query(('b',))
+ traces = wrapper.cache.get_all_traces(((), ()), ('b',))
+ self.assertEqual(traces, [('z',)])
+
+ def test_query_uses_base_sul_bookkeeping_for_queries_and_steps(self):
+ wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL())
+ wrapper.query(('a', 'b'))
+ wrapper.query(('a',))
+ self.assertEqual(wrapper.num_queries, 2)
+ self.assertEqual(wrapper.num_steps, 3)
+
+ def test_query_calls_pre_and_post_on_wrapped_sul_each_time(self):
+ raw = AlternatingNonDetSUL()
+ wrapper = NonDeterministicSULWrapper(raw)
+ wrapper.query(('a',))
+ wrapper.query(('a',))
+ self.assertEqual(raw.pre_calls, 2)
+ self.assertEqual(raw.post_calls, 2)
+
+ def test_frequency_counter_tracks_how_often_each_branch_was_sampled(self):
+ wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL())
+ for _ in range(6):
+ wrapper.query(('a',))
+ self.assertEqual(wrapper.cache.get_s_e_sampling_frequency(((), ()), ('a',)), 6)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/non_deterministic/test_onfsm_lstar.py b/tests/learning_algs/non_deterministic/test_onfsm_lstar.py
new file mode 100644
index 00000000000..c809c3d4acd
--- /dev/null
+++ b/tests/learning_algs/non_deterministic/test_onfsm_lstar.py
@@ -0,0 +1,64 @@
+import random
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs import run_non_det_Lstar
+from aalpy.oracles import RandomWalkEqOracle, RandomWordEqOracle
+from aalpy.utils import get_benchmark_ONFSM
+
+# The original version of this test (tests/test_non_deterministic.py) ran 100 fresh iterations;
+# ONFSM learning is inherently randomized (all-weather sampling + random equivalence oracles), so a
+# handful of seeded iterations is kept here to catch flakiness while staying fast.
+SEEDS = range(8)
+
+
+class TestRunNonDetLstar(unittest.TestCase):
+ def test_learns_benchmark_onfsm_with_correct_state_count_and_no_cex(self):
+ onfsm = get_benchmark_ONFSM()
+ alphabet = onfsm.get_input_alphabet()
+
+ for seed in SEEDS:
+ random.seed(seed)
+ sul = AutomatonSUL(onfsm)
+ oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5)
+
+ learned_onfsm = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=20, print_level=0)
+
+ eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=3000, reset_prob=0.09,
+ reset_after_cex=True)
+ cex = eq_oracle.find_cex(learned_onfsm)
+
+ self.assertIsNone(cex, f'seed {seed}: independent oracle found a counterexample')
+ self.assertEqual(len(learned_onfsm.states), len(onfsm.states),
+ f'seed {seed}: learned model has wrong number of states')
+
+ def test_return_data_reports_consistent_learning_info(self):
+ random.seed(0)
+ onfsm = get_benchmark_ONFSM()
+ alphabet = onfsm.get_input_alphabet()
+ sul = AutomatonSUL(onfsm)
+ oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5)
+
+ learned_onfsm, info = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=20, print_level=0,
+ return_data=True)
+
+ self.assertEqual(info['automaton_size'], len(learned_onfsm.states))
+ self.assertGreaterEqual(info['learning_rounds'], 1)
+ self.assertGreaterEqual(info['queries_learning'], 1)
+
+ def test_stochastic_flag_returns_stochastic_mealy_machine(self):
+ from aalpy.automata import StochasticMealyMachine
+
+ random.seed(0)
+ onfsm = get_benchmark_ONFSM()
+ alphabet = onfsm.get_input_alphabet()
+ sul = AutomatonSUL(onfsm)
+ oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5)
+
+ learned = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=20, stochastic=True, print_level=0)
+
+ self.assertIsInstance(learned, StochasticMealyMachine)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py b/tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py
new file mode 100644
index 00000000000..78ecfa5806c
--- /dev/null
+++ b/tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py
@@ -0,0 +1,37 @@
+import random
+
+import pytest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs import run_non_det_Lstar
+from aalpy.oracles import RandomWalkEqOracle, RandomWordEqOracle
+from aalpy.utils import get_benchmark_ONFSM
+
+pytestmark = pytest.mark.exhaustive
+
+# Full sweep this repo used to run at the root (tests/test_non_deterministic.py) before it was trimmed
+# down to 8 seeded iterations (see the sibling test_onfsm_lstar.py): 100 fresh iterations with a wider
+# random-walk/sampling budget. ONFSM learning is inherently randomized, so more iterations catch rarer
+# non-determinism/sampling edge cases that a small seeded run can miss.
+ITERATIONS = 100
+
+
+def test_learns_benchmark_onfsm_with_correct_state_count_and_no_cex_exhaustive():
+ onfsm = get_benchmark_ONFSM()
+ alphabet = onfsm.get_input_alphabet()
+
+ for i in range(ITERATIONS):
+ sul = AutomatonSUL(onfsm)
+
+ oracle = RandomWordEqOracle(alphabet, sul, num_walks=500, min_walk_len=2, max_walk_len=5)
+
+ learned_onfsm = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=50, print_level=0)
+
+ eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=10000, reset_prob=0.09,
+ reset_after_cex=True)
+
+ cex = eq_oracle.find_cex(learned_onfsm)
+
+ assert cex is None, f'iteration {i}: independent oracle found a counterexample'
+ assert len(learned_onfsm.states) == len(onfsm.states), \
+ f'iteration {i}: learned model has wrong number of states'
diff --git a/tests/learning_algs/non_deterministic/test_onfsm_observation_table.py b/tests/learning_algs/non_deterministic/test_onfsm_observation_table.py
new file mode 100644
index 00000000000..9a9daa6dcdd
--- /dev/null
+++ b/tests/learning_algs/non_deterministic/test_onfsm_observation_table.py
@@ -0,0 +1,174 @@
+import random
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Onfsm, OnfsmState
+from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper
+from aalpy.learning_algs.non_deterministic.OnfsmObservationTable import NonDetObservationTable
+
+
+def branching_onfsm():
+ """s0 --a/x--> s1 --a/x--> s0 and s0 --a/y--> s2 --a/y--> s0.
+ A single-input-letter ONFSM whose two non-initial states are distinguishable by the suffix
+ 'a' (s1 always answers 'x', s2 always answers 'y')."""
+ s0 = OnfsmState('s0')
+ s1 = OnfsmState('s1')
+ s2 = OnfsmState('s2')
+ s0.transitions['a'].append(('x', s1))
+ s0.transitions['a'].append(('y', s2))
+ s1.transitions['a'].append(('x', s0))
+ s2.transitions['a'].append(('y', s0))
+ return Onfsm(s0, [s0, s1, s2])
+
+
+def wrapped_sul(onfsm):
+ return NonDeterministicSULWrapper(AutomatonSUL(onfsm))
+
+
+def close_table(ot):
+ """Drives the table-closing loop the same way run_non_det_Lstar does: querying missing
+ observations must be interleaved with get_row_to_close(), since get_extended_S() (and hence
+ get_row_to_close) only reflects rows the cache has actually been queried for so far."""
+ ot.query_missing_observations()
+ row_to_close = ot.get_row_to_close()
+ while row_to_close is not None:
+ ot.query_missing_observations()
+ row_to_close = ot.get_row_to_close()
+ ot.clean_obs_table()
+
+
+class TestConstruction(unittest.TestCase):
+ def test_initializes_A_and_E_from_alphabet(self):
+ ot = NonDetObservationTable(['a', 'b'], wrapped_sul(branching_onfsm()), 5)
+ self.assertEqual(ot.A, [('a',), ('b',)])
+ self.assertEqual(ot.E, [('a',), ('b',)])
+
+ def test_S_starts_with_only_the_empty_row(self):
+ ot = NonDetObservationTable(['a'], wrapped_sul(branching_onfsm()), 5)
+ self.assertEqual(ot.S, [((), ())])
+
+ def test_asserts_on_missing_alphabet_or_sul(self):
+ with self.assertRaises(AssertionError):
+ NonDetObservationTable(None, wrapped_sul(branching_onfsm()), 5)
+ with self.assertRaises(AssertionError):
+ NonDetObservationTable(['a'], None, 5)
+
+
+class TestQueryMissingObservations(unittest.TestCase):
+ def test_samples_initial_row_at_least_n_times_per_column(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ ot.query_missing_observations()
+ freq = sul.cache.get_s_e_sampling_frequency(((), ()), ('a',))
+ self.assertGreaterEqual(freq, 15)
+
+ def test_first_call_does_not_reach_beyond_depth_one(self):
+ # get_extended_S() is computed from the cache as it stands *before* query_missing_observations
+ # runs, so on a fresh table the very first call only samples the empty-prefix row - deeper
+ # rows only get discovered (and then sampled) on subsequent calls.
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ ot.query_missing_observations()
+ self.assertEqual(sul.cache.get_all_traces((('a',), ('x',)), ('a',)), [])
+
+
+class TestGetRowToClose(unittest.TestCase):
+ def test_returns_none_for_a_freshly_closed_single_state_row(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ ot.query_missing_observations()
+ row = ot.get_row_to_close()
+ self.assertIsNotNone(row)
+ self.assertIn(row, ot.S)
+
+ def test_full_closing_loop_reaches_a_fixed_point(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+ self.assertIsNone(ot.get_row_to_close())
+
+ def test_closing_loop_finds_three_distinguishable_rows(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+ self.assertEqual(len(ot.S), 3)
+ hashes = {ot.row_to_hashable(s) for s in ot.S}
+ self.assertEqual(len(hashes), 3)
+
+
+class TestRowToHashable(unittest.TestCase):
+ def test_distinguishes_rows_with_different_observed_outputs(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+ s1_row = next(s for s in ot.S if s[1] == ('x',))
+ s2_row = next(s for s in ot.S if s[1] == ('y',))
+ self.assertEqual(ot.row_to_hashable(s1_row), (frozenset({('x',)}),))
+ self.assertEqual(ot.row_to_hashable(s2_row), (frozenset({('y',)}),))
+
+ def test_initial_row_reflects_both_branches(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+ self.assertEqual(ot.row_to_hashable(((), ())), (frozenset({('x',), ('y',)}),))
+
+
+class TestCleanObsTable(unittest.TestCase):
+ def test_removes_duplicate_rows_that_loop_back_to_an_existing_state(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+
+ # ('a','a') with outputs ('y','y') loops back to s0 and is thus a duplicate of the empty row
+ duplicate_row = (('a', 'a'), ('y', 'y'))
+ ot.S.append(duplicate_row)
+ ot.query_missing_observations([duplicate_row], ot.E)
+
+ ot.clean_obs_table()
+ self.assertNotIn(duplicate_row, ot.S)
+ self.assertEqual(len(ot.S), 3)
+
+
+class TestGenHypothesis(unittest.TestCase):
+ def test_learned_table_yields_an_equivalent_three_state_onfsm(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+
+ hypothesis = ot.gen_hypothesis()
+ self.assertIsInstance(hypothesis, Onfsm)
+ self.assertEqual(len(hypothesis.states), 3)
+ self.assertCountEqual(hypothesis.outputs_on_input('a'), ['x', 'y'])
+
+ def test_hypothesis_round_trips_a_known_trace(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+ hypothesis = ot.gen_hypothesis()
+
+ hypothesis.reset_to_initial()
+ self.assertEqual(hypothesis.step_to('a', 'x'), 'x')
+ self.assertEqual(hypothesis.step_to('a', 'x'), 'x')
+ self.assertIsNone(hypothesis.step_to('a', 'z'))
+
+ def test_hypothesis_carries_characterization_set(self):
+ random.seed(0)
+ sul = wrapped_sul(branching_onfsm())
+ ot = NonDetObservationTable(['a'], sul, 15)
+ close_table(ot)
+ hypothesis = ot.gen_hypothesis()
+ self.assertEqual(hypothesis.characterization_set, ot.E)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/non_deterministic/test_trace_tree.py b/tests/learning_algs/non_deterministic/test_trace_tree.py
new file mode 100644
index 00000000000..e00c8f959e5
--- /dev/null
+++ b/tests/learning_algs/non_deterministic/test_trace_tree.py
@@ -0,0 +1,205 @@
+import unittest
+
+from aalpy.automata import Onfsm, OnfsmState
+from aalpy.learning_algs.non_deterministic.TraceTree import TraceTree
+
+
+def branching_onfsm():
+ s0 = OnfsmState('s0')
+ s1 = OnfsmState('s1')
+ s2 = OnfsmState('s2')
+ s0.transitions['a'].append(('x', s1))
+ s0.transitions['a'].append(('y', s2))
+ s1.transitions['a'].append(('x', s0))
+ s2.transitions['a'].append(('y', s0))
+ return Onfsm(s0, [s0, s1, s2])
+
+
+class TestTraceTreeConstruction(unittest.TestCase):
+ def test_new_tree_has_root_and_no_cursor(self):
+ tree = TraceTree()
+ self.assertIsNone(tree.root_node.output)
+ self.assertIsNone(tree.curr_node)
+
+ def test_reset_sets_cursor_to_root(self):
+ tree = TraceTree()
+ tree.reset()
+ self.assertIs(tree.curr_node, tree.root_node)
+
+
+class TestAddToTree(unittest.TestCase):
+ def test_add_to_tree_creates_child_and_moves_cursor(self):
+ tree = TraceTree()
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ self.assertIsNot(tree.curr_node, tree.root_node)
+ self.assertEqual(tree.curr_node.output, 'x')
+ self.assertIs(tree.curr_node.parent, tree.root_node)
+
+ def test_repeated_pair_reuses_node_and_bumps_frequency(self):
+ tree = TraceTree()
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ node_first = tree.curr_node
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ self.assertIs(tree.curr_node, node_first)
+ self.assertEqual(node_first.frequency_counter, 2)
+
+ def test_different_output_for_same_input_creates_sibling(self):
+ tree = TraceTree()
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ tree.reset()
+ tree.add_to_tree('a', 'y')
+ self.assertEqual(len(tree.root_node.children['a']), 2)
+ outputs = {child.output for child in tree.root_node.children['a']}
+ self.assertEqual(outputs, {'x', 'y'})
+
+
+class TestAddTrace(unittest.TestCase):
+ def test_add_trace_resets_before_inserting(self):
+ tree = TraceTree()
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ tree.add_trace(('a',), ('y',))
+ # add_trace resets the cursor to root first, so 'y' is a sibling of 'x', not a child of it
+ self.assertEqual(len(tree.root_node.children['a']), 2)
+
+ def test_add_trace_builds_full_path(self):
+ tree = TraceTree()
+ tree.add_trace(('a', 'a'), ('x', 'y'))
+ node = tree.get_to_node(('a', 'a'), ('x', 'y'))
+ self.assertIsNotNone(node)
+ self.assertEqual(node.get_prefix(), ('x', 'y'))
+
+
+class TestGetToNode(unittest.TestCase):
+ def test_returns_node_on_known_path(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ node = tree.get_to_node(('a',), ('x',))
+ self.assertIsNotNone(node)
+
+ def test_returns_none_on_unknown_output(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertIsNone(tree.get_to_node(('a',), ('z',)))
+
+ def test_returns_root_for_empty_path(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertIs(tree.get_to_node((), ()), tree.root_node)
+
+
+class TestGetAllTraces(unittest.TestCase):
+ def test_returns_empty_list_when_prefix_is_empty_tuple(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertEqual(tree.get_all_traces((), ('a',)), [])
+
+ def test_returns_empty_list_when_suffix_is_empty(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertEqual(tree.get_all_traces(((), ()), ()), [])
+
+ def test_root_prefix_is_a_2tuple_of_empty_tuples_and_is_queried_normally(self):
+ # ((), ()) is truthy as a whole (it's a non-empty 2-tuple), so the "not prefix" guard
+ # in get_all_traces never triggers for it - this is the standard shape of an S-set row.
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertEqual(tree.get_all_traces(((), ()), ('a',)), [('x',)])
+
+ def test_branches_are_all_returned_for_the_same_prefix(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ tree.add_trace(('a',), ('y',))
+ traces = tree.get_all_traces(((), ()), ('a',))
+ self.assertCountEqual(traces, [('x',), ('y',)])
+
+ def test_returns_only_the_suffix_portion_of_the_trace(self):
+ tree = TraceTree()
+ tree.add_trace(('a', 'b'), ('x', 'z'))
+ # prefix identifies the node reached after 'a'/'x', then we trace 'b' from there
+ traces = tree.get_all_traces((('a',), ('x',)), ('b',))
+ self.assertEqual(traces, [('z',)])
+
+ def test_returns_empty_list_for_unknown_prefix(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertEqual(tree.get_all_traces((('a',), ('z',)), ('a',)), [])
+
+ def test_returns_empty_list_when_suffix_input_never_observed(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertEqual(tree.get_all_traces(((), ()), ('b',)), [])
+
+
+class TestGetTable(unittest.TestCase):
+ def test_get_table_matches_get_all_traces_per_cell(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ tree.add_trace(('a',), ('y',))
+ s = [((), ())]
+ e = [('a',)]
+ table = tree.get_table(s, e)
+ self.assertCountEqual(table[s[0]][e[0]], tree.get_all_traces(s[0], e[0]))
+
+
+class TestSamplingFrequencyAndDistribution(unittest.TestCase):
+ def test_frequency_counts_repeated_samples(self):
+ tree = TraceTree()
+ for _ in range(3):
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ for _ in range(2):
+ tree.reset()
+ tree.add_to_tree('a', 'y')
+ self.assertEqual(tree.get_s_e_sampling_frequency(((), ()), ('a',)), 5)
+
+ def test_frequency_is_zero_for_never_sampled_path(self):
+ tree = TraceTree()
+ tree.add_trace(('a',), ('x',))
+ self.assertEqual(tree.get_s_e_sampling_frequency(((), ()), ('b',)), 0)
+
+ def test_frequency_over_multi_step_suffix(self):
+ tree = TraceTree()
+ for _ in range(4):
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ tree.add_to_tree('b', 'z')
+ self.assertEqual(tree.get_s_e_sampling_frequency(((), ()), ('a', 'b')), 4)
+
+ def test_sampling_distribution_matches_observed_ratios(self):
+ tree = TraceTree()
+ for _ in range(3):
+ tree.reset()
+ tree.add_to_tree('a', 'x')
+ for _ in range(1):
+ tree.reset()
+ tree.add_to_tree('a', 'y')
+ distribution = tree.get_sampling_distributions(((), ()), 'a')
+ self.assertAlmostEqual(distribution['x'], 0.75)
+ self.assertAlmostEqual(distribution['y'], 0.25)
+ self.assertAlmostEqual(sum(distribution.values()), 1.0)
+
+
+class TestFindCexInCache(unittest.TestCase):
+ def test_returns_none_when_cache_agrees_with_hypothesis(self):
+ onfsm = branching_onfsm()
+ tree = TraceTree()
+ tree.add_trace(('a', 'a'), ('x', 'x'))
+ tree.add_trace(('a', 'a'), ('y', 'y'))
+ self.assertIsNone(tree.find_cex_in_cache(onfsm))
+
+ def test_finds_cex_when_cache_disagrees_with_hypothesis(self):
+ onfsm = branching_onfsm()
+ tree = TraceTree()
+ # from s1 (reached via 'a'/'x'), only 'x' is a valid onward output, so 'y' is a cex
+ tree.add_trace(('a', 'a'), ('x', 'y'))
+ cex = tree.find_cex_in_cache(onfsm)
+ self.assertEqual(cex, (['a', 'a'], ['x', 'y']))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/resetless/test_hW.py b/tests/learning_algs/resetless/test_hW.py
new file mode 100644
index 00000000000..02ec45b9e04
--- /dev/null
+++ b/tests/learning_algs/resetless/test_hW.py
@@ -0,0 +1,146 @@
+import pytest
+
+from aalpy import AutomatonSUL, RandomhWOracle, RandomWphWOracle, bisimilar, generate_random_deterministic_automata, \
+ run_hW
+from aalpy.SULs import MealySUL
+
+# trimmed from the original (2400-case, root-level) sweep in tests/test_hW.py: a handful of
+# small sizes/seeds across the three automaton types is enough to exercise run_hW's main loop
+# without turning the suite into a multi-minute run
+SEEDS = list(range(6))
+MODEL_SIZES = [
+ (2, 2, 2),
+ (3, 2, 2),
+ (4, 2, 3),
+ (6, 3, 2),
+]
+
+TEST_CASES = [
+ pytest.param(
+ automaton_type,
+ seed_val,
+ num_states,
+ input_size,
+ output_size,
+ id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}",
+ )
+ for num_states, input_size, output_size in MODEL_SIZES
+ for seed_val in SEEDS
+ for automaton_type in ['dfa', 'moore', 'mealy']
+]
+
+
+@pytest.mark.parametrize("automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES)
+@pytest.mark.timeout(5)
+def test_hw_seed(automaton_type, seed_val, num_states, input_size, output_size):
+ from random import seed
+
+ seed(seed_val)
+
+ model = generate_random_deterministic_automata(
+ automaton_type,
+ num_states=num_states,
+ input_alphabet_size=input_size,
+ output_alphabet_size=output_size,
+ )
+ if not model.is_minimal():
+ pytest.skip(f"seed {seed_val} does not produce a minimal model")
+ if not model.is_strongly_connected():
+ pytest.skip(
+ f"seed {seed_val} does not produce a strongly connected model "
+ f"for states={num_states}, inputs={input_size}, outputs={output_size}"
+ )
+
+ sul = MealySUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ learned_model = run_hW(input_alphabet,
+ sul,
+ RandomhWOracle(num_testing_steps=1000 * num_states, reset_testing_counter=True),
+ automaton_type=automaton_type,
+ query_for_initial_state=True,
+ print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
+
+
+STRATEGY_TEST_CASES = [
+ pytest.param(
+ oracle_name,
+ seed_val,
+ num_states,
+ input_size,
+ output_size,
+ id=f"oracle={oracle_name}-states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}",
+ )
+ for num_states, input_size, output_size in [(3, 2, 2), (5, 3, 3)]
+ for seed_val in range(4)
+ for oracle_name in ['random', 'wp']
+]
+
+
+@pytest.mark.parametrize("oracle_name,seed_val,num_states,input_size,output_size", STRATEGY_TEST_CASES)
+@pytest.mark.timeout(5)
+def test_hw_eq_oracle(oracle_name, seed_val, num_states, input_size, output_size):
+ from random import seed
+
+ seed(seed_val)
+
+ model = generate_random_deterministic_automata(
+ 'mealy',
+ num_states=num_states,
+ input_alphabet_size=input_size,
+ output_alphabet_size=output_size,
+ )
+ if not model.is_minimal() or not model.is_strongly_connected():
+ pytest.skip(f"seed {seed_val} does not produce a minimal, strongly connected model")
+
+ sul = AutomatonSUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ if oracle_name == 'wp':
+ eq_oracle = RandomWphWOracle(random_walk_length=4 * num_states,
+ num_test_origin_states=4 * num_states)
+ else:
+ eq_oracle = RandomhWOracle(num_testing_steps=1000 * num_states)
+
+ learned_model = run_hW(input_alphabet,
+ sul,
+ eq_oracle,
+ automaton_type='mealy',
+ query_for_initial_state=True,
+ print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
+
+
+def test_hw_uses_user_provided_h_and_w():
+ from random import seed
+ seed(1)
+
+ model = generate_random_deterministic_automata(
+ 'mealy',
+ num_states=20,
+ input_alphabet_size=4,
+ output_alphabet_size=4,
+ )
+
+ sul = AutomatonSUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ char_set = model.compute_characterization_set()
+
+ assert model.is_minimal()
+
+ learned_model = run_hW(input_alphabet,
+ sul,
+ RandomhWOracle(num_testing_steps=2000),
+ automaton_type='mealy',
+ provided_characterization_set=char_set,
+ query_for_initial_state=True,
+ print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
diff --git a/tests/learning_algs/resetless/test_hW_exhaustive.py b/tests/learning_algs/resetless/test_hW_exhaustive.py
new file mode 100644
index 00000000000..e3dc9387c40
--- /dev/null
+++ b/tests/learning_algs/resetless/test_hW_exhaustive.py
@@ -0,0 +1,160 @@
+import pytest
+
+from aalpy import AutomatonSUL, RandomhWOracle, RandomWphWOracle, bisimilar, generate_random_deterministic_automata, \
+ run_hW
+from aalpy.SULs import MealySUL
+
+pytestmark = pytest.mark.exhaustive
+
+# Full sweep this repo used to run at the root (tests/test_hW.py) before it was trimmed down to a fast
+# default (see the sibling test_hW.py in this directory): 16 sizes x 50 seeds x 3 automaton types = 2400
+# cases for test_hw_seed_exhaustive, plus a wider strategy sweep below.
+SEEDS = list(range(50))
+MODEL_SIZES = [
+ (2, 2, 2),
+ (2, 2, 3),
+ (3, 2, 2),
+ (3, 2, 3),
+ (3, 3, 2),
+ (4, 2, 3),
+ (4, 3, 2),
+ (5, 3, 3),
+ (6, 2, 3),
+ (6, 3, 2),
+ (10, 2, 3),
+ (10, 2, 4),
+ (10, 2, 2),
+ (10, 2, 3),
+ (20, 5, 5),
+ (30, 3, 4),
+]
+
+TEST_CASES = [
+ pytest.param(
+ automaton_type,
+ seed_val,
+ num_states,
+ input_size,
+ output_size,
+ id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}",
+ )
+ for num_states, input_size, output_size in MODEL_SIZES
+ for seed_val in SEEDS
+ for automaton_type in ['dfa', 'moore', 'mealy']
+]
+
+
+@pytest.mark.parametrize("automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES)
+@pytest.mark.timeout(5)
+def test_hw_seed_exhaustive(automaton_type, seed_val, num_states, input_size, output_size):
+ from random import seed
+
+ seed(seed_val)
+
+ model = generate_random_deterministic_automata(
+ automaton_type,
+ num_states=num_states,
+ input_alphabet_size=input_size,
+ output_alphabet_size=output_size,
+ )
+ if not model.is_minimal():
+ pytest.skip(f"seed {seed_val} does not produce a minimal model")
+ if not model.is_strongly_connected():
+ pytest.skip(
+ f"seed {seed_val} does not produce a strongly connected model "
+ f"for states={num_states}, inputs={input_size}, outputs={output_size}"
+ )
+
+ sul = MealySUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ learned_model = run_hW(input_alphabet,
+ sul,
+ RandomhWOracle(num_testing_steps=1000 * num_states, reset_testing_counter=True),
+ automaton_type=automaton_type,
+ query_for_initial_state=True,
+ print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
+
+
+STRATEGY_TEST_CASES = [
+ pytest.param(
+ oracle_name,
+ seed_val,
+ num_states,
+ input_size,
+ output_size,
+ id=f"oracle={oracle_name}-states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}",
+ )
+ for num_states, input_size, output_size in [(3, 2, 2), (5, 3, 3), (10, 2, 3)]
+ for seed_val in range(10)
+ for oracle_name in ['random', 'wp']
+]
+
+
+@pytest.mark.parametrize("oracle_name,seed_val,num_states,input_size,output_size", STRATEGY_TEST_CASES)
+@pytest.mark.timeout(5)
+def test_hw_eq_oracle_exhaustive(oracle_name, seed_val, num_states, input_size, output_size):
+ from random import seed
+
+ seed(seed_val)
+
+ model = generate_random_deterministic_automata(
+ 'mealy',
+ num_states=num_states,
+ input_alphabet_size=input_size,
+ output_alphabet_size=output_size,
+ )
+ if not model.is_minimal() or not model.is_strongly_connected():
+ pytest.skip(f"seed {seed_val} does not produce a minimal, strongly connected model")
+
+ sul = AutomatonSUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ if oracle_name == 'wp':
+ eq_oracle = RandomWphWOracle(random_walk_length=4 * num_states,
+ num_test_origin_states=4 * num_states)
+ else:
+ eq_oracle = RandomhWOracle(num_testing_steps=1000 * num_states)
+
+ learned_model = run_hW(input_alphabet,
+ sul,
+ eq_oracle,
+ automaton_type='mealy',
+ query_for_initial_state=True,
+ print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
+
+
+def test_hw_uses_user_provided_h_and_w_exhaustive():
+ from random import seed
+ seed(1)
+
+ model = generate_random_deterministic_automata(
+ 'mealy',
+ num_states=100,
+ input_alphabet_size=4,
+ output_alphabet_size=4,
+ )
+
+ sul = AutomatonSUL(model)
+ input_alphabet = model.get_input_alphabet()
+
+ char_set = model.compute_characterization_set()
+
+ assert model.is_minimal()
+
+ learned_model = run_hW(input_alphabet,
+ sul,
+ RandomhWOracle(num_testing_steps=2000),
+ automaton_type='mealy',
+ provided_characterization_set=char_set,
+ query_for_initial_state=True,
+ print_level=0)
+
+ assert learned_model.is_minimal()
+ assert bisimilar(model, learned_model)
diff --git a/tests/learning_algs/resetless/test_hw_datastructures.py b/tests/learning_algs/resetless/test_hw_datastructures.py
new file mode 100644
index 00000000000..bbc3d15d1ca
--- /dev/null
+++ b/tests/learning_algs/resetless/test_hw_datastructures.py
@@ -0,0 +1,130 @@
+import unittest
+
+from aalpy.learning_algs.resetless.hW_datastructures import HomingSequenceIndex, ModelState
+
+
+class ModelStateTests(unittest.TestCase):
+
+ def test_new_state_has_empty_bookkeeping(self):
+ state = ModelState(('a', 'b'))
+ self.assertEqual(state.hs, ('a', 'b'))
+ self.assertEqual(state.state_w_values, {})
+ self.assertEqual(state.transitions, {})
+ self.assertEqual(state.output_fun, {})
+ self.assertEqual(state.transition_w_values, {})
+ self.assertEqual(len(state.learned_w_per_input), 0)
+
+ def test_learned_w_per_input_auto_creates_set_per_key(self):
+ state = ModelState(())
+ state.learned_w_per_input['a'].add(('w',))
+ self.assertEqual(state.learned_w_per_input['a'], {('w',)})
+ # accessing an unseen key must not pollute any other key's set
+ self.assertEqual(state.learned_w_per_input['b'], set())
+ state.learned_w_per_input['b'].add(('other',))
+ self.assertEqual(state.learned_w_per_input['a'], {('w',)})
+
+ def test_learned_w_per_input_is_independent_per_instance(self):
+ # a shared mutable default here would make every ModelState alias the
+ # same per-input sets, silently corrupting unrelated states
+ state1 = ModelState(('x',))
+ state2 = ModelState(('y',))
+ state1.learned_w_per_input['a'].add(('w',))
+ self.assertEqual(state2.learned_w_per_input['a'], set())
+
+
+class HomingSequenceIndexTests(unittest.TestCase):
+
+ def test_empty_h_never_flags_nondeterminism(self):
+ index = HomingSequenceIndex()
+ trace = [('a', 'x'), ('a', 'x'), ('a', 'y')]
+ self.assertIsNone(index.scan(trace, h=()))
+ self.assertEqual(index.continuation_starts(('x',)), ())
+
+ def test_reset_clears_pairs_and_advances_scan_position(self):
+ index = HomingSequenceIndex()
+ trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '1')]
+ index.scan(trace, h=('a',))
+ self.assertNotEqual(index.continuation_starts(('x',)), ())
+
+ index.reset(trace_len=len(trace))
+ self.assertEqual(index.continuation_starts(('x',)), ())
+ self.assertEqual(index._scan_pos, len(trace))
+ self.assertEqual(index._next_occ_min_start, len(trace))
+ self.assertEqual(index._pair_progress, {})
+
+ def test_scan_is_none_when_continuations_after_same_response_agree(self):
+ index = HomingSequenceIndex()
+ # h='a' occurs at position 0 and 2, both with response 'x'; both
+ # continuations produce the same ('b', '1') step, so h stays consistent
+ trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '1')]
+ self.assertIsNone(index.scan(trace, h=('a',)))
+ self.assertEqual(list(index.continuation_starts(('x',))), [1, 3])
+
+ def test_scan_detects_output_divergence_between_same_response_occurrences(self):
+ index = HomingSequenceIndex()
+ # h='a' occurs at position 0 and 2, both with response 'x', but the
+ # continuations disagree on the output of 'b' ('1' vs '2')
+ trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '2')]
+ extension = index.scan(trace, h=('a',))
+ self.assertEqual(extension, ('b',))
+
+ def test_scan_returns_none_for_pairs_with_different_response(self):
+ index = HomingSequenceIndex()
+ # h='a' occurs twice but with different responses ('x' vs 'y'), so the
+ # two occurrences are never paired and no divergence can be reported
+ trace = [('a', 'x'), ('b', '1'), ('a', 'y'), ('b', '2')]
+ self.assertIsNone(index.scan(trace, h=('a',)))
+
+ def test_scan_deletes_pair_once_inputs_diverge_without_reporting(self):
+ index = HomingSequenceIndex()
+ # continuations diverge on the *input* executed, not the output, so this
+ # is not h-non-determinism and must not be reported
+ trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('c', '1')]
+ self.assertIsNone(index.scan(trace, h=('a',)))
+ # the pair was dropped, so a later matching continuation does not
+ # magically resurrect a stale comparison
+ self.assertEqual(index._pair_progress, {})
+
+ def test_self_overlapping_occurrence_is_skipped_unless_forced(self):
+ index = HomingSequenceIndex()
+ # h = ('a', 'a') inside a run of four 'a's: occurrences start at 0, 1, 2
+ # (all matching), but the one at 1 overlaps the just-registered occurrence
+ # at 0 (its continuation at 3 falls before the next allowed start) and is
+ # skipped as incidental; 0 and 2 are far enough apart to both register
+ trace = [('a', 'x'), ('a', 'y'), ('a', 'x'), ('a', 'y')]
+ h = ('a', 'a')
+
+ index.scan(trace, h)
+ starts = sorted(c for starts in index._hs_cont_starts.values() for c in starts)
+ self.assertEqual(starts, [2, 4])
+ self.assertNotIn(3, starts)
+
+ def test_forced_cont_start_registers_otherwise_skipped_occurrence(self):
+ index = HomingSequenceIndex()
+ trace = [('a', 'x'), ('a', 'y'), ('a', 'x'), ('a', 'y')]
+ h = ('a', 'a')
+
+ # continuation at position 3 belongs to the overlapping occurrence
+ # starting at 1, which would normally be skipped as incidental
+ index.scan(trace, h, forced_cont_start=3)
+ starts = sorted(c for starts in index._hs_cont_starts.values() for c in starts)
+ self.assertEqual(starts, [2, 3])
+
+ def test_continuation_starts_returns_empty_tuple_for_unknown_response(self):
+ index = HomingSequenceIndex()
+ index.scan([('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '1')], h=('a',))
+ self.assertEqual(index.continuation_starts(('never', 'seen')), ())
+
+ def test_incremental_scan_only_processes_newly_added_trace(self):
+ index = HomingSequenceIndex()
+ h = ('a',)
+ trace = [('a', 'x'), ('b', '1')]
+ self.assertIsNone(index.scan(trace, h))
+
+ trace = trace + [('a', 'x'), ('b', '2')]
+ extension = index.scan(trace, h)
+ self.assertEqual(extension, ('b',))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/resetless/test_resetless_oracles.py b/tests/learning_algs/resetless/test_resetless_oracles.py
new file mode 100644
index 00000000000..1e74af4510a
--- /dev/null
+++ b/tests/learning_algs/resetless/test_resetless_oracles.py
@@ -0,0 +1,279 @@
+import random
+import unittest
+
+from aalpy.automata import MealyMachine, MealyState, MooreMachine, MooreState
+from aalpy.learning_algs.resetless.resetless_oracles import (
+ RandomhWOracle,
+ RandomWphWOracle,
+ find_counterexample_in_trace,
+ hWOracle,
+)
+from aalpy.SULs import AutomatonSUL
+
+
+def chain_mealy(length, alphabet=('a', 'b')):
+ """Chain of states s0 -> s1 -> ... -> s_length, advanced by the first letter of alphabet, looping on the rest."""
+ states = [MealyState(f's{i}') for i in range(length + 1)]
+ first = alphabet[0]
+ for i in range(length):
+ states[i].transitions = {a: states[i] for a in alphabet}
+ states[i].transitions[first] = states[i + 1]
+ states[i].output_fun = {a: 'o' for a in alphabet}
+ states[length].transitions = {a: states[length] for a in alphabet}
+ states[length].output_fun = {a: 'o' for a in alphabet}
+ mm = MealyMachine(states[0], states)
+ mm.compute_prefixes()
+ return mm
+
+
+def ring_mealy(size, alphabet=('a', 'b')):
+ """
+ Ring of `size` states: 'a' advances cyclically s0->s1->...->s(size-1)->s0, 'b' self-loops.
+ Strongly connected, so every state can reach every other state (unlike chain_mealy).
+ """
+ states = [MealyState(f's{i}') for i in range(size)]
+ first = alphabet[0]
+ for i in range(size):
+ states[i].transitions = {a: states[i] for a in alphabet}
+ states[i].transitions[first] = states[(i + 1) % size]
+ states[i].output_fun = {a: 'o' for a in alphabet}
+ mm = MealyMachine(states[0], states)
+ mm.compute_prefixes()
+ return mm
+
+
+class FakeLearner:
+ """Minimal stand-in for hW exposing just what the resetless oracles/backstop need."""
+
+ def __init__(self, sul, input_alphabet, W=None, is_moore=False):
+ self.sul = sul
+ self.input_alphabet = input_alphabet
+ self.W = W if W is not None else []
+ self.is_moore = is_moore
+ self.global_trace = []
+
+ def step_wrapper(self, letter):
+ output = self.sul.step(letter)
+ self.global_trace.append((letter, output))
+ return output
+
+
+class HWOracleBaseTests(unittest.TestCase):
+
+ def test_find_cex_is_not_implemented_on_base_class(self):
+ oracle = hWOracle()
+ self.assertIsNone(oracle.learner)
+ self.assertEqual(oracle.num_steps, 0)
+ with self.assertRaises(NotImplementedError):
+ oracle.find_cex(hypothesis=None)
+
+ def test_execute_and_compare_stops_at_first_mismatch_and_counts_steps(self):
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+ hypothesis.states[1].output_fun['a'] = 'x'
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'])
+ oracle = hWOracle()
+ oracle.learner = learner
+
+ cex = []
+ mismatched = oracle._execute_and_compare(hypothesis, ('a', 'a', 'a'), cex)
+
+ self.assertTrue(mismatched)
+ # the reference walks s0->s1->s2 while the hypothesis is stepped in lock-step;
+ # the divergent output_fun edit is only observed on the second 'a' (from s1)
+ self.assertEqual(cex, ['a', 'a'])
+ self.assertEqual(oracle.num_steps, 2)
+
+ def test_execute_and_compare_returns_false_when_no_mismatch(self):
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'])
+ oracle = hWOracle()
+ oracle.learner = learner
+
+ cex = []
+ mismatched = oracle._execute_and_compare(hypothesis, ('a', 'b', 'a'), cex)
+
+ self.assertFalse(mismatched)
+ self.assertEqual(cex, ['a', 'b', 'a'])
+ self.assertEqual(oracle.num_steps, 3)
+
+
+class RandomhWOracleTests(unittest.TestCase):
+
+ def test_finds_cex_over_several_seeds(self):
+ successes = 0
+ for seed_val in range(10):
+ random.seed(seed_val)
+ reference = chain_mealy(6)
+ hypothesis = chain_mealy(6)
+ hypothesis.states[6].output_fun['a'] = 'x'
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'])
+ oracle = RandomhWOracle(num_testing_steps=500)
+ oracle.learner = learner
+
+ cex = oracle.find_cex(hypothesis)
+ if cex is not None:
+ successes += 1
+ self.assertEqual(cex[-1], 'a')
+
+ self.assertGreaterEqual(successes, 9)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ random.seed(0)
+ reference = chain_mealy(4)
+ hypothesis = chain_mealy(4)
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'])
+ oracle = RandomhWOracle(num_testing_steps=500)
+ oracle.learner = learner
+
+ self.assertIsNone(oracle.find_cex(hypothesis))
+ self.assertEqual(oracle.num_steps, 500)
+
+ def test_reset_testing_counter_false_shares_budget_across_calls(self):
+ random.seed(0)
+ reference = chain_mealy(4)
+ hypothesis = chain_mealy(4)
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'])
+ oracle = RandomhWOracle(num_testing_steps=10, reset_testing_counter=False)
+ oracle.learner = learner
+
+ oracle.find_cex(hypothesis)
+ self.assertEqual(oracle.num_steps, 10)
+
+ # the whole budget of 10 was already consumed by the first call, so a
+ # second call must execute zero further steps
+ oracle.find_cex(hypothesis)
+ self.assertEqual(oracle.num_steps, 10)
+
+ def test_reset_testing_counter_true_replenishes_budget_each_call(self):
+ random.seed(0)
+ reference = chain_mealy(4)
+ hypothesis = chain_mealy(4)
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'])
+ oracle = RandomhWOracle(num_testing_steps=10, reset_testing_counter=True)
+ oracle.learner = learner
+
+ oracle.find_cex(hypothesis)
+ oracle.find_cex(hypothesis)
+ self.assertEqual(oracle.num_steps, 20)
+
+
+class RandomWphWOracleTests(unittest.TestCase):
+
+ def test_finds_cex_over_several_seeds(self):
+ successes = 0
+ for seed_val in range(10):
+ random.seed(seed_val)
+ reference = ring_mealy(4)
+ hypothesis = ring_mealy(4)
+ hypothesis.states[2].output_fun['b'] = 'x'
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'], W=[('a',), ('b',)])
+ oracle = RandomWphWOracle(random_walk_length=10, num_test_origin_states=8)
+ oracle.learner = learner
+
+ cex = oracle.find_cex(hypothesis)
+ if cex is not None:
+ successes += 1
+
+ self.assertGreaterEqual(successes, 9)
+
+ def test_no_cex_for_equivalent_hypothesis(self):
+ random.seed(0)
+ reference = chain_mealy(4)
+ hypothesis = chain_mealy(4)
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'], W=[('a',), ('b',)])
+ oracle = RandomWphWOracle(random_walk_length=5, num_test_origin_states=5)
+ oracle.learner = learner
+
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_empty_characterization_set_does_not_crash(self):
+ random.seed(0)
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'], W=[])
+ oracle = RandomWphWOracle(random_walk_length=5, num_test_origin_states=3)
+ oracle.learner = learner
+
+ self.assertIsNone(oracle.find_cex(hypothesis))
+
+ def test_zero_origin_states_never_tests_anything(self):
+ reference = chain_mealy(3)
+ hypothesis = chain_mealy(3)
+ hypothesis.states[2].output_fun['a'] = 'x'
+
+ sul = AutomatonSUL(reference)
+ sul.pre()
+ learner = FakeLearner(sul, ['a', 'b'], W=[('a',)])
+ oracle = RandomWphWOracle(random_walk_length=5, num_test_origin_states=0)
+ oracle.learner = learner
+
+ self.assertIsNone(oracle.find_cex(hypothesis))
+ self.assertEqual(oracle.num_steps, 0)
+
+
+class FindCounterexampleInTraceTests(unittest.TestCase):
+
+ def test_returns_none_for_trace_fully_explained_by_hypothesis(self):
+ hypothesis = chain_mealy(3)
+ learner = FakeLearner(sul=None, input_alphabet=['a', 'b'], is_moore=False)
+ learner.global_trace = [('a', 'o'), ('a', 'o'), ('b', 'o')]
+
+ self.assertIsNone(find_counterexample_in_trace(learner, hypothesis))
+
+ def test_finds_unexplained_suffix_for_mealy(self):
+ hypothesis = chain_mealy(3)
+ learner = FakeLearner(sul=None, input_alphabet=['a', 'b'], is_moore=False)
+ # 'x' is never produced by any state of the hypothesis
+ learner.global_trace = [('a', 'o'), ('a', 'x')]
+
+ cex = find_counterexample_in_trace(learner, hypothesis)
+
+ self.assertIsNotNone(cex)
+ self.assertEqual(cex[-1], 'a')
+
+ def test_finds_unexplained_step_for_moore(self):
+ s0 = MooreState('s0', output='0')
+ s1 = MooreState('s1', output='1')
+ s0.transitions = {'a': s1}
+ s1.transitions = {'a': s0}
+ mm = MooreMachine(s0, [s0, s1])
+
+ learner = FakeLearner(sul=None, input_alphabet=['a'], is_moore=True)
+ # after one 'a' the hypothesis would be in s1 (output '1'), not '9'
+ learner.global_trace = [('a', '9')]
+
+ cex = find_counterexample_in_trace(learner, mm)
+ self.assertEqual(cex, ['a'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic/test_difference_checker.py b/tests/learning_algs/stochastic/test_difference_checker.py
new file mode 100644
index 00000000000..38dc7c867e1
--- /dev/null
+++ b/tests/learning_algs/stochastic/test_difference_checker.py
@@ -0,0 +1,140 @@
+import unittest
+
+from aalpy.learning_algs.stochastic.DifferenceChecker import (
+ AdvancedHoeffdingChecker,
+ ChiSquareChecker,
+ HoeffdingChecker,
+ compute_epsilon,
+)
+
+
+class HoeffdingCheckerTest(unittest.TestCase):
+
+ def test_identical_distributions_are_not_different(self):
+ checker = HoeffdingChecker(alpha=0.05)
+ c1 = {'x': 50, 'y': 50}
+ c2 = {'x': 500, 'y': 500}
+ self.assertFalse(checker.are_cells_different(c1, c2))
+
+ def test_disjoint_output_supports_are_different(self):
+ checker = HoeffdingChecker(alpha=0.05)
+ self.assertTrue(checker.are_cells_different({'x': 10}, {'y': 10}))
+
+ def test_clearly_different_ratios_with_large_samples_are_different(self):
+ checker = HoeffdingChecker(alpha=0.05)
+ c1 = {'x': 950, 'y': 50}
+ c2 = {'x': 50, 'y': 950}
+ self.assertTrue(checker.are_cells_different(c1, c2))
+
+ def test_small_samples_with_different_ratios_can_be_indistinguishable(self):
+ # With very few samples the Hoeffding bound is wide, so even a 1 vs 0 count difference
+ # should not be flagged as a statistically significant difference.
+ checker = HoeffdingChecker(alpha=0.05)
+ self.assertFalse(checker.are_cells_different({'x': 1, 'y': 0}, {'x': 0, 'y': 1}))
+
+ def test_empty_cells_are_not_different(self):
+ checker = HoeffdingChecker(alpha=0.05)
+ self.assertFalse(checker.are_cells_different({}, {}))
+
+ def test_difference_value_and_use_diff_value_default_to_unsupported(self):
+ checker = HoeffdingChecker()
+ self.assertFalse(checker.use_diff_value())
+ self.assertIsNone(checker.difference_value({'x': 1}, {'x': 1}))
+
+
+class ComputeEpsilonTest(unittest.TestCase):
+
+ def test_epsilon_decreases_with_more_samples(self):
+ small_n = compute_epsilon(0.05, 10)
+ large_n = compute_epsilon(0.05, 10000)
+ self.assertGreater(small_n, large_n)
+
+ def test_epsilon_increases_with_smaller_alpha(self):
+ loose = compute_epsilon(0.5, 100)
+ strict = compute_epsilon(0.01, 100)
+ self.assertGreater(strict, loose)
+
+
+class AdvancedHoeffdingCheckerTest(unittest.TestCase):
+
+ def test_identical_distributions_are_not_different(self):
+ checker = AdvancedHoeffdingChecker(alpha=0.05)
+ self.assertFalse(checker.are_cells_different({'x': 100, 'y': 100}, {'x': 1000, 'y': 1000}))
+
+ def test_clearly_different_ratios_are_different(self):
+ checker = AdvancedHoeffdingChecker(alpha=0.05)
+ self.assertTrue(checker.are_cells_different({'x': 950, 'y': 50}, {'x': 50, 'y': 950}))
+
+ def test_use_diff_value_reflects_constructor_flag(self):
+ self.assertFalse(AdvancedHoeffdingChecker(use_diff=False).use_diff_value())
+ self.assertTrue(AdvancedHoeffdingChecker(use_diff=True).use_diff_value())
+
+ def test_difference_value_is_symmetric_and_zero_for_identical_cells(self):
+ checker = AdvancedHoeffdingChecker()
+ cell = {'x': 10, 'y': 20}
+ self.assertEqual(checker.difference_value(cell, dict(cell)), 0)
+
+ def test_difference_value_grows_with_more_disagreement(self):
+ checker = AdvancedHoeffdingChecker()
+ small_diff = checker.difference_value({'x': 51, 'y': 49}, {'x': 49, 'y': 51})
+ large_diff = checker.difference_value({'x': 99, 'y': 1}, {'x': 1, 'y': 99})
+ self.assertLess(small_diff, large_diff)
+
+ def test_difference_value_with_one_empty_cell_uses_epsilon_bound(self):
+ checker = AdvancedHoeffdingChecker(alpha=0.05)
+ value = checker.difference_value({}, {'x': 10})
+ self.assertGreater(value, 0)
+
+ def test_difference_value_both_empty_is_zero(self):
+ checker = AdvancedHoeffdingChecker()
+ self.assertEqual(checker.difference_value({}, {}), 0)
+
+
+class ChiSquareCheckerTest(unittest.TestCase):
+
+ def test_invalid_alpha_raises(self):
+ with self.assertRaises(ValueError):
+ ChiSquareChecker(alpha=0.1234)
+
+ def test_valid_alphas_are_accepted(self):
+ for alpha in (0.05, 0.01, 0.001):
+ ChiSquareChecker(alpha=alpha)
+
+ def test_identical_distributions_are_not_different(self):
+ checker = ChiSquareChecker(alpha=0.05)
+ self.assertFalse(checker.are_cells_different({'x': 100, 'y': 100}, {'x': 1000, 'y': 1000}))
+
+ def test_clearly_different_ratios_are_different(self):
+ checker = ChiSquareChecker(alpha=0.05)
+ self.assertTrue(checker.are_cells_different({'x': 950, 'y': 50}, {'x': 50, 'y': 950}))
+
+ def test_empty_cell_is_never_different(self):
+ checker = ChiSquareChecker(alpha=0.05)
+ self.assertFalse(checker.are_cells_different({}, {'x': 10}))
+ self.assertFalse(checker.are_cells_different({'x': 10}, {}))
+
+ def test_single_shared_output_key_has_zero_degrees_of_freedom_and_is_not_different(self):
+ checker = ChiSquareChecker(alpha=0.05)
+ self.assertFalse(checker.are_cells_different({'x': 5}, {'x': 500}))
+
+ def test_disjoint_supports_falls_back_to_hoeffding_and_flags_large_samples_as_different(self):
+ checker = ChiSquareChecker(alpha=0.05)
+ self.assertTrue(checker.are_cells_different({'x': 1000}, {'y': 1000}))
+
+ def test_use_diff_value_reflects_constructor_flag(self):
+ self.assertFalse(ChiSquareChecker(use_diff_value=False).use_diff_value())
+ self.assertTrue(ChiSquareChecker(use_diff_value=True).use_diff_value())
+
+ def test_difference_value_zero_for_single_degree_of_freedom(self):
+ checker = ChiSquareChecker()
+ self.assertEqual(checker.difference_value({'x': 5}, {'x': 500}), 0)
+
+ def test_difference_value_grows_with_disagreement(self):
+ checker = ChiSquareChecker()
+ small = checker.difference_value({'x': 51, 'y': 49}, {'x': 49, 'y': 51})
+ large = checker.difference_value({'x': 99, 'y': 1}, {'x': 1, 'y': 99})
+ self.assertLess(small, large)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic/test_sampling_based_observation_table.py b/tests/learning_algs/stochastic/test_sampling_based_observation_table.py
new file mode 100644
index 00000000000..42ad1e5b470
--- /dev/null
+++ b/tests/learning_algs/stochastic/test_sampling_based_observation_table.py
@@ -0,0 +1,233 @@
+import unittest
+from collections import defaultdict
+
+from aalpy.learning_algs.stochastic.SamplingBasedObservationTable import SamplingBasedObservationTable
+
+
+class _Teacher:
+ initial_value = [False]
+
+ def complete_query(self, s, e):
+ return True
+
+
+class _CompatibilityChecker:
+ def __init__(self, different_pairs=frozenset()):
+ self.different_pairs = different_pairs
+
+ def are_cells_different(self, c1, c2, **kwargs):
+ return (id(c1), id(c2)) in self.different_pairs or (id(c2), id(c1)) in self.different_pairs
+
+ def use_diff_value(self):
+ return False
+
+
+def _reachable_states(automaton):
+ reachable = set()
+ stack = [automaton.initial_state]
+ while stack:
+ state = stack.pop()
+ if state in reachable:
+ continue
+ reachable.add(state)
+ for transitions in state.transitions.values():
+ for target, probability in transitions:
+ if probability and target not in reachable:
+ stack.append(target)
+ return reachable
+
+
+class StochasticObservationTableTest(unittest.TestCase):
+
+ def test_mdp_representatives_are_access_closed(self):
+ table = SamplingBasedObservationTable(
+ ['a', 'b', 'c', 'd'], 'mdp', _Teacher(), _CompatibilityChecker()
+ )
+
+ initial = (False,)
+ first = (False, 'd', False)
+ second = (False, 'd', False, 'c', False)
+ third = (False, 'd', False, 'c', False, 'b', False)
+ discovered = (False, 'd', False, 'c', False, 'b', False, 'c', True)
+ table.S = [initial, first, second, third, discovered]
+
+ table.T = defaultdict(dict)
+ for row in table.S:
+ for inp in table.input_alphabet:
+ table.T[row][inp] = {row[-1]: 10}
+
+ table.T[initial][('d',)] = {False: 10}
+ table.T[first][('c',)] = {False: 10}
+ table.T[second][('b',)] = {False: 10}
+ table.T[third][('c',)] = {False: 9, True: 1}
+
+ table.update_compatibility_classes()
+ self.assertTrue(set(table.S).issubset(table.compatibility_classes_representatives))
+
+ hypothesis = table.generate_hypothesis()
+ non_chaos_states = {state for state in hypothesis.states if state.output != 'chaos'}
+ self.assertTrue(non_chaos_states.issubset(_reachable_states(hypothesis)))
+
+
+class AreRowsCompatibleTest(unittest.TestCase):
+
+ def test_mdp_rows_with_different_final_output_are_never_compatible(self):
+ table = SamplingBasedObservationTable(['a'], 'mdp', _Teacher(), _CompatibilityChecker())
+ s1 = (False,)
+ s2 = (True,)
+ table.T[s1][('a',)] = {False: 5}
+ table.T[s2][('a',)] = {False: 5}
+ # cells are identical (checker would say compatible), but final outputs differ
+ self.assertFalse(table.are_rows_compatible(s1, s2))
+
+ def test_smm_rows_use_checker_only(self):
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ s2 = ('a', 'x')
+ table.T[s1][('a',)] = {'x': 5}
+ table.T[s2][('a',)] = {'x': 5}
+ self.assertTrue(table.are_rows_compatible(s1, s2))
+
+ def test_rows_incompatible_when_checker_flags_a_cell(self):
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ s2 = ('a', 'x')
+ cell1 = {'x': 5}
+ cell2 = {'x': 1, 'y': 4}
+ table.T[s1][('a',)] = cell1
+ table.T[s2][('a',)] = cell2
+ table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))})
+ self.assertFalse(table.are_rows_compatible(s1, s2))
+
+
+class UpdateCompatibilityClassesTest(unittest.TestCase):
+
+ def test_incompatible_rows_end_up_in_different_classes(self):
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ s2 = ('a', 'x')
+ cell1 = {'x': 5}
+ cell2 = {'x': 1, 'y': 4}
+ table.S = [s1, s2]
+ table.T[s1][('a',)] = cell1
+ table.T[s2][('a',)] = cell2
+ table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))})
+
+ table.update_compatibility_classes()
+
+ self.assertEqual(set(table.compatibility_classes_representatives), {s1, s2})
+ self.assertEqual(table.compatibility_class[s1], [])
+ self.assertEqual(table.compatibility_class[s2], [])
+
+ def test_compatible_rows_merge_into_a_single_class(self):
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ s2 = ('a', 'x')
+ table.S = [s1, s2]
+ table.T[s1][('a',)] = {'x': 5}
+ table.T[s2][('a',)] = {'x': 3}
+
+ table.update_compatibility_classes()
+
+ self.assertEqual(len(table.compatibility_classes_representatives), 1)
+ rep = table.compatibility_classes_representatives[0]
+ other = s2 if rep == s1 else s1
+ self.assertEqual(table.compatibility_class[rep], [other])
+
+
+class GetRowToCloseTest(unittest.TestCase):
+
+ def test_returns_none_when_all_extensions_are_covered(self):
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ table.S = [s1]
+ # no output observed for input 'a' -> get_extended_s yields nothing
+ table.T[s1][('a',)] = {}
+ table.freq_query_cache[s1 + ('a',)] = {}
+ table.update_compatibility_classes()
+ self.assertIsNone(table.get_row_to_close())
+
+ def test_returns_uncovered_extension_row(self):
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ lt = ('a', 'x')
+ table.S = [s1]
+ cell1 = {'x': 5}
+ cell2 = {'y': 1}
+ table.T[s1][('a',)] = cell1
+ table.T[lt][('a',)] = cell2
+ table.freq_query_cache[s1 + ('a',)] = cell1
+ table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))})
+ table.update_compatibility_classes()
+ self.assertEqual(table.get_row_to_close(), lt)
+
+
+class GetConsistencyViolationTest(unittest.TestCase):
+
+ def _build_inconsistent_table(self):
+ # S = [s1, s2] compatible on E=[('a',)], but their extensions by (i='a', o='x') differ on the same
+ # column, which is exactly a consistency violation.
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ s2 = ('a', 'x')
+ table.S = [s1, s2]
+ # rows are compatible: same content on the only column
+ table.T[s1][('a',)] = {'x': 5}
+ table.T[s2][('a',)] = {'x': 5}
+
+ ext1 = s1 + ('a', 'x') # ('a', 'x')
+ ext2 = s2 + ('a', 'x') # ('a', 'x', 'a', 'x')
+ cell1 = {'y': 1}
+ cell2 = {'z': 1}
+ table.T[ext1][('a',)] = cell1
+ table.T[ext2][('a',)] = cell2
+ table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))})
+ return table, ext1, ext2
+
+ def test_detects_violation(self):
+ table, ext1, ext2 = self._build_inconsistent_table()
+ violation = table.get_consistency_violation()
+ self.assertEqual(violation, ('a', 'x', 'a'))
+
+ def test_no_violation_when_extension_cells_agree(self):
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ s2 = ('a', 'x')
+ table.S = [s1, s2]
+ table.T[s1][('a',)] = {'x': 5}
+ table.T[s2][('a',)] = {'x': 5}
+ ext1 = s1 + ('a', 'x')
+ ext2 = s2 + ('a', 'x')
+ table.T[ext1][('a',)] = {'y': 1}
+ table.T[ext2][('a',)] = {'y': 1}
+ self.assertIsNone(table.get_consistency_violation())
+
+ def test_none_when_cex_processing_enabled(self):
+ table, _, _ = self._build_inconsistent_table()
+ table.cex_processing = 'longest_prefix'
+ self.assertIsNone(table.get_consistency_violation())
+
+
+class GetUnambPercentageTest(unittest.TestCase):
+
+ def test_representative_rows_are_unambiguous_but_unexplored_extensions_are_not(self):
+ # S rows s1/s2 are in disjoint compatibility classes (unambiguous), but the freshly
+ # discovered extension rows have no data in T yet, so they are (correctly) compatible
+ # with every representative and therefore counted as ambiguous.
+ table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker())
+ s1 = ()
+ s2 = ('a', 'x')
+ cell1 = {'x': 5}
+ cell2 = {'x': 1, 'y': 4}
+ table.S = [s1, s2]
+ table.T[s1][('a',)] = cell1
+ table.T[s2][('a',)] = cell2
+ table.freq_query_cache[s1 + ('a',)] = cell1
+ table.freq_query_cache[s2 + ('a',)] = cell2
+ table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))})
+
+ self.assertEqual(table.get_unamb_percentage(), 50.0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic/test_stochastic_cex_processing.py b/tests/learning_algs/stochastic/test_stochastic_cex_processing.py
new file mode 100644
index 00000000000..219a23de9da
--- /dev/null
+++ b/tests/learning_algs/stochastic/test_stochastic_cex_processing.py
@@ -0,0 +1,93 @@
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Mdp, MdpState
+from aalpy.learning_algs.stochastic.StochasticCexProcessing import stochastic_longest_prefix, stochastic_rs
+
+
+class StochasticLongestPrefixTest(unittest.TestCase):
+
+ def test_no_matching_prefix_uses_whole_cex(self):
+ # mdp-style cex: output, input, output, input, output, ...
+ cex = ('o0', 'a', 'o1', 'b', 'o2')
+ prefixes = [('does', 'not', 'match')]
+ suffixes = stochastic_longest_prefix(cex, prefixes)
+ # suffixes are ordered shortest (index 0) to longest (last == the whole, untrimmed cex)
+ self.assertEqual(suffixes[0], ('o2',))
+ self.assertEqual(suffixes[-1], cex)
+ self.assertEqual(len(suffixes), 3)
+
+ def test_matching_prefix_trims_cex(self):
+ cex = ('o0', 'a', 'o1', 'b', 'o2')
+ # a prefix matching the leading input 'a' (comparing at odd indices, i.e. inputs) trims the cex down
+ # to ('b', 'o2'), which (having even length) yields a single length-1 suffix.
+ prefixes = [('o0', 'a', 'o1')]
+ suffixes = stochastic_longest_prefix(cex, prefixes)
+ self.assertEqual(suffixes, [('o2',)])
+
+ def test_longest_matching_prefix_is_preferred(self):
+ cex = ('o0', 'a', 'o1', 'b', 'o2', 'c', 'o3')
+ short_prefix = ('o0', 'a', 'o1')
+ long_prefix = ('o0', 'a', 'o1', 'b', 'o2')
+ # the short prefix alone would trim to ('b', 'o2', 'c', 'o3') (even length -> 2 suffixes); since the
+ # long prefix is tried first (prefixes are sorted by length, descending) and also matches, it wins,
+ # trimming to ('c', 'o3') (even length -> a single suffix).
+ suffixes_long_preferred = stochastic_longest_prefix(cex, [short_prefix, long_prefix])
+ self.assertEqual(suffixes_long_preferred, [('o3',)])
+
+ suffixes_short_only = stochastic_longest_prefix(cex, [short_prefix])
+ self.assertEqual(suffixes_short_only, [('o3',), ('o2', 'c', 'o3')])
+
+ def test_full_match_returns_empty_tuple(self):
+ cex = ('o0',)
+ prefixes = [('o0',)]
+ result = stochastic_longest_prefix(cex, prefixes)
+ self.assertEqual(result, ())
+
+ def test_suffixes_are_all_suffixes_of_trimmed_cex_with_odd_lengths(self):
+ cex = ('o0', 'a', 'o1', 'b', 'o2', 'c', 'o3')
+ suffixes = stochastic_longest_prefix(cex, [])
+ for suf in suffixes:
+ self.assertEqual(cex[len(cex) - len(suf):], suf)
+ self.assertEqual(len(suf) % 2, 1)
+
+
+def _hypothesis_mdp():
+ """
+ 2-state deterministic MDP hypothesis: s0(A) --a--> s1(B) --a--> s0(A). Prefixes are assigned in the same
+ (initial_output, i1, o1, i2, o2, ...) format used by SamplingBasedObservationTable.generate_hypothesis.
+ """
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s0.transitions['a'].append((s1, 1.0))
+ s1.transitions['a'].append((s0, 1.0))
+ s0.prefix = ('A',)
+ s1.prefix = ('A', 'a', 'B')
+ return Mdp(s0, [s0, s1])
+
+
+def _ground_truth_sul():
+ """Ground truth: single-state deterministic MDP whose output is always 'X', diverging immediately from
+ the alternating A/B hypothesis above."""
+ g0 = MdpState('g0', output='X')
+ g0.transitions['a'].append((g0, 1.0))
+ return AutomatonSUL(Mdp(g0, [g0]))
+
+
+class StochasticRsTest(unittest.TestCase):
+
+ def test_rs_returns_suffix_pinpointing_first_divergence(self):
+ hypothesis = _hypothesis_mdp()
+ sul = _ground_truth_sul()
+ # real trace observed on the ground truth SUL (all outputs 'X'), in mdp cex format (o0, i1, o1, i2, o2)
+ cex = ('X', 'a', 'X', 'a', 'X')
+
+ suffixes = stochastic_rs(sul, cex, hypothesis)
+
+ self.assertEqual(suffixes, [('X',), ('X', 'a', 'X')])
+ for suf in suffixes:
+ self.assertEqual(len(suf) % 2, 1)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic/test_stochastic_lstar_integration.py b/tests/learning_algs/stochastic/test_stochastic_lstar_integration.py
new file mode 100644
index 00000000000..3c792f36be5
--- /dev/null
+++ b/tests/learning_algs/stochastic/test_stochastic_lstar_integration.py
@@ -0,0 +1,104 @@
+import random
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Mdp, MdpState
+from aalpy.learning_algs import run_stochastic_Lstar
+from aalpy.oracles import RandomWalkEqOracle
+
+# NOTE on why this does not follow the legacy tests/test_stochastic.py verification approach:
+# that test hardcoded aalpy.paths.path_to_prism to a Windows path and called model_check_experiment, which
+# shells out to a real PRISM binary. PRISM is not installed on this machine (or in CI), so that check would
+# always fail with FileNotFoundError. Instead we validate the learned model directly against the ground
+# truth by sampling: comparing per-input-sequence output distributions between the ground truth MDP and the
+# learned model (both Mdp and StochasticMealyMachine expose execute_sequence(state, input_seq) returning one
+# output per input, so no PRISM/conversion step is required for this comparison).
+
+
+def _ground_truth_mdp():
+ """
+ 3-state MDP ("biased grid"): s0(A) --a--> s1(B) w.p. 0.7 / s2(C) w.p. 0.3; s0 --b--> s0.
+ s1(B)/s2(C) are absorbing back to s0 on 'a', and self-loop on 'b'.
+ """
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s2 = MdpState('s2', output='C')
+ s0.transitions['a'].append((s1, 0.7))
+ s0.transitions['a'].append((s2, 0.3))
+ s0.transitions['b'].append((s0, 1.0))
+ s1.transitions['a'].append((s0, 1.0))
+ s1.transitions['b'].append((s1, 1.0))
+ s2.transitions['a'].append((s0, 1.0))
+ s2.transitions['b'].append((s2, 1.0))
+ return Mdp(s0, [s0, s1, s2])
+
+
+def _output_distribution(model, input_seq, n_samples):
+ """Samples model.execute_sequence(initial_state, input_seq) n_samples times and returns a normalized
+ frequency dict over the resulting output tuples."""
+ counts = {}
+ for _ in range(n_samples):
+ outputs = tuple(model.execute_sequence(model.initial_state, list(input_seq)))
+ counts[outputs] = counts.get(outputs, 0) + 1
+ return {k: v / n_samples for k, v in counts.items()}
+
+
+def _total_variation_distance(dist1, dist2):
+ keys = set(dist1) | set(dist2)
+ return 0.5 * sum(abs(dist1.get(k, 0) - dist2.get(k, 0)) for k in keys)
+
+
+def _assert_models_behave_similarly(test_case, ground_truth, learned_model, seed=123, n_samples=300, tolerance=0.35):
+ random.seed(seed)
+ input_sequences = [('a',), ('b', 'a'), ('a', 'a'), ('a', 'b', 'a')]
+ for input_seq in input_sequences:
+ truth_dist = _output_distribution(ground_truth, input_seq, n_samples)
+ learned_dist = _output_distribution(learned_model, input_seq, n_samples)
+ tvd = _total_variation_distance(truth_dist, learned_dist)
+ test_case.assertLessEqual(
+ tvd, tolerance,
+ f'Output distribution for {input_seq} diverged too much: truth={truth_dist} learned={learned_dist}')
+
+
+class StochasticLStarIntegrationTest(unittest.TestCase):
+ """
+ Trimmed-down sweep over run_stochastic_Lstar: one representative combination per automaton_type, plus a
+ couple of extra strategy/cex_processing/samples_cex_strategy variations, instead of the full 2x3x2x3
+ cross-product from the legacy test (kept fast, and every combination exercises the same core algorithm).
+ """
+
+ def _learn(self, automaton_type, strategy, cex_processing, samples_cex_strategy, seed):
+ random.seed(seed)
+ ground_truth = _ground_truth_mdp()
+ input_alphabet = ground_truth.get_input_alphabet()
+ sul = AutomatonSUL(ground_truth)
+ eq_oracle = RandomWalkEqOracle(input_alphabet, sul=sul, num_steps=150, reset_prob=0.25,
+ reset_after_cex=True)
+
+ learned_model = run_stochastic_Lstar(
+ input_alphabet=input_alphabet, eq_oracle=eq_oracle, sul=sul,
+ n_c=20, n_resample=100, min_rounds=5, max_rounds=20,
+ automaton_type=automaton_type, strategy=strategy, cex_processing=cex_processing,
+ samples_cex_strategy=samples_cex_strategy, target_unambiguity=0.99, print_level=0)
+
+ return ground_truth, learned_model
+
+ def test_learn_mdp_classic_strategy(self):
+ ground_truth, learned_model = self._learn('mdp', 'classic', None, None, seed=1)
+ _assert_models_behave_similarly(self, ground_truth, learned_model)
+
+ def test_learn_smm_normal_strategy_with_longest_prefix_cex(self):
+ ground_truth, learned_model = self._learn('smm', 'normal', 'longest_prefix', None, seed=2)
+ _assert_models_behave_similarly(self, ground_truth, learned_model)
+
+ def test_learn_mdp_chi2_strategy_with_bfs_samples_cex(self):
+ ground_truth, learned_model = self._learn('mdp', 'chi2', None, 'bfs', seed=3)
+ _assert_models_behave_similarly(self, ground_truth, learned_model)
+
+ def test_learn_smm_classic_strategy_with_random_samples_cex(self):
+ ground_truth, learned_model = self._learn('smm', 'classic', None, 'random:200:0.3', seed=4)
+ _assert_models_behave_similarly(self, ground_truth, learned_model)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py b/tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py
new file mode 100644
index 00000000000..58998228243
--- /dev/null
+++ b/tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py
@@ -0,0 +1,59 @@
+import random
+import unittest
+
+import pytest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.learning_algs import run_stochastic_Lstar
+from aalpy.oracles import RandomWalkEqOracle
+from tests.learning_algs.stochastic.test_stochastic_lstar_integration import (
+ _assert_models_behave_similarly, _ground_truth_mdp)
+
+pytestmark = pytest.mark.exhaustive
+
+# Full cross-product this repo's legacy tests/test_stochastic.py swept (minus its PRISM dependency, dropped
+# for the same reason as the sibling test_stochastic_lstar_integration.py): 2 automaton_types x 3 strategies
+# x 2 cex_processing x 3 samples_cex_strategy = 36 combinations, instead of the trimmed handful kept in the
+# fast default suite.
+AUTOMATON_TYPES = ['mdp', 'smm']
+STRATEGIES = ['classic', 'normal', 'chi2']
+CEX_PROCESSING = [None, 'longest_prefix']
+SAMPLES_CEX_STRATEGY = [None, 'bfs', 'random:200:0.3']
+
+TEST_CASES = [
+ (automaton_type, strategy, cex_processing, samples_cex_strategy)
+ for automaton_type in AUTOMATON_TYPES
+ for strategy in STRATEGIES
+ for cex_processing in CEX_PROCESSING
+ for samples_cex_strategy in SAMPLES_CEX_STRATEGY
+]
+
+
+class StochasticLStarExhaustiveTest(unittest.TestCase):
+ def _learn(self, automaton_type, strategy, cex_processing, samples_cex_strategy, seed):
+ random.seed(seed)
+ ground_truth = _ground_truth_mdp()
+ input_alphabet = ground_truth.get_input_alphabet()
+ sul = AutomatonSUL(ground_truth)
+ eq_oracle = RandomWalkEqOracle(input_alphabet, sul=sul, num_steps=200, reset_prob=0.25,
+ reset_after_cex=True)
+
+ learned_model = run_stochastic_Lstar(
+ input_alphabet=input_alphabet, eq_oracle=eq_oracle, sul=sul,
+ n_c=20, n_resample=1000, min_rounds=10, max_rounds=100,
+ automaton_type=automaton_type, strategy=strategy, cex_processing=cex_processing,
+ samples_cex_strategy=samples_cex_strategy, target_unambiguity=0.99, print_level=0)
+
+ return ground_truth, learned_model
+
+ def test_full_sweep(self):
+ for seed, (automaton_type, strategy, cex_processing, samples_cex_strategy) in enumerate(TEST_CASES):
+ with self.subTest(automaton_type=automaton_type, strategy=strategy,
+ cex_processing=cex_processing, samples_cex_strategy=samples_cex_strategy):
+ ground_truth, learned_model = self._learn(automaton_type, strategy, cex_processing,
+ samples_cex_strategy, seed=seed)
+ _assert_models_behave_similarly(self, ground_truth, learned_model)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic/test_stochastic_teacher.py b/tests/learning_algs/stochastic/test_stochastic_teacher.py
new file mode 100644
index 00000000000..422fc8b9c0b
--- /dev/null
+++ b/tests/learning_algs/stochastic/test_stochastic_teacher.py
@@ -0,0 +1,181 @@
+import random
+import unittest
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Mdp, MdpState
+from aalpy.learning_algs.stochastic.DifferenceChecker import HoeffdingChecker
+from aalpy.learning_algs.stochastic.StochasticTeacher import Node, StochasticTeacher
+
+
+def _branching_mdp():
+ """
+ s0(A) --a--> s1(B) with prob 0.7, s2(C) with prob 0.3; s0 --b--> s0(A) with prob 1.0.
+ s1, s2 --a/b--> s0 with prob 1.0 (absorbing back).
+ """
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s2 = MdpState('s2', output='C')
+ s0.transitions['a'].append((s1, 0.7))
+ s0.transitions['a'].append((s2, 0.3))
+ s0.transitions['b'].append((s0, 1.0))
+ s1.transitions['a'].append((s0, 1.0))
+ s1.transitions['b'].append((s0, 1.0))
+ s2.transitions['a'].append((s0, 1.0))
+ s2.transitions['b'].append((s0, 1.0))
+ return Mdp(s0, [s0, s1, s2])
+
+
+class _NullEqOracle:
+ num_queries = 0
+ num_steps = 0
+
+ def reset_counter(self):
+ pass
+
+ def find_cex(self, hypothesis):
+ return None
+
+
+class StochasticTeacherAddAndQueryTest(unittest.TestCase):
+
+ def setUp(self):
+ random.seed(42)
+ self.sul = AutomatonSUL(_branching_mdp())
+ self.teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(),
+ automaton_type='mdp', compatibility_checker=HoeffdingChecker())
+
+ def test_initial_value_is_captured_from_sul(self):
+ self.assertEqual(self.teacher.initial_value, ['A'])
+ self.assertEqual(self.teacher.root_node.output, 'A')
+
+ def test_add_updates_frequency_and_children(self):
+ self.teacher.back_to_root()
+ self.teacher.add('a', 'B')
+ self.assertEqual(self.teacher.root_node.input_frequencies['a'], 1)
+ child = self.teacher.root_node.get_child('a', 'B')
+ self.assertIsNotNone(child)
+ self.assertEqual(child.frequency, 1)
+
+ self.teacher.back_to_root()
+ self.teacher.add('a', 'B')
+ # same (input, output) pair increments the same child's frequency
+ self.assertEqual(self.teacher.root_node.input_frequencies['a'], 2)
+ self.assertEqual(child.frequency, 2)
+
+ def test_frequency_query_reflects_added_traces(self):
+ for _ in range(5):
+ self.teacher.back_to_root()
+ self.teacher.add('a', 'B')
+ for _ in range(3):
+ self.teacher.back_to_root()
+ self.teacher.add('a', 'C')
+
+ # s is the mdp-format prefix (initial_output,), e is ('a',)
+ freq = self.teacher.frequency_query(('A',), ('a',))
+ self.assertEqual(freq, {'B': 5, 'C': 3})
+
+ def test_frequency_query_missing_path_returns_empty_dict(self):
+ freq = self.teacher.frequency_query(('A', 'a', 'B'), ('a',))
+ self.assertEqual(freq, {})
+
+ def test_complete_query_false_below_n_c_true_at_or_above(self):
+ for i in range(4):
+ self.teacher.back_to_root()
+ self.teacher.add('a', 'B')
+ self.assertFalse(self.teacher.complete_query(('A',), ('a',)))
+
+ self.teacher.back_to_root()
+ self.teacher.add('a', 'B')
+ self.assertTrue(self.teacher.complete_query(('A',), ('a',)))
+
+ def test_complete_query_is_cached(self):
+ for i in range(5):
+ self.teacher.back_to_root()
+ self.teacher.add('a', 'B')
+ self.assertTrue(self.teacher.complete_query(('A',), ('a',)))
+ # the mdp's leading initial-output symbol is stripped internally before building the cache key
+ self.assertIn(('a',), self.teacher.complete_query_cache)
+
+
+class StochasticTeacherTreeQueryTest(unittest.TestCase):
+
+ def setUp(self):
+ random.seed(1)
+ self.sul = AutomatonSUL(_branching_mdp())
+ self.teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(),
+ automaton_type='mdp', compatibility_checker=HoeffdingChecker())
+
+ def test_tree_query_samples_from_pta_and_adds_to_tree(self):
+ pta_root = Node('A')
+ pta_root.input_frequencies['a'] = 10
+ pta_root.children['a']['B'] = Node('B')
+ pta_root.children['a']['C'] = Node('C')
+
+ for _ in range(50):
+ self.teacher.tree_query(pta_root)
+
+ total_a = self.teacher.root_node.get_frequency_sum('a')
+ self.assertEqual(total_a, 50)
+ freqs = self.teacher.root_node.get_output_frequencies('a')
+ self.assertEqual(sum(freqs.values()), 50)
+ self.assertTrue(set(freqs.keys()).issubset({'B', 'C'}))
+
+
+class StochasticTeacherEquivalenceQueryTest(unittest.TestCase):
+
+ def setUp(self):
+ random.seed(7)
+ self.sul = AutomatonSUL(_branching_mdp())
+
+ def test_last_cex_is_reused_while_still_valid(self):
+ teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(),
+ automaton_type='mdp', compatibility_checker=HoeffdingChecker())
+ hyp_s0 = MdpState('s0', output='A')
+ hyp_s0.prefix = ('A',)
+ hypothesis = Mdp(hyp_s0, [hyp_s0])
+ # hypothesis has no transitions at all, so any cex trying to step 'a' is still "unprocessed";
+ # cex format is (o0, i1, o1, ..., i_n), ending in a dangling input
+ teacher.last_cex = ('A', 'a', 'B', 'a')
+
+ cex = teacher.equivalence_query(hypothesis)
+ self.assertEqual(cex, ('A', 'a', 'B', 'a'))
+
+ def test_falls_back_to_eq_oracle_when_no_cached_or_tree_cex(self):
+ class _OracleWithCex:
+ num_queries = 0
+ num_steps = 0
+
+ def reset_counter(self):
+ pass
+
+ def find_cex(self, hypothesis):
+ return ('A', 'a', 'B', 'a', 'A')
+
+ teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_OracleWithCex(),
+ automaton_type='mdp', compatibility_checker=HoeffdingChecker())
+ hyp_s0 = MdpState('s0', output='A')
+ hyp_s0.prefix = ('A',)
+ hypothesis = Mdp(hyp_s0, [hyp_s0])
+
+ cex = teacher.equivalence_query(hypothesis)
+ # equivalence_query strips the last element of the oracle's cex
+ self.assertEqual(cex, ('A', 'a', 'B', 'a'))
+ self.assertEqual(teacher.last_cex, cex)
+
+ def test_bfs_for_cex_in_tree_finds_output_mismatch(self):
+ teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(),
+ automaton_type='mdp', compatibility_checker=HoeffdingChecker())
+ for _ in range(10):
+ teacher.back_to_root()
+ teacher.add('a', 'B')
+
+ hyp_s0 = MdpState('s0', output='A')
+ hyp_s0.prefix = ('A',)
+ hypothesis = Mdp(hyp_s0, [hyp_s0]) # no transitions at all for 'a'
+
+ cex = teacher.bfs_for_cex_in_tree(hypothesis)
+ self.assertEqual(cex, ('A', 'a'))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic_passive/test_alergia_integration.py b/tests/learning_algs/stochastic_passive/test_alergia_integration.py
new file mode 100644
index 00000000000..e85159fecfd
--- /dev/null
+++ b/tests/learning_algs/stochastic_passive/test_alergia_integration.py
@@ -0,0 +1,206 @@
+import os
+import random
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from aalpy.SULs import AutomatonSUL
+from aalpy.automata import Mdp, MdpState, MarkovChain, McState
+from aalpy.learning_algs import run_Alergia, run_active_Alergia
+from aalpy.learning_algs.stochastic_passive.ActiveAleriga import RandomWordSampler
+from aalpy.learning_algs.stochastic_passive.Alergia import run_JAlergia
+
+
+def _ground_truth_mdp():
+ """
+ 3-state MDP: s0(A) --a--> s1(B) w.p. 0.7 / s2(C) w.p. 0.3; s0 --b--> s0.
+ s1(B)/s2(C) absorb back to s0 on 'a', self-loop on 'b'.
+ """
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s2 = MdpState('s2', output='C')
+ s0.transitions['a'].append((s1, 0.7))
+ s0.transitions['a'].append((s2, 0.3))
+ s0.transitions['b'].append((s0, 1.0))
+ s1.transitions['a'].append((s0, 1.0))
+ s1.transitions['b'].append((s1, 1.0))
+ s2.transitions['a'].append((s0, 1.0))
+ s2.transitions['b'].append((s2, 1.0))
+ return Mdp(s0, [s0, s1, s2])
+
+
+def _ground_truth_mc():
+ """2-state biased coin Markov chain: heads (0.7) self-loop-ish, tails (0.3)."""
+ heads = McState('heads', output='H')
+ tails = McState('tails', output='T')
+ heads.transitions.append((heads, 0.7))
+ heads.transitions.append((tails, 0.3))
+ tails.transitions.append((heads, 0.6))
+ tails.transitions.append((tails, 0.4))
+ return MarkovChain(heads, [heads, tails])
+
+
+def _generate_mdp_data(ground_truth, alphabet, num_traces, trace_len):
+ sul = AutomatonSUL(ground_truth)
+ data = []
+ for _ in range(num_traces):
+ walk = [random.choice(alphabet) for _ in range(trace_len)]
+ outputs = sul.query(tuple(walk))
+ trace = [ground_truth.initial_state.output]
+ for inp, out in zip(walk, outputs):
+ trace.append((inp, out))
+ data.append(trace)
+ return data
+
+
+def _generate_mc_data(ground_truth, num_traces, trace_len):
+ data = []
+ for _ in range(num_traces):
+ ground_truth.reset_to_initial()
+ trace = [ground_truth.current_state.output]
+ for _ in range(trace_len):
+ trace.append(ground_truth.step())
+ data.append(trace)
+ return data
+
+
+def _output_distribution(model, input_seq, n_samples):
+ counts = {}
+ for _ in range(n_samples):
+ outputs = tuple(model.execute_sequence(model.initial_state, list(input_seq)))
+ counts[outputs] = counts.get(outputs, 0) + 1
+ return {k: v / n_samples for k, v in counts.items()}
+
+
+def _total_variation_distance(dist1, dist2):
+ keys = set(dist1) | set(dist2)
+ return 0.5 * sum(abs(dist1.get(k, 0) - dist2.get(k, 0)) for k in keys)
+
+
+class RunAlergiaMdpTest(unittest.TestCase):
+
+ def test_learned_mdp_resembles_ground_truth_output_distributions(self):
+ random.seed(10)
+ ground_truth = _ground_truth_mdp()
+ alphabet = ground_truth.get_input_alphabet()
+ data = _generate_mdp_data(ground_truth, alphabet, num_traces=3000, trace_len=4)
+
+ learned_model = run_Alergia(data, automaton_type='mdp', eps=0.3, print_info=False)
+
+ random.seed(99)
+ for input_seq in [('a',), ('a', 'a'), ('b', 'a')]:
+ truth_dist = _output_distribution(ground_truth, input_seq, 300)
+ learned_dist = _output_distribution(learned_model, input_seq, 300)
+ tvd = _total_variation_distance(truth_dist, learned_dist)
+ self.assertLessEqual(tvd, 0.35, f'{input_seq}: truth={truth_dist} learned={learned_dist}')
+
+
+class RunAlergiaMcTest(unittest.TestCase):
+
+ def test_learned_markov_chain_resembles_ground_truth_stationary_behaviour(self):
+ random.seed(11)
+ ground_truth = _ground_truth_mc()
+ data = _generate_mc_data(ground_truth, num_traces=3000, trace_len=5)
+
+ learned_model = run_Alergia(data, automaton_type='mc', eps=0.3, print_info=False)
+
+ def first_step_distribution(model, n):
+ counts = {'H': 0, 'T': 0}
+ for _ in range(n):
+ model.reset_to_initial()
+ out = model.step()
+ counts[out] = counts.get(out, 0) + 1
+ return {k: v / n for k, v in counts.items()}
+
+ random.seed(55)
+ truth_dist = first_step_distribution(ground_truth, 1000)
+ learned_dist = first_step_distribution(learned_model, 1000)
+ tvd = _total_variation_distance(truth_dist, learned_dist)
+ self.assertLessEqual(tvd, 0.2, f'truth={truth_dist} learned={learned_dist}')
+
+
+class RunJAlergiaTest(unittest.TestCase):
+
+ def test_missing_jar_returns_none_without_raising(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ missing_jar = os.path.join(tmp_dir, 'does_not_exist.jar')
+ data_file = os.path.join(tmp_dir, 'data.txt')
+ with open(data_file, 'w') as f:
+ f.write('A,a,B\n')
+
+ result = run_JAlergia(data_file, 'mdp', missing_jar)
+
+ self.assertIsNone(result)
+
+
+class RandomWordSamplerTest(unittest.TestCase):
+
+ def test_sample_uses_true_initial_output_and_aligned_input_output_pairs(self):
+ # Regression test: RandomWordSampler.sample() used to treat the output observed after the *first*
+ # input as if it were the SUL's initial output, and then paired random_walk[i] with the output
+ # observed after input i+1 (an off-by-one misalignment). Both bugs corrupted every generated sample
+ # and could even make consistent MDP data collection impossible (differing "initial outputs" trip
+ # the FPTA's initial-output consistency assertion).
+ random.seed(30)
+ # deterministic (prob-1.0 only) ground truth so re-querying the same inputs later is reproducible
+ s0 = MdpState('s0', output='A')
+ s1 = MdpState('s1', output='B')
+ s0.transitions['a'].append((s1, 1.0))
+ s0.transitions['b'].append((s0, 1.0))
+ s1.transitions['a'].append((s0, 1.0))
+ s1.transitions['b'].append((s1, 1.0))
+ ground_truth = Mdp(s0, [s0, s1])
+ sul = AutomatonSUL(ground_truth)
+ sampler = RandomWordSampler(num_walks=20, min_walk_len=3, max_walk_len=3)
+
+ samples = sampler.sample(sul, ground_truth)
+
+ for sample in samples:
+ initial_output, *steps = sample
+ self.assertEqual(initial_output, ground_truth.initial_state.output)
+
+ inputs = tuple(io[0] for io in steps)
+ outputs = tuple(io[1] for io in steps)
+ expected_outputs = tuple(sul.query(inputs))
+ self.assertEqual(outputs, expected_outputs)
+
+
+class RunActiveAlergiaTest(unittest.TestCase):
+
+ def test_active_alergia_improves_model_over_iterations(self):
+ random.seed(21)
+ ground_truth = _ground_truth_mdp()
+ alphabet = ground_truth.get_input_alphabet()
+ initial_data = _generate_mdp_data(ground_truth, alphabet, num_traces=200, trace_len=3)
+
+ sul = AutomatonSUL(ground_truth)
+ sampler = RandomWordSampler(num_walks=200, min_walk_len=2, max_walk_len=4)
+
+ learned_model = run_active_Alergia(initial_data, sul, sampler, n_iter=3, eps=0.3, print_info=False)
+
+ random.seed(88)
+ for input_seq in [('a',), ('b', 'a')]:
+ truth_dist = _output_distribution(ground_truth, input_seq, 300)
+ learned_dist = _output_distribution(learned_model, input_seq, 300)
+ tvd = _total_variation_distance(truth_dist, learned_dist)
+ self.assertLessEqual(tvd, 0.35, f'{input_seq}: truth={truth_dist} learned={learned_dist}')
+
+ def test_active_alergia_forwards_automaton_type_to_run_alergia(self):
+ # Regression test: run_active_Alergia used to hardcode automaton_type='mdp' when calling run_Alergia,
+ # silently ignoring its own automaton_type parameter. Verify the parameter is now actually forwarded.
+ data = [['A', ('a', 'B')]]
+ sul = object()
+
+ class _NoopSampler:
+ def sample(self, sul, model):
+ return []
+
+ with patch('aalpy.learning_algs.stochastic_passive.ActiveAleriga.run_Alergia') as mock_run_alergia:
+ mock_run_alergia.return_value = 'fake-model'
+ run_active_Alergia(data, sul, _NoopSampler(), n_iter=1, automaton_type='smm', print_info=False)
+
+ self.assertEqual(mock_run_alergia.call_args.kwargs['automaton_type'], 'smm')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic_passive/test_compatibility_checker.py b/tests/learning_algs/stochastic_passive/test_compatibility_checker.py
new file mode 100644
index 00000000000..e771caf3d04
--- /dev/null
+++ b/tests/learning_algs/stochastic_passive/test_compatibility_checker.py
@@ -0,0 +1,93 @@
+import unittest
+
+from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import HoeffdingCompatibility
+from aalpy.learning_algs.stochastic_passive.FPTA import AlergiaPtaNode
+
+
+def _mc_node(freq: dict) -> AlergiaPtaNode:
+ """Builds a standalone AlergiaPtaNode with plain-symbol (Alergia/MC-style) original frequencies."""
+ node = AlergiaPtaNode(None, ())
+ node.original_input_frequency = dict(freq)
+ node.original_children = {k: AlergiaPtaNode(k, (k,)) for k in freq}
+ return node
+
+
+def _io_node(freq: dict) -> AlergiaPtaNode:
+ """Builds a standalone AlergiaPtaNode with (input, output)-keyed (IOAlergia-style) original frequencies."""
+ node = AlergiaPtaNode(None, ())
+ node.original_input_frequency = dict(freq)
+ node.original_children = {k: AlergiaPtaNode(k[1], (k,)) for k in freq}
+ return node
+
+
+class HoeffdingCompatibilityMcTest(unittest.TestCase):
+ """For plain-symbol (Alergia) data, original_input_frequency keys are not tuples."""
+
+ def test_identical_distributions_are_compatible(self):
+ node_a = _mc_node({'x': 500, 'y': 500})
+ node_b = _mc_node({'x': 500, 'y': 500})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertFalse(checker.are_states_different(node_a, node_b))
+
+ def test_clearly_different_distributions_are_different(self):
+ node_a = _mc_node({'x': 950, 'y': 50})
+ node_b = _mc_node({'x': 50, 'y': 950})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertTrue(checker.are_states_different(node_a, node_b))
+
+ def test_no_data_on_either_side_is_never_different(self):
+ node_a = _mc_node({})
+ node_b = _mc_node({'x': 10})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertFalse(checker.are_states_different(node_a, node_b))
+
+ def test_smaller_eps_makes_the_bound_wider_and_less_sensitive(self):
+ # A smaller eps means we demand more confidence before calling two distributions "different",
+ # which widens the Hoeffding bound; the same borderline difference can therefore be flagged as
+ # different for a larger eps but not for a much smaller one.
+ node_a = _mc_node({'x': 5150, 'y': 4850})
+ node_b = _mc_node({'x': 4850, 'y': 5150})
+ larger_eps = HoeffdingCompatibility(eps=0.3)
+ smaller_eps = HoeffdingCompatibility(eps=0.001)
+ self.assertTrue(larger_eps.are_states_different(node_a, node_b))
+ self.assertFalse(smaller_eps.are_states_different(node_a, node_b))
+
+
+class HoeffdingCompatibilityIOAlergiaTest(unittest.TestCase):
+ """For (input, output)-keyed (IOAlergia) data, the Hoeffding bound is checked per shared input."""
+
+ def test_identical_conditional_output_distributions_are_compatible(self):
+ node_a = _io_node({('a', 'x'): 500, ('a', 'y'): 500})
+ node_b = _io_node({('a', 'x'): 500, ('a', 'y'): 500})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertFalse(checker.are_states_different(node_a, node_b))
+
+ def test_different_conditional_output_distributions_are_different(self):
+ node_a = _io_node({('a', 'x'): 950, ('a', 'y'): 50})
+ node_b = _io_node({('a', 'x'): 50, ('a', 'y'): 950})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertTrue(checker.are_states_different(node_a, node_b))
+
+ def test_disjoint_inputs_between_nodes_are_never_different(self):
+ # get_immutable_inputs intersection is empty -> the per-input loop never runs -> not different,
+ # regardless of how skewed each node's own distribution is
+ node_a = _io_node({('a', 'x'): 950, ('a', 'y'): 50})
+ node_b = _io_node({('b', 'x'): 50, ('b', 'y'): 950})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertFalse(checker.are_states_different(node_a, node_b))
+
+ def test_different_output_for_shared_input_is_detected_even_with_extra_disjoint_input(self):
+ node_a = _io_node({('a', 'x'): 950, ('a', 'y'): 50, ('c', 'z'): 10})
+ node_b = _io_node({('a', 'x'): 50, ('a', 'y'): 950})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertTrue(checker.are_states_different(node_a, node_b))
+
+ def test_no_data_on_either_side_is_never_different(self):
+ node_a = _io_node({})
+ node_b = _io_node({('a', 'x'): 10})
+ checker = HoeffdingCompatibility(eps=0.05)
+ self.assertFalse(checker.are_states_different(node_a, node_b))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/learning_algs/stochastic_passive/test_fpta.py b/tests/learning_algs/stochastic_passive/test_fpta.py
new file mode 100644
index 00000000000..43a23b35887
--- /dev/null
+++ b/tests/learning_algs/stochastic_passive/test_fpta.py
@@ -0,0 +1,125 @@
+import unittest
+
+from aalpy.learning_algs.stochastic_passive.FPTA import AlergiaPtaNode, create_fpta
+
+
+class CreateFptaMcTest(unittest.TestCase):
+
+ def test_single_sequence_builds_a_chain(self):
+ # for 'mc', like 'mdp', the first element of each sequence is treated as the (shared) initial output
+ # of the root and is not itself a transition
+ data = [['a', 'b', 'a']]
+ root = create_fpta(data, 'mc')
+
+ self.assertEqual(root.prefix, ())
+ self.assertEqual(root.output, 'a')
+ self.assertEqual(root.input_frequency, {'b': 1})
+
+ node_b = root.children['b']
+ self.assertEqual(node_b.output, 'b')
+ self.assertEqual(node_b.prefix, ('b',))
+ self.assertEqual(node_b.input_frequency, {'a': 1})
+
+ node_ba = node_b.children['a']
+ self.assertEqual(node_ba.output, 'a')
+ self.assertEqual(node_ba.children, {})
+
+ def test_shared_prefixes_merge_into_a_tree(self):
+ data = [['a', 'b'], ['a', 'c'], ['a', 'b']]
+ root = create_fpta(data, 'mc')
+
+ self.assertEqual(root.output, 'a')
+ self.assertEqual(root.input_frequency, {'b': 2, 'c': 1})
+ self.assertEqual(set(root.children.keys()), {'b', 'c'})
+
+
+class CreateFptaMdpTest(unittest.TestCase):
+
+ def test_initial_output_is_first_element_of_first_sequence(self):
+ data = [['A', ('a', 'B'), ('b', 'A')], ['A', ('a', 'C')]]
+ root = create_fpta(data, 'mdp')
+
+ self.assertEqual(root.output, 'A')
+ self.assertEqual(root.prefix, ())
+ self.assertEqual(root.input_frequency, {('a', 'B'): 1, ('a', 'C'): 1})
+
+ node_b = root.children[('a', 'B')]
+ self.assertEqual(node_b.output, 'B')
+ self.assertEqual(node_b.prefix, (('a', 'B'),))
+ self.assertEqual(node_b.input_frequency, {('b', 'A'): 1})
+
+ node_c = root.children[('a', 'C')]
+ self.assertEqual(node_c.output, 'C')
+ self.assertEqual(node_c.children, {})
+
+ def test_inconsistent_initial_output_raises(self):
+ data = [['A', ('a', 'B')], ['X', ('a', 'B')]]
+ with self.assertRaises(AssertionError):
+ create_fpta(data, 'mdp')
+
+
+class CreateFptaSmmTest(unittest.TestCase):
+
+ def test_no_initial_output_and_outputs_are_none(self):
+ data = [['a', 'o1', 'b', 'o2'], ['a', 'o1', 'a', 'o3']]
+ root = create_fpta(data, 'smm')
+
+ self.assertIsNone(root.output)
+ self.assertEqual(root.prefix, ())
+ self.assertEqual(root.input_frequency, {'a': 2})
+
+ node = root.children['a']
+ # for smm the output is never inferred from the input element itself
+ self.assertIsNone(node.output)
+ self.assertEqual(node.input_frequency, {'o1': 2})
+
+ node2 = node.children['o1']
+ self.assertEqual(set(node2.children.keys()), {'b', 'a'})
+
+
+class AlergiaPtaNodeHelpersTest(unittest.TestCase):
+
+ def test_get_input_frequency_sums_over_outputs(self):
+ node = AlergiaPtaNode(None, ())
+ node.input_frequency = {('a', 'x'): 3, ('a', 'y'): 2, ('b', 'x'): 5}
+ self.assertEqual(node.get_input_frequency('a'), 5)
+ self.assertEqual(node.get_input_frequency('b'), 5)
+ self.assertEqual(node.get_input_frequency('c'), 0)
+
+ def test_get_output_frequencies_filters_by_input(self):
+ node = AlergiaPtaNode(None, ())
+ node.input_frequency = {('a', 'x'): 3, ('a', 'y'): 2, ('b', 'x'): 5}
+ self.assertEqual(node.get_output_frequencies('a'), {'x': 3, 'y': 2})
+
+ def test_get_inputs_uses_mutable_input_frequency(self):
+ node = AlergiaPtaNode(None, ())
+ node.input_frequency = {('a', 'x'): 1, ('b', 'y'): 1}
+ self.assertEqual(node.get_inputs(), {'a', 'b'})
+
+ def test_successors_returns_children_values(self):
+ # AlergiaPtaNode defines __eq__ by prefix but is unhashable, so successors are compared by identity
+ parent = AlergiaPtaNode(None, ())
+ child1 = AlergiaPtaNode('x', ('a',))
+ child2 = AlergiaPtaNode('y', ('b',))
+ parent.children = {'a': child1, 'b': child2}
+ successors = parent.successors()
+ self.assertEqual(len(successors), 2)
+ self.assertTrue(any(s is child1 for s in successors))
+ self.assertTrue(any(s is child2 for s in successors))
+
+ def test_ordering_is_by_prefix_length_then_value(self):
+ short = AlergiaPtaNode(None, ('a',))
+ long = AlergiaPtaNode(None, ('a', 'b'))
+ other_short = AlergiaPtaNode(None, ('z',))
+ self.assertLess(short, long)
+ self.assertLess(short, other_short)
+ self.assertLessEqual(short, short)
+
+ def test_equality_is_based_on_prefix_only(self):
+ node1 = AlergiaPtaNode('x', ('a', 'b'))
+ node2 = AlergiaPtaNode('y', ('a', 'b'))
+ self.assertEqual(node1, node2)
+
+
+if __name__ == '__main__':
+ unittest.main()
From 3b42e4b77498e6d4c5e9aab690b0315152a815ea Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:20:19 +0200
Subject: [PATCH 16/25] Remove old test files
---
tests/test_charSet.py | 68 --------
tests/test_deterministic.py | 202 ----------------------
tests/test_deterministic_learning_runs.py | 70 --------
tests/test_deterministic_passive.py | 93 ----------
tests/test_file_operations.py | 38 ----
tests/test_hW.py | 156 -----------------
tests/test_non_deterministic.py | 30 ----
tests/test_rwpmethod_oracle.py | 113 ------------
tests/test_stochastic.py | 70 --------
tests/test_wmethod_oracle.py | 109 ------------
tests/test_wpmethod_oracle.py | 113 ------------
11 files changed, 1062 deletions(-)
delete mode 100644 tests/test_charSet.py
delete mode 100644 tests/test_deterministic.py
delete mode 100644 tests/test_deterministic_learning_runs.py
delete mode 100644 tests/test_deterministic_passive.py
delete mode 100644 tests/test_file_operations.py
delete mode 100644 tests/test_hW.py
delete mode 100644 tests/test_non_deterministic.py
delete mode 100644 tests/test_rwpmethod_oracle.py
delete mode 100644 tests/test_stochastic.py
delete mode 100644 tests/test_wmethod_oracle.py
delete mode 100644 tests/test_wpmethod_oracle.py
diff --git a/tests/test_charSet.py b/tests/test_charSet.py
deleted file mode 100644
index 25d4731f0c5..00000000000
--- a/tests/test_charSet.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import unittest
-
-from aalpy.utils import get_Angluin_dfa, load_automaton_from_file
-from aalpy.utils.HelperFunctions import all_suffixes
-
-
-class TestCharSet(unittest.TestCase):
-
- def get_test_automata(self):
- return {"angluin_dfa": get_Angluin_dfa(),
- "angluin_mealy": load_automaton_from_file('../DotModels/Angluin_Mealy.dot', automaton_type='mealy'),
- "angluin_moore": load_automaton_from_file('../DotModels/Angluin_Moore.dot', automaton_type='moore'),
- "mqtt": load_automaton_from_file('../DotModels/MQTT/emqtt__two_client_will_retain.dot',
- automaton_type='mealy'),
- "openssl": load_automaton_from_file('../DotModels/TLS/OpenSSL_1.0.2_server_regular.dot',
- automaton_type='mealy'),
- "tcp_server": load_automaton_from_file('../DotModels/TCP/TCP_Linux_Client.dot',
- automaton_type='mealy')}
-
- def test_can_differentiate(self):
- automata = self.get_test_automata()
- for init_with_alphabet in [True, False]:
- for (online_suffix_closure, split_all_blocks) in [(False, False), (False, True), (True, False),
- (True, True)]:
- for test_aut_name in automata:
- print(f"Testing with {test_aut_name}")
- test_aut = automata[test_aut_name]
- char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \
- if init_with_alphabet else None
- if "dfa" in test_aut_name or "moore" in test_aut_name:
- char_set_init = [] if char_set_init is None else char_set_init
- char_set_init.append(())
- char_set = test_aut.compute_characterization_set(char_set_init=char_set_init,
- online_suffix_closure=online_suffix_closure,
- split_all_blocks=split_all_blocks)
- print(f"Char. set {char_set}")
- all_responses = set()
- for s in test_aut.states:
- responses_from_s = []
- for c in char_set:
- responses_from_s.append(tuple(test_aut.compute_output_seq(s, c)))
- all_responses.add(tuple(responses_from_s))
-
- # every state must have a unique response to the whole characterization set
- assert len(all_responses) == len(test_aut.states)
-
- def test_suffix_closed(self):
- automata = self.get_test_automata()
- for init_with_alphabet in [True, False]:
- online_suffix_closure = True
- for split_all_blocks in [True, False]:
- for test_aut_name in automata:
- print(f"Testing with {test_aut_name}")
- test_aut = automata[test_aut_name]
- char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \
- if init_with_alphabet else None
- if "dfa" in test_aut_name or "moore" in test_aut_name:
- char_set_init = [] if char_set_init is None else char_set_init
- char_set_init.append(())
- char_set = test_aut.compute_characterization_set(char_set_init=char_set_init,
- online_suffix_closure=online_suffix_closure,
- split_all_blocks=split_all_blocks)
- print(f"Char. set {char_set}")
- for s in char_set:
- for suffix in all_suffixes(s):
- if suffix not in char_set:
- print(suffix)
- assert suffix in char_set
diff --git a/tests/test_deterministic.py b/tests/test_deterministic.py
deleted file mode 100644
index d95349509d7..00000000000
--- a/tests/test_deterministic.py
+++ /dev/null
@@ -1,202 +0,0 @@
-import unittest
-
-from aalpy.SULs import AutomatonSUL
-from aalpy.automata import Dfa, MealyMachine, MooreMachine
-from aalpy.learning_algs import run_Lstar
-from aalpy.oracles import WMethodEqOracle, WpMethodEqOracle, RandomWalkEqOracle, StatePrefixEqOracle, TransitionFocusOracle, \
- RandomWMethodEqOracle, BreadthFirstExplorationEqOracle, RandomWordEqOracle, CacheBasedEqOracle, \
- KWayStateCoverageEqOracle, RandomWpMethodEqOracle
-from aalpy.utils import get_Angluin_dfa, load_automaton_from_file
-from aalpy.utils.ModelChecking import bisimilar
-
-correct_automata = {Dfa: get_Angluin_dfa(),
- MealyMachine: load_automaton_from_file('../DotModels/Angluin_Mealy.dot', automaton_type='mealy'),
- MooreMachine: load_automaton_from_file('../DotModels/Angluin_Moore.dot', automaton_type='moore')}
-
-
-class DeterministicTest(unittest.TestCase):
-
- def prove_equivalence(self, learned_automaton):
-
- correct_automaton = correct_automata[learned_automaton.__class__]
-
- # only work if correct automaton is already minimal
- if len(learned_automaton.states) != len(correct_automaton.states):
- print(len(learned_automaton.states), len(correct_automaton.states))
- return False
-
- return bisimilar(correct_automaton, learned_automaton)
-
- def test_closing_strategies(self):
-
- dfa = get_Angluin_dfa()
-
- alphabet = dfa.get_input_alphabet()
-
- closing_strategies = ['shortest_first', 'longest_first', 'single']
- automata_type = ['dfa', 'mealy', 'moore']
-
- for automata in automata_type:
- for closing in closing_strategies:
- sul = AutomatonSUL(dfa)
- eq_oracle = RandomWalkEqOracle(alphabet, sul, 1000)
-
- learned_dfa = run_Lstar(alphabet, sul, eq_oracle, automaton_type=automata, closing_strategy=closing,
- cache_and_non_det_check=True, cex_processing='rs', print_level=0)
-
- is_eq = self.prove_equivalence(learned_dfa)
- if not is_eq:
- assert False
-
- assert True
-
- def test_suffix_closedness(self):
-
- angluin_example = get_Angluin_dfa()
-
- alphabet = angluin_example.get_input_alphabet()
-
- suffix_closedness = [True, False]
- automata_type = ['dfa', 'mealy', 'moore']
-
- for automata in automata_type:
- for s_closed in suffix_closedness:
- sul = AutomatonSUL(angluin_example)
- eq_oracle = RandomWalkEqOracle(alphabet, sul, 500)
-
- learned_dfa = run_Lstar(alphabet, sul, eq_oracle, automaton_type=automata,
- all_prefixes_in_obs_table=True,
- e_set_suffix_closed=s_closed,
- cache_and_non_det_check=True, cex_processing='rs', print_level=0)
-
- is_eq = self.prove_equivalence(learned_dfa)
- if not is_eq:
- assert False
-
- assert True
-
- def test_cex_processing(self):
- angluin_example = get_Angluin_dfa()
-
- alphabet = angluin_example.get_input_alphabet()
-
- cex_processing = [None, 'longest_prefix', 'rs']
- automata_type = ['dfa', 'mealy', 'moore']
-
- for automata in automata_type:
- for cex in cex_processing:
- sul = AutomatonSUL(angluin_example)
- eq_oracle = RandomWalkEqOracle(alphabet, sul, 500)
-
- learned_dfa = run_Lstar(alphabet, sul, eq_oracle, automaton_type=automata,
- cache_and_non_det_check=True, cex_processing=cex, print_level=0)
-
- is_eq = self.prove_equivalence(learned_dfa)
- if not is_eq:
- assert False
-
- assert True
-
- def test_eq_oracles(self):
- angluin_example = get_Angluin_dfa()
-
- alphabet = angluin_example.get_input_alphabet()
-
- automata_type = ['dfa', 'mealy', 'moore']
-
- for automata in automata_type:
- sul = AutomatonSUL(angluin_example)
-
- random_walk_eq_oracle = RandomWalkEqOracle(alphabet, sul, 5000, reset_after_cex=True)
- state_origin_eq_oracle = StatePrefixEqOracle(alphabet, sul, walks_per_state=10, walk_len=50)
- tran_cov_eq_oracle = TransitionFocusOracle(alphabet, sul, num_random_walks=200, walk_len=30,
- same_state_prob=0.3)
- w_method_eq_oracle = WMethodEqOracle(alphabet, sul, max_number_of_states=len(angluin_example.states) + 1)
- wp_method_eq_oracle = WpMethodEqOracle(alphabet, sul, max_number_of_states=len(angluin_example.states) + 1)
- rwp_method_eq_oracle = RandomWpMethodEqOracle(alphabet, sul)
- random_W_method_eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=10, walk_len=50)
- bf_exploration_eq_oracle = BreadthFirstExplorationEqOracle(alphabet, sul, 4)
- random_word_eq_oracle = RandomWordEqOracle(alphabet, sul)
- cache_based_eq_oracle = CacheBasedEqOracle(alphabet, sul)
- kWayStateCoverageEqOracle = KWayStateCoverageEqOracle(alphabet, sul)
-
- oracles = [random_walk_eq_oracle, random_word_eq_oracle,
- random_W_method_eq_oracle, w_method_eq_oracle,
- wp_method_eq_oracle, rwp_method_eq_oracle,
- kWayStateCoverageEqOracle, cache_based_eq_oracle,
- bf_exploration_eq_oracle, tran_cov_eq_oracle,
- state_origin_eq_oracle]
-
- for oracle in oracles:
- sul = AutomatonSUL(angluin_example)
- oracle.sul = sul
-
- learned_model = run_Lstar(alphabet, sul, oracle, automaton_type=automata,
- cache_and_non_det_check=True, cex_processing=None, print_level=0)
-
- is_eq = self.prove_equivalence(learned_model)
- if not is_eq:
- print(learned_model)
- print(oracle, automata)
- assert False
-
- assert True
-
- def test_all_configuration_combinations(self):
- angluin_example = get_Angluin_dfa()
-
- alphabet = angluin_example.get_input_alphabet()
-
- automata_type = ['dfa', 'mealy', 'moore']
- closing_strategies = ['shortest_first', 'longest_first', 'single']
- cex_processing = [None, 'longest_prefix', 'rs']
- suffix_closedness = [True, False]
- caching = [True, False]
-
- for automata in automata_type:
- for closing in closing_strategies:
- for cex in cex_processing:
- for suffix in suffix_closedness:
- for cache in caching:
- sul = AutomatonSUL(angluin_example)
-
- random_walk_eq_oracle = RandomWalkEqOracle(alphabet, sul, 5000, reset_after_cex=True)
- state_origin_eq_oracle = StatePrefixEqOracle(alphabet, sul, walks_per_state=10, walk_len=50)
- tran_cov_eq_oracle = TransitionFocusOracle(alphabet, sul, num_random_walks=200, walk_len=30,
- same_state_prob=0.3)
- w_method_eq_oracle = WMethodEqOracle(alphabet, sul,
- max_number_of_states=len(angluin_example.states))
- wp_method_eq_oracle = WpMethodEqOracle(alphabet, sul,
- max_number_of_states=len(angluin_example.states))
- rwp_method_eq_oracle = RandomWpMethodEqOracle(alphabet, sul)
- random_W_method_eq_oracle = RandomWMethodEqOracle(alphabet, sul,
- walks_per_state=10, walk_len=50)
- bf_exploration_eq_oracle = BreadthFirstExplorationEqOracle(alphabet, sul, 4)
- random_word_eq_oracle = RandomWordEqOracle(alphabet, sul)
- cache_based_eq_oracle = CacheBasedEqOracle(alphabet, sul)
-
- oracles = [random_walk_eq_oracle, random_word_eq_oracle, random_W_method_eq_oracle,
- rwp_method_eq_oracle, cache_based_eq_oracle, bf_exploration_eq_oracle,
- wp_method_eq_oracle, tran_cov_eq_oracle, w_method_eq_oracle,
- state_origin_eq_oracle]
-
- if not cache:
- oracles.remove(cache_based_eq_oracle)
-
- for oracle in oracles:
- sul = AutomatonSUL(angluin_example)
- oracle.sul = sul
-
- learned_model = run_Lstar(alphabet, sul, oracle, automaton_type=automata,
- closing_strategy=closing,
- cache_and_non_det_check=cache,
- cex_processing=cex,
- e_set_suffix_closed=suffix,
- print_level=0)
-
- is_eq = self.prove_equivalence(learned_model)
- if not is_eq:
- print(oracle, automata)
- assert False
-
- assert True
diff --git a/tests/test_deterministic_learning_runs.py b/tests/test_deterministic_learning_runs.py
deleted file mode 100644
index 2c6364f2211..00000000000
--- a/tests/test_deterministic_learning_runs.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import pytest
-
-from aalpy import generate_random_deterministic_automata, bisimilar, AutomatonSUL, run_Lstar, run_KV, \
- RandomWMethodEqOracle
-from aalpy.learning_algs import run_Lsharp
-
-SEEDS = list(range(50))
-MODEL_SIZES = [
- (2, 2, 2),
- (2, 2, 3),
- (3, 2, 2),
- (3, 2, 3),
- (3, 3, 2),
- (4, 2, 3),
- (4, 3, 2),
- (5, 3, 3),
- (6, 2, 3),
- (6, 3, 2),
- (10, 2, 3),
- (10, 2, 4),
- (10, 2, 2),
- (10, 2, 3),
- (20, 5, 5),
- (30, 3, 4),
-]
-
-TEST_CASES = [
- pytest.param(
- learning_alg,
- automaton_type,
- seed_val,
- num_states,
- input_size,
- output_size,
- id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}",
- )
- for num_states, input_size, output_size in MODEL_SIZES
- for seed_val in SEEDS
- for automaton_type in ['dfa', 'moore', 'mealy']
- for learning_alg in [run_Lstar, run_Lsharp, run_KV]
-]
-
-
-@pytest.mark.parametrize("learning_alg,automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES)
-@pytest.mark.timeout(5)
-def test_learning_algs(learning_alg: callable, automaton_type, seed_val, num_states, input_size, output_size):
- from random import seed
-
- seed(seed_val)
-
- model = generate_random_deterministic_automata(
- automaton_type,
- num_states=num_states,
- input_alphabet_size=input_size,
- output_alphabet_size=output_size,
- )
-
- sul = AutomatonSUL(model)
- input_alphabet = model.get_input_alphabet()
-
- eq_oracle = RandomWMethodEqOracle(input_alphabet, sul, walks_per_state=num_states * 10, walk_len=15)
-
- learned_model = learning_alg(input_alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level = 0)
-
- assert learned_model.is_minimal()
-
- print(learned_model)
- print(model)
- assert bisimilar(model, learned_model)
-
diff --git a/tests/test_deterministic_passive.py b/tests/test_deterministic_passive.py
deleted file mode 100644
index 731a1024efd..00000000000
--- a/tests/test_deterministic_passive.py
+++ /dev/null
@@ -1,93 +0,0 @@
-import unittest
-from itertools import product
-
-import aalpy
-from aalpy.automata import Dfa, MooreMachine, MealyMachine
-from aalpy.learning_algs import run_RPNI
-from aalpy.utils import load_automaton_from_file
-# from aalpy.utils.ModelChecking import bisimilar
-from aalpy.utils.ModelChecking import compare_automata
-
-correct_automata = {Dfa: load_automaton_from_file('../DotModels/SimpleABC/simple_abc_dfa.dot', automaton_type='dfa'),
- MooreMachine: load_automaton_from_file('../DotModels/SimpleABC/simple_abc_moore.dot', automaton_type='moore'),
- MealyMachine: load_automaton_from_file('../DotModels/SimpleABC/simple_abc_mealy.dot', automaton_type='mealy')}
-
-
-class DeterministicPassiveTest(unittest.TestCase):
-
- def prove_equivalence(self, learned_automaton):
-
- correct_automaton = correct_automata[learned_automaton.__class__]
-
- # only work if correct automaton is already minimal
- if len(learned_automaton.states) != len(correct_automaton.states):
- print(len(learned_automaton.states), len(correct_automaton.states))
- return False
-
- return correct_automaton == learned_automaton # bisimilar
-
- def generate_data(self, ground_truth, depth=5, step=1):
- data = []
- if isinstance(ground_truth, aalpy.automata.Dfa) or isinstance(ground_truth, aalpy.automata.MooreMachine):
- data.append(((), ground_truth.initial_state.output))
-
- alphabet = ground_truth.get_input_alphabet()
- for level in range(1, depth + 1, step):
- for seq in product(alphabet, repeat=level):
- ground_truth.reset_to_initial()
- outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq)
- data.append((seq, outputs[-1]))
-
- return data
-
- def test_all_configuration_combinations(self):
- automata_type = {Dfa: 'dfa', MooreMachine: 'moore', MealyMachine: 'mealy'}
- algorithms = ['gsm', 'classic']
-
- for automata in correct_automata:
- correct_automaton = correct_automata[automata]
- alphabet = correct_automaton.get_input_alphabet()
- data = self.generate_data(correct_automaton, depth=3)
- for algorithm in algorithms:
- learned_model = run_RPNI(data,
- automaton_type=automata_type[automata],
- algorithm=algorithm,
- print_info=False)
-
- is_eq = self.prove_equivalence(learned_model)
- if not is_eq:
- print("Learned:")
- print(learned_model)
- print(algorithm, automata_type[automata])
- cex = compare_automata(learned_model, correct_automaton)
- print(cex)
- assert False
-
- assert True
-
- def test_all_configuration_combinations_input_incomplete_data(self):
- automata_type = {Dfa: 'dfa', MooreMachine: 'moore', MealyMachine: 'mealy'}
- algorithms = ['gsm', 'classic']
-
- for automata in correct_automata:
- correct_automaton = correct_automata[automata]
- alphabet = correct_automaton.get_input_alphabet()
- data = self.generate_data(correct_automaton, depth=3, step=2)
- if automata_type[automata] == 'moore':
- data += [(('a', 'a', 'a', 'a'), 1), (('b', 'b', 'b', 'b'), 2), (('c', 'c', 'c', 'c'), 3)]
- for algorithm in algorithms:
- learned_model = run_RPNI(data,
- automaton_type=automata_type[automata],
- algorithm=algorithm,
- print_info=False)
-
- is_eq = self.prove_equivalence(learned_model)
- if not is_eq:
- print("Learned:")
- print(learned_model)
- print(algorithm, automata_type[automata])
- cex = compare_automata(learned_model, correct_automaton)
- print(cex)
- assert False
-
- assert True
diff --git a/tests/test_file_operations.py b/tests/test_file_operations.py
deleted file mode 100644
index dc98750b765..00000000000
--- a/tests/test_file_operations.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import unittest
-
-from aalpy.utils import generate_random_markov_chain, load_automaton_from_file
-from aalpy.utils.BenchmarkSULs import *
-
-
-class TestFileHandler(unittest.TestCase):
-
- def test_saving_loading(self):
- try:
- type_model_pairs = [
- ("dfa", get_Angluin_dfa()),
- ("mealy", load_automaton_from_file('../DotModels/Angluin_Mealy.dot', automaton_type='mealy')),
- ("moore", load_automaton_from_file('../DotModels/Angluin_Moore.dot', automaton_type='moore')),
- ("onfsm", get_benchmark_ONFSM()),
- ("mdp", get_small_pomdp()),
- ("mdp", load_automaton_from_file('../DotModels/MDPs/first_grid.dot', automaton_type='mdp')),
- ("smm", get_faulty_coffee_machine_SMM()),
- ("mc", generate_random_markov_chain(num_states=10)),
- ]
-
- for type, model in type_model_pairs:
- model.save()
- print(model)
- loaded_model = load_automaton_from_file('LearnedModel.dot', type)
- loaded_model.save()
- loaded_model2 = load_automaton_from_file('LearnedModel.dot', type)
-
- if type != 'mc':
- assert set(model.get_input_alphabet()) == set(loaded_model.get_input_alphabet())
- assert set(model.get_input_alphabet()) == set(loaded_model2.get_input_alphabet())
-
- if type in {'dfa', 'moore', 'mealy'}:
- assert model.compute_characterization_set() == loaded_model2.compute_characterization_set()
-
- assert True
- except:
- assert False
diff --git a/tests/test_hW.py b/tests/test_hW.py
deleted file mode 100644
index 2baad740a0a..00000000000
--- a/tests/test_hW.py
+++ /dev/null
@@ -1,156 +0,0 @@
-import pytest
-
-from aalpy import generate_random_deterministic_automata, bisimilar, run_hW, RandomhWOracle, RandomWphWOracle, \
- AutomatonSUL
-from aalpy.SULs import MealySUL
-
-SEEDS = list(range(50))
-MODEL_SIZES = [
- (2, 2, 2),
- (2, 2, 3),
- (3, 2, 2),
- (3, 2, 3),
- (3, 3, 2),
- (4, 2, 3),
- (4, 3, 2),
- (5, 3, 3),
- (6, 2, 3),
- (6, 3, 2),
- (10, 2, 3),
- (10, 2, 4),
- (10, 2, 2),
- (10, 2, 3),
- (20, 5, 5),
- (30, 3, 4),
-]
-
-TEST_CASES = [
- pytest.param(
- automaton_type,
- seed_val,
- num_states,
- input_size,
- output_size,
- id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}",
- )
- for num_states, input_size, output_size in MODEL_SIZES
- for seed_val in SEEDS
- for automaton_type in ['dfa', 'moore', 'mealy']
-]
-
-
-@pytest.mark.parametrize("automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES)
-@pytest.mark.timeout(5)
-def test_hw_seed(automaton_type, seed_val, num_states, input_size, output_size):
- from random import seed
-
- seed(seed_val)
-
- model = generate_random_deterministic_automata(
- automaton_type,
- num_states=num_states,
- input_alphabet_size=input_size,
- output_alphabet_size=output_size,
- )
- if not model.is_minimal():
- pytest.skip(f"seed {seed_val} does not produce a minimal model")
- if not model.is_strongly_connected():
- pytest.skip(
- f"seed {seed_val} does not produce a strongly connected model "
- f"for states={num_states}, inputs={input_size}, outputs={output_size}"
- )
-
- sul = MealySUL(model)
- input_alphabet = model.get_input_alphabet()
-
-
- learned_model = run_hW(input_alphabet,
- sul,
- RandomhWOracle(num_testing_steps=1000 * num_states, reset_testing_counter=True),
- automaton_type=automaton_type,
- query_for_initial_state=True)
-
- assert learned_model.is_minimal()
- assert bisimilar(model, learned_model)
-
-
-STRATEGY_TEST_CASES = [
- pytest.param(
- oracle_name,
- seed_val,
- num_states,
- input_size,
- output_size,
- id=f"oracle={oracle_name}-states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}",
- )
- for num_states, input_size, output_size in [(3, 2, 2), (5, 3, 3), (10, 2, 3)]
- for seed_val in range(10)
- for oracle_name in ['random', 'wp']
-]
-
-
-@pytest.mark.parametrize("oracle_name,seed_val,num_states,input_size,output_size", STRATEGY_TEST_CASES)
-@pytest.mark.timeout(5)
-def test_hw_eq_oracle(oracle_name, seed_val, num_states, input_size, output_size):
- from random import seed
-
- seed(seed_val)
-
- model = generate_random_deterministic_automata(
- 'mealy',
- num_states=num_states,
- input_alphabet_size=input_size,
- output_alphabet_size=output_size,
- )
- if not model.is_minimal() or not model.is_strongly_connected():
- pytest.skip(f"seed {seed_val} does not produce a minimal, strongly connected model")
-
- sul = AutomatonSUL(model)
- input_alphabet = model.get_input_alphabet()
-
- if oracle_name == 'wp':
- eq_oracle = RandomWphWOracle(random_walk_length=4 * num_states,
- num_test_origin_states=4 * num_states)
- else:
- eq_oracle = RandomhWOracle(num_testing_steps=1000 * num_states)
-
- learned_model = run_hW(input_alphabet,
- sul,
- eq_oracle,
- automaton_type='mealy',
- query_for_initial_state=True,
- print_level=0)
-
- assert learned_model.is_minimal()
- assert bisimilar(model, learned_model)
-
-
-def test_hw_uses_user_provided_h_and_w():
- from aalpy import bisimilar, generate_random_deterministic_automata, run_hW, AutomatonSUL
- from random import seed
- seed(1)
-
- model = generate_random_deterministic_automata(
- 'mealy',
- num_states=100,
- input_alphabet_size=4,
- output_alphabet_size=4,
- )
-
- sul = AutomatonSUL(model)
- input_alphabet = model.get_input_alphabet()
-
- char_set = model.compute_characterization_set()
-
- assert model.is_minimal() and model.is_minimal()
-
- learned_model = run_hW(input_alphabet,
- sul,
- RandomhWOracle(num_testing_steps=2000),
- automaton_type='mealy',
- provided_characterization_set=char_set,
- query_for_initial_state=True)
-
- assert learned_model.is_minimal()
- assert bisimilar(model, learned_model)
-
diff --git a/tests/test_non_deterministic.py b/tests/test_non_deterministic.py
deleted file mode 100644
index 0b0cb0ac253..00000000000
--- a/tests/test_non_deterministic.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import unittest
-
-
-class NonDeterministicTest(unittest.TestCase):
-
- def test_non_det(self):
-
- from aalpy.SULs import AutomatonSUL
- from aalpy.oracles import RandomWordEqOracle, RandomWalkEqOracle
- from aalpy.learning_algs import run_non_det_Lstar
- from aalpy.utils import get_benchmark_ONFSM
-
- onfsm = get_benchmark_ONFSM()
- alphabet = onfsm.get_input_alphabet()
-
- for _ in range(100):
- sul = AutomatonSUL(onfsm)
-
- oracle = RandomWordEqOracle(alphabet, sul, num_walks=500, min_walk_len=2, max_walk_len=5)
-
- learned_onfsm = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=50, print_level=0)
-
- eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=10000, reset_prob=0.09,
- reset_after_cex=True)
-
- cex = eq_oracle.find_cex(learned_onfsm)
-
- if cex or len(learned_onfsm.states) != len(onfsm.states):
- assert False
- assert True
diff --git a/tests/test_rwpmethod_oracle.py b/tests/test_rwpmethod_oracle.py
deleted file mode 100644
index c99892f4789..00000000000
--- a/tests/test_rwpmethod_oracle.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import unittest
-
-try:
- from aalpy.automata import MooreMachine, MooreState
- from aalpy.learning_algs import run_Lstar
- from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle
- from aalpy.SULs import AutomatonSUL
- from aalpy.utils import visualize_automaton
-except ImportError:
- import sys
- from pathlib import Path
-
- # if you want to run the test directly from CLI
- # either from root or from tests folder
- p = Path(__file__).parent.resolve()
- sys.path.append(str(p))
- sys.path.append(str(p.parent))
- from aalpy.automata import MooreMachine, MooreState
- from aalpy.learning_algs import run_Lstar
- from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle
- from aalpy.SULs import AutomatonSUL
- from aalpy.utils import visualize_automaton
-
-
-class TestRandomWpMethodOracle(unittest.TestCase):
- @staticmethod
- def gen_moore_from_state_setup(state_setup) -> MooreMachine:
- # state_setup shoud map from state_id to tuple(output and transitions_dict)
-
- # build states with state_id and output
- states = {key: MooreState(key, val[0]) for key, val in state_setup.items()}
-
- # add transitions to states
- for state_id, state in states.items():
- for _input, target_state_id in state_setup[state_id][1].items():
- state.transitions[_input] = states[target_state_id]
-
- # states to list
- states = [state for state in states.values()]
-
- # build moore machine with first state as starting state
- mm = MooreMachine(states[0], states)
-
- for state in states:
- state.prefix = mm.get_shortest_path(mm.initial_state, state)
-
- return mm
-
- def generate_real_automata(self) -> MooreMachine:
- state_setup = {
- "a": ("a", {"x": "b1", "y": "a"}),
- "b1": ("b", {"x": "b2", "y": "a"}),
- "b2": ("b", {"x": "b3", "y": "a"}),
- "b3": ("b", {"x": "b4", "y": "a"}),
- "b4": ("b", {"x": "c", "y": "a"}),
- "c": ("c", {"x": "a", "y": "a"}),
- }
-
- mm = self.gen_moore_from_state_setup(state_setup)
- mm.characterization_set = mm.compute_characterization_set() + [tuple()]
- return mm
-
- def generate_hypothesis(self) -> MooreMachine:
- state_setup = {
- "a": ("a", {"x": "b", "y": "a"}),
- "b": ("b", {"x": "b", "y": "a"}),
- }
-
- mm = self.gen_moore_from_state_setup(state_setup)
- # ! computer_characterization_set does not work for Moore machines in general!
- # mm.characterization_set = mm.compute_characterization_set() + [tuple()]
- mm.characterization_set = [tuple(), ("x",), ("y",)]
- return mm
-
- def test_rwpmethod_oracle(self):
- real = self.generate_real_automata()
- hyp = self.generate_hypothesis()
- # visualize_automaton(real)
- # visualize_automaton(hyp)
- assert set(real.get_input_alphabet()) == {"x", "y"}
- assert set(hyp.get_input_alphabet()) == {"x", "y"}
- assert len(real.states) == 6
- assert len(hyp.states) == 2
- alphabet = real.get_input_alphabet()
- oracle = RandomWpMethodEqOracle(
- alphabet, AutomatonSUL(real)
- )
- cex = oracle.find_cex(hyp)
- assert cex is not None, "Expected a counterexample, but got None"
-
- def test_rwpmethod_oracle_with_lstar(self):
- real = self.generate_real_automata()
- hyp = self.generate_hypothesis()
- # visualize_automaton(real)
- # visualize_automaton(hyp)
- assert real.get_input_alphabet() == ["x", "y"]
- assert hyp.get_input_alphabet() == ["x", "y"]
- assert len(real.states) == 6
- assert len(hyp.states) == 2
- alphabet = real.get_input_alphabet()
- oracle = RandomWpMethodEqOracle(
- alphabet, AutomatonSUL(real)
- )
- lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore")
- # print(lstar_hyp)
- # visualize_automaton(lstar_hyp)
- assert (
- len(lstar_hyp.states) == 6
- ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis"
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_stochastic.py b/tests/test_stochastic.py
deleted file mode 100644
index 99b90b1b581..00000000000
--- a/tests/test_stochastic.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import unittest
-
-import aalpy.paths
-from aalpy.SULs import AutomatonSUL
-from aalpy.learning_algs import run_stochastic_Lstar
-from aalpy.oracles import RandomWalkEqOracle
-from aalpy.utils import load_automaton_from_file
-
-
-class StochasticTest(unittest.TestCase):
-
- def test_learning_based_on_accuracy_based_stopping(self):
-
- example = 'first_grid'
- mdp = load_automaton_from_file(f'../DotModels/MDPs/{example}.dot', automaton_type='mdp')
-
- min_rounds = 10
- max_rounds = 500
-
- from aalpy.automata import StochasticMealyMachine
- from aalpy.utils import model_check_experiment, get_properties_file, \
- get_correct_prop_values
- from aalpy.automata.StochasticMealyMachine import smm_to_mdp_conversion
-
- aalpy.paths.path_to_prism = "C:/Program Files/prism-4.6/bin/prism.bat"
- aalpy.paths.path_to_properties = "../Benchmarking/prism_eval_props/"
-
- stopping_based_on_prop = (get_properties_file(example), get_correct_prop_values(example), 0.02)
-
- input_alphabet = mdp.get_input_alphabet()
-
- automaton_type = ['mdp', 'smm']
- similarity_strategy = ['classic', 'normal', 'chi2']
- cex_processing = [None, 'longest_prefix']
- samples_cex_strategy = [None, 'bfs', 'random:200:0.3']
-
- for aut_type in automaton_type:
- for strategy in similarity_strategy:
- for cex in cex_processing:
- for sample_cex in samples_cex_strategy:
-
- sul = AutomatonSUL(mdp)
-
- eq_oracle = RandomWalkEqOracle(input_alphabet, sul=sul, num_steps=200,
- reset_prob=0.25,
- reset_after_cex=True)
-
- learned_model = run_stochastic_Lstar(input_alphabet=input_alphabet, eq_oracle=eq_oracle,
- sul=sul, n_c=20,
- n_resample=1000, min_rounds=min_rounds,
- max_rounds=max_rounds,
- automaton_type=aut_type, strategy=strategy,
- cex_processing=cex,
- samples_cex_strategy=sample_cex, target_unambiguity=0.99,
- property_based_stopping=stopping_based_on_prop,
- print_level=0)
-
- if isinstance(learned_model, StochasticMealyMachine):
- mdp = smm_to_mdp_conversion(learned_model)
- else:
- mdp = learned_model
-
- results, diff = model_check_experiment(get_properties_file(example),
- get_correct_prop_values(example), mdp)
-
- for d in diff.values():
- if d > stopping_based_on_prop[2]:
- assert False
-
- assert True
diff --git a/tests/test_wmethod_oracle.py b/tests/test_wmethod_oracle.py
deleted file mode 100644
index 97412265dda..00000000000
--- a/tests/test_wmethod_oracle.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import unittest
-
-try:
- from aalpy.automata import MooreMachine, MooreState
- from aalpy.learning_algs import run_Lstar
- from aalpy.oracles.WMethodEqOracle import WMethodEqOracle
- from aalpy.SULs import AutomatonSUL
- from aalpy.utils import visualize_automaton
-except ImportError:
- import sys
- from pathlib import Path
-
- # if you want to run the test directly from CLI
- # either from root or from tests folder
- p = Path(__file__).parent.resolve()
- sys.path.append(str(p))
- sys.path.append(str(p.parent))
- from aalpy.automata import MooreMachine, MooreState
- from aalpy.learning_algs import run_Lstar
- from aalpy.oracles.WMethodEqOracle import WMethodEqOracle
- from aalpy.SULs import AutomatonSUL
- from aalpy.utils import visualize_automaton
-
-
-class TestWMethodOracle(unittest.TestCase):
- @staticmethod
- def gen_moore_from_state_setup(state_setup) -> MooreMachine:
- # state_setup shoud map from state_id to tuple(output and transitions_dict)
-
- # build states with state_id and output
- states = {key: MooreState(key, val[0]) for key, val in state_setup.items()}
-
- # add transitions to states
- for state_id, state in states.items():
- for _input, target_state_id in state_setup[state_id][1].items():
- state.transitions[_input] = states[target_state_id]
-
- # states to list
- states = [state for state in states.values()]
-
- # build moore machine with first state as starting state
- mm = MooreMachine(states[0], states)
-
- for state in states:
- state.prefix = mm.get_shortest_path(mm.initial_state, state)
-
- return mm
-
- def generate_real_automata(self) -> MooreMachine:
- state_setup = {
- "a": ("a", {"x": "b1", "y": "a"}),
- "b1": ("b", {"x": "b2", "y": "a"}),
- "b2": ("b", {"x": "b3", "y": "a"}),
- "b3": ("b", {"x": "b4", "y": "a"}),
- "b4": ("b", {"x": "c", "y": "a"}),
- "c": ("c", {"x": "a", "y": "a"}),
- }
-
- mm = self.gen_moore_from_state_setup(state_setup)
- mm.characterization_set = mm.compute_characterization_set() + [tuple()]
- return mm
-
- def generate_hypothesis(self) -> MooreMachine:
- state_setup = {
- "a": ("a", {"x": "b", "y": "a"}),
- "b": ("b", {"x": "b", "y": "a"}),
- }
-
- mm = self.gen_moore_from_state_setup(state_setup)
- # ! computer_characterization_set does not work for Moore machines in general!
- # mm.characterization_set = mm.compute_characterization_set() + [tuple()]
- mm.characterization_set = [tuple(), ("x",), ("y",)]
- return mm
-
- def test_wmethod_oracle(self):
- real = self.generate_real_automata()
- hyp = self.generate_hypothesis()
- # visualize_automaton(real)
- # visualize_automaton(hyp)
- assert set(real.get_input_alphabet()) == {"x", "y"}
- assert set(hyp.get_input_alphabet()) == {"x", "y"}
- assert len(real.states) == 6
- assert len(hyp.states) == 2
- alphabet = real.get_input_alphabet()
- oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1)
- cex = oracle.find_cex(hyp)
- assert cex is not None, "Expected a counterexample, but got None"
-
- def test_wmethod_oracle_with_lstar(self):
- real = self.generate_real_automata()
- hyp = self.generate_hypothesis()
- # visualize_automaton(real)
- # visualize_automaton(hyp)
- assert real.get_input_alphabet() == ["x", "y"]
- assert hyp.get_input_alphabet() == ["x", "y"]
- assert len(real.states) == 6
- assert len(hyp.states) == 2
- alphabet = real.get_input_alphabet()
- oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1)
- lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore")
- # print(lstar_hyp)
- # visualize_automaton(lstar_hyp)
- assert (
- len(lstar_hyp.states) == 6
- ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis"
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_wpmethod_oracle.py b/tests/test_wpmethod_oracle.py
deleted file mode 100644
index d1498235914..00000000000
--- a/tests/test_wpmethod_oracle.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import unittest
-
-try:
- from aalpy.automata import MooreMachine, MooreState
- from aalpy.learning_algs import run_Lstar
- from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle
- from aalpy.SULs import AutomatonSUL
- from aalpy.utils import visualize_automaton
-except ImportError:
- import sys
- from pathlib import Path
-
- # if you want to run the test directly from CLI
- # either from root or from tests folder
- p = Path(__file__).parent.resolve()
- sys.path.append(str(p))
- sys.path.append(str(p.parent))
- from aalpy.automata import MooreMachine, MooreState
- from aalpy.learning_algs import run_Lstar
- from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle
- from aalpy.SULs import AutomatonSUL
- from aalpy.utils import visualize_automaton
-
-
-class TestWpMethodOracle(unittest.TestCase):
- @staticmethod
- def gen_moore_from_state_setup(state_setup) -> MooreMachine:
- # state_setup shoud map from state_id to tuple(output and transitions_dict)
-
- # build states with state_id and output
- states = {key: MooreState(key, val[0]) for key, val in state_setup.items()}
-
- # add transitions to states
- for state_id, state in states.items():
- for _input, target_state_id in state_setup[state_id][1].items():
- state.transitions[_input] = states[target_state_id]
-
- # states to list
- states = [state for state in states.values()]
-
- # build moore machine with first state as starting state
- mm = MooreMachine(states[0], states)
-
- for state in states:
- state.prefix = mm.get_shortest_path(mm.initial_state, state)
-
- return mm
-
- def generate_real_automata(self) -> MooreMachine:
- state_setup = {
- "a": ("a", {"x": "b1", "y": "a"}),
- "b1": ("b", {"x": "b2", "y": "a"}),
- "b2": ("b", {"x": "b3", "y": "a"}),
- "b3": ("b", {"x": "b4", "y": "a"}),
- "b4": ("b", {"x": "c", "y": "a"}),
- "c": ("c", {"x": "a", "y": "a"}),
- }
-
- mm = self.gen_moore_from_state_setup(state_setup)
- mm.characterization_set = mm.compute_characterization_set() + [tuple()]
- return mm
-
- def generate_hypothesis(self) -> MooreMachine:
- state_setup = {
- "a": ("a", {"x": "b", "y": "a"}),
- "b": ("b", {"x": "b", "y": "a"}),
- }
-
- mm = self.gen_moore_from_state_setup(state_setup)
- # ! computer_characterization_set does not work for Moore machines in general!
- # mm.characterization_set = mm.compute_characterization_set() + [tuple()]
- mm.characterization_set = [tuple(), ("x",), ("y",)]
- return mm
-
- def test_wpmethod_oracle(self):
- real = self.generate_real_automata()
- hyp = self.generate_hypothesis()
- # visualize_automaton(real)
- # visualize_automaton(hyp)
- assert set(real.get_input_alphabet()) == {"x", "y"}
- assert set(hyp.get_input_alphabet()) == {"x", "y"}
- assert len(real.states) == 6
- assert len(hyp.states) == 2
- alphabet = real.get_input_alphabet()
- oracle = WpMethodEqOracle(
- alphabet, AutomatonSUL(real), len(real.states) + 1
- )
- cex = oracle.find_cex(hyp)
- assert cex is not None, "Expected a counterexample, but got None"
-
- def test_wpmethod_oracle_with_lstar(self):
- real = self.generate_real_automata()
- hyp = self.generate_hypothesis()
- # visualize_automaton(real)
- # visualize_automaton(hyp)
- assert real.get_input_alphabet() == ["x", "y"]
- assert hyp.get_input_alphabet() == ["x", "y"]
- assert len(real.states) == 6
- assert len(hyp.states) == 2
- alphabet = real.get_input_alphabet()
- oracle = WpMethodEqOracle(
- alphabet, AutomatonSUL(real), len(real.states) + 1
- )
- lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore")
- # print(lstar_hyp)
- # visualize_automaton(lstar_hyp)
- assert (
- len(lstar_hyp.states) == 6
- ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis"
-
-
-if __name__ == "__main__":
- unittest.main()
From 4ffadf4c45b1c5f3fdbcddbd524f450cce2c782e Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:26:56 +0200
Subject: [PATCH 17/25] update version
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 94a322ff04f..cc2d84b6583 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aalpy"
-version = "1.6.2"
+version = "2.0.0"
description = "An active automata learning library"
readme = "README.md"
requires-python = ">=3.10"
From 2bf7ab90c6f6f9c645c5ed52bcfb19d80f46429b Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:28:21 +0200
Subject: [PATCH 18/25] Update python version in CI/CD
---
.github/workflows/python-app.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 6e100961e6e..e46294793c1 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -16,10 +16,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- - name: Set up Python 3.9
+ - name: Set up Python 3.10
uses: actions/setup-python@v2
with:
- python-version: 3.9
+ python-version: 3.10
- name: Install dependencies
run: |
python -m pip install --upgrade pip
From 6894de5035d5f9801fb818a6eb3d31058ab0bac3 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:28:29 +0200
Subject: [PATCH 19/25] Update typing
---
aalpy/automata/MooreMachine.py | 5 +-
.../stochastic_passive/ActiveAleriga.py | 7 +--
aalpy/utils/BenchmarkSULs.py | 47 ++++++-------------
3 files changed, 20 insertions(+), 39 deletions(-)
diff --git a/aalpy/automata/MooreMachine.py b/aalpy/automata/MooreMachine.py
index beb086045a2..bf9f10a3d59 100644
--- a/aalpy/automata/MooreMachine.py
+++ b/aalpy/automata/MooreMachine.py
@@ -2,6 +2,7 @@
from collections.abc import Hashable
from typing import Generic
+from aalpy.automata.Dfa import Dfa, DfaState
from aalpy.base import AutomatonState, DeterministicAutomaton
from aalpy.base.Automaton import InputType, OutputType
@@ -150,15 +151,13 @@ def from_state_setup(state_setup: dict, **kwargs) -> 'MooreMachine':
return mm
@staticmethod
- def to_dfa(moore_machine: 'MooreMachine') -> 'Dfa':
+ def to_dfa(moore_machine: 'MooreMachine') -> Dfa:
"""
Converts a Moore machine with boolean state outputs to a DFA.
:param MooreMachine moore_machine: Moore machine to convert. All states must have boolean outputs.
:return Dfa: The equivalent DFA.
"""
- from aalpy.automata.Dfa import Dfa, DfaState
-
if not all(isinstance(state.output, bool) for state in moore_machine.states):
raise ValueError('Only Moore machines with boolean state outputs can be cast to a Dfa.')
diff --git a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
index acf4d14d7eb..07e418903cc 100644
--- a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
+++ b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
@@ -3,6 +3,7 @@
from abc import ABC, abstractmethod
from random import randint, choice
+from aalpy.automata import Mdp
from aalpy.base import SUL
from aalpy.learning_algs import run_Alergia
from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import CompatibilityChecker
@@ -14,7 +15,7 @@ class Sampler(ABC):
"""
@abstractmethod
- def sample(self, sul: SUL, model: 'Mdp') -> list:
+ def sample(self, sul: SUL, model: Mdp) -> list:
"""
Abstract method implementing sampling strategy.
@@ -42,7 +43,7 @@ def __init__(self, num_walks: int, min_walk_len: int, max_walk_len: int) -> None
self.min_walk_len = min_walk_len
self.max_walk_len = max_walk_len
- def sample(self, sul: SUL, model: 'Mdp') -> list:
+ def sample(self, sul: SUL, model: Mdp) -> list:
"""
Samples num_walks random walks of random length over the current hypothesis' input alphabet.
@@ -73,7 +74,7 @@ def sample(self, sul: SUL, model: 'Mdp') -> list:
def run_active_Alergia(data: list, sul: SUL, sampler: Sampler, n_iter: int, eps: float | str = 0.05,
compatibility_checker: CompatibilityChecker | None = None, automaton_type: str = 'mdp',
- print_info: bool = True) -> 'Mdp':
+ print_info: bool = True) -> Mdp:
"""
Active version of IOAlergia algorithm. Based on intermediate hypothesis sampling on the system is performed.
Sampled data is added to the learning data and more accurate model is learned.
diff --git a/aalpy/utils/BenchmarkSULs.py b/aalpy/utils/BenchmarkSULs.py
index 393b5bc318b..acbca6e1b62 100644
--- a/aalpy/utils/BenchmarkSULs.py
+++ b/aalpy/utils/BenchmarkSULs.py
@@ -1,13 +1,14 @@
# Hand-crafted example automata and systems under learning used throughout benchmarks, examples and tests.
from typing import Any
+from aalpy.automata import Dfa, Onfsm, OnfsmState, Mdp, MdpState, StochasticMealyMachine, StochasticMealyState
+from aalpy.base import SUL
-def get_Angluin_dfa() -> 'Dfa':
+
+def get_Angluin_dfa() -> Dfa:
"""
:return Dfa: The classical DFA example used by Angluin to illustrate L*.
"""
- from aalpy.automata import Dfa
-
angluin_dfa = {
'q0': (True, {'a': 'q1', 'b': 'q2'}),
'q1': (False, {'a': 'q0', 'b': 'q3'}),
@@ -18,15 +19,13 @@ def get_Angluin_dfa() -> 'Dfa':
return Dfa.from_state_setup(angluin_dfa)
-def get_benchmark_ONFSM() -> 'Onfsm':
+def get_benchmark_ONFSM() -> Onfsm:
"""
Returns ONFSM presented in 'Learning Finite State Models of Observable Nondeterministic Systems in a Testing
Context'.
:return Onfsm: The example ONFSM.
"""
- from aalpy.automata import Onfsm, OnfsmState
-
a = OnfsmState('q0')
b = OnfsmState('q1')
c = OnfsmState('g2')
@@ -49,14 +48,12 @@ def get_benchmark_ONFSM() -> 'Onfsm':
return Onfsm(a, [a, b, c, d])
-def get_ONFSM() -> 'Onfsm':
+def get_ONFSM() -> Onfsm:
"""
Returns example of an ONFSM.
:return Onfsm: The example ONFSM.
"""
- from aalpy.automata import Onfsm, OnfsmState
-
q0 = OnfsmState('q0')
q1 = OnfsmState('q1')
q2 = OnfsmState('q2')
@@ -97,12 +94,10 @@ def get_ONFSM() -> 'Onfsm':
return Onfsm(q0, [q0, q1, q2, q3, q4, q5, q6, q7, q8])
-def get_faulty_coffee_machine_MDP() -> 'Mdp':
+def get_faulty_coffee_machine_MDP() -> Mdp:
"""
:return Mdp: An MDP modeling a coffee machine that occasionally serves coffee without the beep step.
"""
- from aalpy.automata import Mdp, MdpState
-
q0 = MdpState("q0", "init")
q1 = MdpState("q1", "beep")
q2 = MdpState("q2", "coffee")
@@ -120,12 +115,10 @@ def get_faulty_coffee_machine_MDP() -> 'Mdp':
return mdp
-def get_weird_coffee_machine_MDP() -> 'Mdp':
+def get_weird_coffee_machine_MDP() -> Mdp:
"""
:return Mdp: An MDP modeling a coffee machine with an alternate 'koin' input path that can crash the machine.
"""
- from aalpy.automata import Mdp, MdpState
-
q0 = MdpState("q0", "init")
q1 = MdpState("q1", "beep")
q2 = MdpState("q2", "coffee")
@@ -171,13 +164,11 @@ def get_weird_coffee_machine_MDP() -> 'Mdp':
return mdp
-def get_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine':
+def get_faulty_coffee_machine_SMM() -> StochasticMealyMachine:
"""
:return StochasticMealyMachine: A stochastic Mealy machine modeling a coffee machine that occasionally serves
coffee without the beep step.
"""
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
s2 = StochasticMealyState('q2')
@@ -195,12 +186,10 @@ def get_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine':
return smm
-def get_minimal_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine':
+def get_minimal_faulty_coffee_machine_SMM() -> StochasticMealyMachine:
"""
:return StochasticMealyMachine: A minimal 2-state stochastic Mealy machine modeling the faulty coffee machine.
"""
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
@@ -215,12 +204,10 @@ def get_minimal_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine':
return smm
-def get_faulty_mqtt_SMM() -> 'StochasticMealyMachine':
+def get_faulty_mqtt_SMM() -> StochasticMealyMachine:
"""
:return StochasticMealyMachine: A stochastic Mealy machine modeling a faulty MQTT broker.
"""
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
s2 = StochasticMealyState('q2')
@@ -250,13 +237,11 @@ def get_faulty_mqtt_SMM() -> 'StochasticMealyMachine':
return smm
-def get_small_gridworld() -> 'StochasticMealyMachine':
+def get_small_gridworld() -> StochasticMealyMachine:
"""
:return StochasticMealyMachine: A stochastic Mealy machine modeling a small 2x2 gridworld with mud and grass
tiles.
"""
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
s2 = StochasticMealyState('q2')
@@ -429,12 +414,10 @@ def is_date_accepted(self, date_string: str) -> bool:
return True
-def get_small_pomdp() -> 'Mdp':
+def get_small_pomdp() -> Mdp:
"""
:return Mdp: An MDP with partially observable states (a small POMDP-like example).
"""
- from aalpy.automata import Mdp, MdpState
-
q0 = MdpState("q0", "init")
q1 = MdpState("q1", "beep")
q2 = MdpState("q2", "beep")
@@ -490,7 +473,7 @@ def is_balanced(test_string: str, call_return_map: dict, allow_empty_string: boo
return not stack if allow_empty_string else not stack and len(test_string) > 0
-def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) -> 'SUL':
+def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) -> SUL:
"""
Creates a SUL that accepts balanced strings of call/return symbols.
@@ -498,8 +481,6 @@ def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) ->
:param bool allow_empty_string: Whether an empty string counts as balanced.
:return SUL: The constructed SUL.
"""
- from aalpy.base import SUL
-
class BalancedStringSUL(SUL):
"""
System under learning that checks whether the sequence of inputs seen so far is a balanced string.
From c4eec084b8a68e64f2e458ff756948166d9926b3 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:30:43 +0200
Subject: [PATCH 20/25] Update python-app.yml
---
.github/workflows/python-app.yml | 35 ++++++++++++++------------------
1 file changed, 15 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index e46294793c1..21da6138aee 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -1,6 +1,3 @@
-# This workflow will install Python dependencies, run tests and lint with a single version of Python
-# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
-
name: Python application
on:
@@ -11,24 +8,22 @@ on:
jobs:
build:
-
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Set up Python 3.10
- uses: actions/setup-python@v2
- with:
- python-version: 3.10
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install flake8 pytest
- if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- - name: Lint with flake8
- run: |
- # stop the build if there are Python syntax errors or undefined names
- flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
- flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install flake8 pytest
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+ - name: Lint with flake8
+ run: |
+ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
\ No newline at end of file
From 58cba98fb5b40ba0e3e2c5a7fab7b67f2c70c12c Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:32:26 +0200
Subject: [PATCH 21/25] Update python-app.yml
---
.github/workflows/python-app.yml | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 21da6138aee..a3227255afd 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -26,4 +26,7 @@ jobs:
- name: Lint with flake8
run: |
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
\ No newline at end of file
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+ - name: Run tests
+ run: pytest -q
\ No newline at end of file
From c175c0b313b8f24cbbe56728550b9d000a323240 Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:32:26 +0200
Subject: [PATCH 22/25] Update python-app.yml
---
.github/workflows/python-app.yml | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 21da6138aee..a3227255afd 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -26,4 +26,7 @@ jobs:
- name: Lint with flake8
run: |
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
\ No newline at end of file
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+ - name: Run tests
+ run: pytest -q
\ No newline at end of file
From e6cedb96e30a94f4bd8f1b86b41955f028a549ba Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:34:41 +0200
Subject: [PATCH 23/25] Update python-app.yml
---
.github/workflows/python-app.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index a3227255afd..d51aa775c42 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -20,7 +20,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install flake8 pytest
+ pip install flake8 pytest pydot
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
From 791a63e2800a6d8a282cb3010550e15b0818df6e Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:36:51 +0200
Subject: [PATCH 24/25] Update python-app.yml
---
.github/workflows/python-app.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index d51aa775c42..b9c3387e0f5 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -20,7 +20,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install flake8 pytest pydot
+ pip install flake8 pytest pytest-timeout pydot
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
From a6d35773cbe1acb790d65465f4649eb85536632f Mon Sep 17 00:00:00 2001
From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:43:31 +0200
Subject: [PATCH 25/25] Update failing patch in test_user_input_eq_oracle.py
---
tests/oracles/test_user_input_eq_oracle.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tests/oracles/test_user_input_eq_oracle.py b/tests/oracles/test_user_input_eq_oracle.py
index 4185fe23856..60ed37a2e98 100644
--- a/tests/oracles/test_user_input_eq_oracle.py
+++ b/tests/oracles/test_user_input_eq_oracle.py
@@ -1,8 +1,9 @@
+import importlib
import unittest
from unittest.mock import patch
from aalpy.automata import MealyMachine, MealyState
-from aalpy.oracles import UserInputEqOracle
+from aalpy.oracles.UserInputEqOracle import UserInputEqOracle
from aalpy.SULs import AutomatonSUL
@@ -22,7 +23,8 @@ def sample_mealy():
class UserInputEqOracleTests(unittest.TestCase):
def setUp(self):
- self.visualize_patcher = patch('aalpy.oracles.UserInputEqOracle.visualize_automaton')
+ module = importlib.import_module("aalpy.oracles.UserInputEqOracle")
+ self.visualize_patcher = patch.object(module, "visualize_automaton")
self.visualize_patcher.start()
def tearDown(self):