:').
+ """
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..07e418903cc 100644
--- a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
+++ b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py
@@ -1,7 +1,12 @@
+# 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.automata import Mdp
+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 +15,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 = []
@@ -41,9 +58,12 @@ def sample(self, sul, model):
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]))
@@ -52,37 +72,31 @@ 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):
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)
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..5a631579f06 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 = []
@@ -68,9 +76,10 @@ def find_cex(self, hypothesis):
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:
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..ee66f04a9cc 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)]
@@ -503,7 +481,7 @@ def generate_random_sevpa(num_states, internal_alphabet_size, call_alphabet_size
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))
diff --git a/aalpy/utils/BenchmarkSULs.py b/aalpy/utils/BenchmarkSULs.py
index 6a812b8784a..acbca6e1b62 100644
--- a/aalpy/utils/BenchmarkSULs.py
+++ b/aalpy/utils/BenchmarkSULs.py
@@ -1,6 +1,14 @@
-def get_Angluin_dfa():
- from aalpy.automata import Dfa
+# 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:
+ """
+ :return Dfa: The classical DFA example used by Angluin to illustrate L*.
+ """
angluin_dfa = {
'q0': (True, {'a': 'q1', 'b': 'q2'}),
'q1': (False, {'a': 'q0', 'b': 'q3'}),
@@ -11,13 +19,13 @@ 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'.
- """
- from aalpy.automata import Onfsm, OnfsmState
+ :return Onfsm: The example ONFSM.
+ """
a = OnfsmState('q0')
b = OnfsmState('q1')
c = OnfsmState('g2')
@@ -40,12 +48,12 @@ def get_benchmark_ONFSM():
return Onfsm(a, [a, b, c, d])
-def get_ONFSM():
+def get_ONFSM() -> Onfsm:
"""
Returns example of an ONFSM.
- """
- from aalpy.automata import Onfsm, OnfsmState
+ :return Onfsm: The example ONFSM.
+ """
q0 = OnfsmState('q0')
q1 = OnfsmState('q1')
q2 = OnfsmState('q2')
@@ -86,9 +94,10 @@ def get_ONFSM():
return Onfsm(q0, [q0, q1, q2, q3, q4, q5, q6, q7, q8])
-def get_faulty_coffee_machine_MDP():
- from aalpy.automata import Mdp, MdpState
-
+def get_faulty_coffee_machine_MDP() -> Mdp:
+ """
+ :return Mdp: An MDP modeling a coffee machine that occasionally serves coffee without the beep step.
+ """
q0 = MdpState("q0", "init")
q1 = MdpState("q1", "beep")
q2 = MdpState("q2", "coffee")
@@ -106,9 +115,10 @@ def get_faulty_coffee_machine_MDP():
return mdp
-def get_weird_coffee_machine_MDP():
- from aalpy.automata import Mdp, MdpState
-
+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.
+ """
q0 = MdpState("q0", "init")
q1 = MdpState("q1", "beep")
q2 = MdpState("q2", "coffee")
@@ -154,9 +164,11 @@ def get_weird_coffee_machine_MDP():
return mdp
-def get_faulty_coffee_machine_SMM():
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
+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.
+ """
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
s2 = StochasticMealyState('q2')
@@ -174,9 +186,10 @@ def get_faulty_coffee_machine_SMM():
return smm
-def get_minimal_faulty_coffee_machine_SMM():
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
+def get_minimal_faulty_coffee_machine_SMM() -> StochasticMealyMachine:
+ """
+ :return StochasticMealyMachine: A minimal 2-state stochastic Mealy machine modeling the faulty coffee machine.
+ """
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
@@ -191,9 +204,10 @@ def get_minimal_faulty_coffee_machine_SMM():
return smm
-def get_faulty_mqtt_SMM():
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
+def get_faulty_mqtt_SMM() -> StochasticMealyMachine:
+ """
+ :return StochasticMealyMachine: A stochastic Mealy machine modeling a faulty MQTT broker.
+ """
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
s2 = StochasticMealyState('q2')
@@ -223,9 +237,11 @@ def get_faulty_mqtt_SMM():
return smm
-def get_small_gridworld():
- from aalpy.automata import StochasticMealyMachine, StochasticMealyState
-
+def get_small_gridworld() -> StochasticMealyMachine:
+ """
+ :return StochasticMealyMachine: A stochastic Mealy machine modeling a small 2x2 gridworld with mud and grass
+ tiles.
+ """
s0 = StochasticMealyState('q0')
s1 = StochasticMealyState('q1')
s2 = StochasticMealyState('q2')
@@ -274,12 +290,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 +317,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 +334,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 +347,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 +382,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,9 +414,10 @@ def is_date_accepted(self, date_string: str):
return True
-def get_small_pomdp():
- from aalpy.automata import Mdp, MdpState
-
+def get_small_pomdp() -> Mdp:
+ """
+ :return Mdp: An MDP with partially observable states (a small POMDP-like example).
+ """
q0 = MdpState("q0", "init")
q1 = MdpState("q1", "beep")
q2 = MdpState("q2", "beep")
@@ -387,7 +444,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 +473,50 @@ 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):
- from aalpy.base import 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.
+ :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.
+ """
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..4196ade91f5 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,41 +155,41 @@ 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(suffix) == 0:
+ return True
if len(trace) < len(suffix):
return False
else:
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 +201,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 +210,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 +258,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 +292,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 +324,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 +343,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 +376,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 +427,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 +465,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 +490,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..040c5539907 100644
--- a/aalpy/utils/Sampling.py
+++ b/aalpy/utils/Sampling.py
@@ -1,16 +1,29 @@
+# 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)
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
@@ -19,6 +32,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 +54,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 +71,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 +114,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 +147,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..cc2d84b6583 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,10 +4,10 @@ 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.6"
+requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "Edi Muskardin" }
@@ -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"
@@ -30,3 +36,16 @@ 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
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/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/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/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/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/test_deterministic_learning_runs.py b/tests/learning_algs/deterministic/test_random_learning_runs.py
similarity index 54%
rename from tests/test_deterministic_learning_runs.py
rename to tests/learning_algs/deterministic/test_random_learning_runs.py
index 2c6364f2211..fcb1c333601 100644
--- a/tests/test_deterministic_learning_runs.py
+++ b/tests/learning_algs/deterministic/test_random_learning_runs.py
@@ -1,27 +1,22 @@
+import random
+
import pytest
-from aalpy import generate_random_deterministic_automata, bisimilar, AutomatonSUL, run_Lstar, run_KV, \
- RandomWMethodEqOracle
-from aalpy.learning_algs import run_Lsharp
+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
-SEEDS = list(range(50))
+# 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),
- (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 = [
@@ -32,7 +27,7 @@
num_states,
input_size,
output_size,
- id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}",
+ 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
@@ -42,11 +37,9 @@
@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)
+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,
@@ -60,11 +53,7 @@ def test_learning_algs(learning_alg: callable, automaton_type, seed_val, num_sta
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)
+ 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/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/test_hW.py b/tests/learning_algs/resetless/test_hW.py
similarity index 70%
rename from tests/test_hW.py
rename to tests/learning_algs/resetless/test_hW.py
index 2baad740a0a..02ec45b9e04 100644
--- a/tests/test_hW.py
+++ b/tests/learning_algs/resetless/test_hW.py
@@ -1,27 +1,18 @@
import pytest
-from aalpy import generate_random_deterministic_automata, bisimilar, run_hW, RandomhWOracle, RandomWphWOracle, \
- AutomatonSUL
+from aalpy import AutomatonSUL, RandomhWOracle, RandomWphWOracle, bisimilar, generate_random_deterministic_automata, \
+ run_hW
from aalpy.SULs import MealySUL
-SEEDS = list(range(50))
+# 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),
- (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 = [
@@ -63,12 +54,12 @@ def test_hw_seed(automaton_type, seed_val, num_states, input_size, 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)
+ 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)
@@ -83,8 +74,8 @@ def test_hw_seed(automaton_type, seed_val, num_states, input_size, output_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 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']
]
@@ -110,29 +101,28 @@ def test_hw_eq_oracle(oracle_name, seed_val, num_states, input_size, output_size
if oracle_name == 'wp':
eq_oracle = RandomWphWOracle(random_walk_length=4 * num_states,
- num_test_origin_states=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)
+ 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,
+ num_states=20,
input_alphabet_size=4,
output_alphabet_size=4,
)
@@ -142,15 +132,15 @@ def test_hw_uses_user_provided_h_and_w():
char_set = model.compute_characterization_set()
- assert model.is_minimal() and model.is_minimal()
+ 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)
+ 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()
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_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_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/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/test_rwpmethod_oracle.py b/tests/oracles/test_rwpmethod_oracle.py
similarity index 99%
rename from tests/test_rwpmethod_oracle.py
rename to tests/oracles/test_rwpmethod_oracle.py
index c99892f4789..40c2c1077d6 100644
--- a/tests/test_rwpmethod_oracle.py
+++ b/tests/oracles/test_rwpmethod_oracle.py
@@ -15,6 +15,7 @@
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
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..60ed37a2e98
--- /dev/null
+++ b/tests/oracles/test_user_input_eq_oracle.py
@@ -0,0 +1,83 @@
+import importlib
+import unittest
+from unittest.mock import patch
+
+from aalpy.automata import MealyMachine, MealyState
+from aalpy.oracles.UserInputEqOracle 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):
+ module = importlib.import_module("aalpy.oracles.UserInputEqOracle")
+ self.visualize_patcher = patch.object(module, "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/test_wmethod_oracle.py b/tests/oracles/test_wmethod_oracle.py
similarity index 99%
rename from tests/test_wmethod_oracle.py
rename to tests/oracles/test_wmethod_oracle.py
index 97412265dda..30e9030824e 100644
--- a/tests/test_wmethod_oracle.py
+++ b/tests/oracles/test_wmethod_oracle.py
@@ -15,6 +15,7 @@
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
diff --git a/tests/test_wpmethod_oracle.py b/tests/oracles/test_wpmethod_oracle.py
similarity index 99%
rename from tests/test_wpmethod_oracle.py
rename to tests/oracles/test_wpmethod_oracle.py
index d1498235914..8cbd89625a4 100644
--- a/tests/test_wpmethod_oracle.py
+++ b/tests/oracles/test_wpmethod_oracle.py
@@ -15,6 +15,7 @@
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
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_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_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_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/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}")
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/test_charSet.py b/tests/utils/test_char_set.py
similarity index 82%
rename from tests/test_charSet.py
rename to tests/utils/test_char_set.py
index 25d4731f0c5..91faa8db563 100644
--- a/tests/test_charSet.py
+++ b/tests/utils/test_char_set.py
@@ -1,20 +1,25 @@
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('../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',
+ "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('../DotModels/TLS/OpenSSL_1.0.2_server_regular.dot',
+ "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('../DotModels/TCP/TCP_Linux_Client.dot',
+ "tcp_server": load_automaton_from_file(DOT_MODELS_DIR / 'TCP/TCP_Linux_Client.dot',
automaton_type='mealy')}
def test_can_differentiate(self):
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()