From 23ad2453ad8ee572ccdfb07922b8fae1aece4627 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:29:03 +0200 Subject: [PATCH 01/25] Update types, docstrings, and bump minimal python version to 3.10 --- aalpy/SULs/AutomataSUL.py | 32 +- aalpy/SULs/PyMethodSUL.py | 48 +-- aalpy/SULs/RegexSUL.py | 34 +- aalpy/SULs/TomitaSUL.py | 74 +++- aalpy/automata/Dfa.py | 99 +++-- aalpy/automata/MarkovChain.py | 69 ++-- aalpy/automata/Mdp.py | 71 ++-- aalpy/automata/MealyMachine.py | 68 ++-- aalpy/automata/MooreMachine.py | 131 ++++-- .../automata/NonDeterministicMooreMachine.py | 59 ++- aalpy/automata/Onfsm.py | 110 ++--- aalpy/automata/Sevpa.py | 185 ++++++--- aalpy/automata/StochasticMealyMachine.py | 94 +++-- aalpy/automata/Vpa.py | 155 ++++--- aalpy/base/Automaton.py | 272 +++++++------ aalpy/base/CacheTree.py | 111 ++--- aalpy/base/Oracle.py | 30 +- aalpy/base/SUL.py | 100 +++-- .../learning_algs/adaptive/AdaptiveLSharp.py | 85 ++-- .../adaptive/AdaptiveObservationTree.py | 150 +++++-- aalpy/learning_algs/adaptive/StateMatching.py | 258 +++++++++--- aalpy/learning_algs/adaptive/__init__.py | 1 + aalpy/learning_algs/deterministic/ADS.py | 161 ++++++-- .../learning_algs/deterministic/Apartness.py | 82 +++- .../deterministic/ClassificationTree.py | 146 ++++--- .../deterministic/CounterExampleProcessing.py | 96 +++-- aalpy/learning_algs/deterministic/KV.py | 52 +-- aalpy/learning_algs/deterministic/LSharp.py | 66 ++- aalpy/learning_algs/deterministic/LStar.py | 78 ++-- .../deterministic/ObservationTable.py | 82 ++-- .../deterministic/ObservationTree.py | 380 ++++++++++++++---- .../deterministic_passive/ClassicRPNI.py | 55 ++- .../deterministic_passive/GsmRPNI.py | 40 +- .../deterministic_passive/PAPNI.py | 24 +- .../deterministic_passive/RPNI.py | 33 +- .../deterministic_passive/active_RPNI.py | 53 ++- .../rpni_helper_functions.py | 111 ++++- .../GeneralizedStateMerging.py | 192 ++++++--- .../general_passive/GsmAlgorithms.py | 107 ++--- .../learning_algs/general_passive/GsmNode.py | 316 ++++++++++++--- .../general_passive/Instrumentation.py | 88 +++- .../general_passive/ScoreFunctionsGSM.py | 240 +++++++++-- .../learning_algs/general_passive/__init__.py | 1 + .../non_deterministic/AbstractedOnfsmLstar.py | 46 +-- .../AbstractedOnfsmObservationTable.py | 180 ++++----- .../NonDeterministicSULWrapper.py | 28 +- .../non_deterministic/OnfsmLstar.py | 61 +-- .../OnfsmObservationTable.py | 67 ++- .../non_deterministic/TraceTree.py | 137 ++++--- .../non_deterministic/__init__.py | 1 + aalpy/learning_algs/resetless/__init__.py | 1 + aalpy/learning_algs/resetless/hW.py | 338 +++++++++++----- .../resetless/hW_datastructures.py | 36 +- .../resetless/resetless_oracles.py | 72 +++- .../stochastic/DifferenceChecker.py | 142 ++++++- .../SamplingBasedObservationTable.py | 232 +++++------ .../stochastic/StochasticCexProcessing.py | 33 +- .../stochastic/StochasticLStar.py | 89 ++-- .../stochastic/StochasticTeacher.py | 217 ++++++---- aalpy/learning_algs/stochastic/__init__.py | 1 + .../stochastic_passive/ActiveAleriga.py | 74 ++-- .../stochastic_passive/Alergia.py | 146 ++++--- .../CompatibilityChecker.py | 45 ++- .../learning_algs/stochastic_passive/FPTA.py | 84 +++- .../stochastic_passive/__init__.py | 1 + .../BreadthFirstExplorationEqOracle.py | 21 +- aalpy/oracles/CacheBasedEqOracle.py | 49 +-- aalpy/oracles/KWayStateCoverageEqOracle.py | 40 +- .../oracles/KWayTransitionCoverageEqOracle.py | 98 +++-- aalpy/oracles/PacOracle.py | 22 +- aalpy/oracles/PerfectKnowledgeEqOracle.py | 18 +- .../oracles/ProvidedSequencesOracleWrapper.py | 33 +- aalpy/oracles/RandomWalkEqOracle.py | 39 +- aalpy/oracles/RandomWordEqOracle.py | 42 +- aalpy/oracles/StatePrefixEqOracle.py | 34 +- aalpy/oracles/TransitionFocusOracle.py | 26 +- aalpy/oracles/UserInputEqOracle.py | 18 +- aalpy/oracles/WMethodEqOracle.py | 56 +-- aalpy/oracles/WpMethodEqOracle.py | 84 ++-- aalpy/oracles/__init__.py | 1 + aalpy/utils/AutomatonGenerators.py | 204 +++++----- aalpy/utils/BenchmarkSULs.py | 157 ++++++-- aalpy/utils/BenchmarkSevpaModels.py | 100 ++++- aalpy/utils/BenchmarkVpaModels.py | 154 ++++++- aalpy/utils/DataHandler.py | 44 +- aalpy/utils/FileHandler.py | 132 +++--- aalpy/utils/HelperFunctions.py | 196 +++++---- aalpy/utils/ModelChecking.py | 208 ++++++---- aalpy/utils/Sampling.py | 88 +++- aalpy/utils/__init__.py | 1 + pyproject.toml | 6 +- 91 files changed, 5706 insertions(+), 2835 deletions(-) diff --git a/aalpy/SULs/AutomataSUL.py b/aalpy/SULs/AutomataSUL.py index 0337b158400..defe43b46df 100644 --- a/aalpy/SULs/AutomataSUL.py +++ b/aalpy/SULs/AutomataSUL.py @@ -1,19 +1,43 @@ +# SUL wrapping an in-memory automaton, used to expose learned/reference automata as systems under learning. +from typing import Any + from aalpy.base import Automaton from aalpy.base import SUL class AutomatonSUL(SUL): - def __init__(self, automaton: Automaton): + """ + System under learning that wraps an in-memory automaton, delegating steps to it. + """ + + def __init__(self, automaton: Automaton) -> None: + """ + Creates a SUL wrapping an automaton. + + :param Automaton automaton: The automaton to wrap. + """ super().__init__() self.automaton: Automaton = automaton - def pre(self): + def pre(self) -> None: + """ + Resets the wrapped automaton to its initial state. + """ self.automaton.reset_to_initial() - def step(self, letter=None): + def step(self, letter: Any = None) -> Any: + """ + Executes a single input on the wrapped automaton. + + :param Any letter: Single input that is executed on the wrapped automaton. + :return Any: Output received after executing the input. + """ return self.automaton.step(letter) - def post(self): + def post(self) -> None: + """ + Performs no cleanup, as the wrapped automaton requires none between queries. + """ pass diff --git a/aalpy/SULs/PyMethodSUL.py b/aalpy/SULs/PyMethodSUL.py index d45d7b4a090..469c37df4ba 100644 --- a/aalpy/SULs/PyMethodSUL.py +++ b/aalpy/SULs/PyMethodSUL.py @@ -1,3 +1,6 @@ +# SUL for learning the behavior of an arbitrary Python class through its methods. +from typing import Any + from aalpy.base import SUL @@ -6,14 +9,13 @@ class FunctionDecorator: Decorator of methods found in the SUL class. """ - def __init__(self, function, args=None): + def __init__(self, function: Any, args: Any = None) -> None: """ - Args: - - function: function of the class to be learned + Creates a function decorator. - args: arguments to be passed to the function. Either a single argument, or a list of arguments if - function has more than one parameter. + :param Any function: Function of the class to be learned. + :param Any args: Arguments to be passed to the function. Either a single argument, or a list of arguments + if the function has more than one parameter. """ self.function = function @@ -21,7 +23,10 @@ def __init__(self, function, args=None): if args: self.args = [args] if not isinstance(args, (list, tuple)) else args - def __repr__(self): + def __repr__(self) -> str: + """ + :return str: A string representation of the function call. + """ if self.args: return f'{self.function.__name__}{self.args}' return self.function.__name__ @@ -31,37 +36,34 @@ class PyClassSUL(SUL): """ System under learning for inferring python classes. """ - def __init__(self, python_class): + def __init__(self, python_class: type) -> None: """ - Args: + Creates a SUL for a Python class. - python_class: class to be learned + :param type python_class: Class to be learned. """ super().__init__() self._class = python_class self.sul: object = None - def pre(self): + def pre(self) -> None: """ - Do the reset by initializing the class again or call reset method of the class + Do the reset by initializing the class again or call reset method of the class. """ self.sul = self._class() - def post(self): + def post(self) -> None: + """ + Performs no additional cleanup, as a fresh instance is created on every pre() call. + """ pass - def step(self, letter): + def step(self, letter: FunctionDecorator) -> Any: """ - Executes the function(with arguments) found in letter against the SUL - - Args: - - letter: single input of type FunctionDecorator - - Returns: - - output of the function + Executes the function(with arguments) found in letter against the SUL. + :param FunctionDecorator letter: Single input of type FunctionDecorator. + :return Any: Output of the function. """ if letter.args: return getattr(self.sul, letter.function.__name__, letter)(*letter.args) diff --git a/aalpy/SULs/RegexSUL.py b/aalpy/SULs/RegexSUL.py index 1499bdc5168..5454acf8bba 100644 --- a/aalpy/SULs/RegexSUL.py +++ b/aalpy/SULs/RegexSUL.py @@ -1,36 +1,44 @@ -from aalpy.base import SUL +# SUL for learning a regular expression as a DFA-like acceptor. import re +from aalpy.base import SUL + class RegexSUL(SUL): """ An example implementation of a system under learning that can be used to learn any regex expression. Note that the $ is added to the expression as in this SUL only exact matches are learned. """ - def __init__(self, regex: str): + def __init__(self, regex: str) -> None: + """ + Creates a SUL for a regular expression. + + :param str regex: The regular expression to learn. A trailing '$' is added if missing. + """ super().__init__() self.regex = regex if regex[-1] == '$' else regex + '$' self.string = "" - def pre(self): + def pre(self) -> None: + """ + Resets the accumulated input string. + """ self.string = "" pass - def post(self): + def post(self) -> None: + """ + Resets the accumulated input string. + """ self.string = "" pass - def step(self, letter): + def step(self, letter: str | None) -> bool: """ + Appends the letter to the accumulated string and checks whether it matches the regex. - Args: - - letter: single element of the input alphabet - - Returns: - - Whether the current string (previous string + letter) is accepted - + :param str | None letter: Single element of the input alphabet. + :return bool: Whether the current string (previous string + letter) is accepted. """ if letter is not None: self.string += str(letter) diff --git a/aalpy/SULs/TomitaSUL.py b/aalpy/SULs/TomitaSUL.py index 89a7fbe71fe..a319c012cf6 100644 --- a/aalpy/SULs/TomitaSUL.py +++ b/aalpy/SULs/TomitaSUL.py @@ -1,3 +1,4 @@ +# SUL implementing the seven classical Tomita grammars, a common benchmark for automata learning. import re from aalpy.base import SUL @@ -9,7 +10,12 @@ class TomitaSUL(SUL): Tomita grammars and enables their learning. """ - def __init__(self, tomita_level_fun): + def __init__(self, tomita_level_fun: int) -> None: + """ + Creates a SUL for a Tomita grammar. + + :param int tomita_level_fun: Number of the Tomita grammar to learn (1-7, or -3 for the negation of grammar 3). + """ super().__init__() num_fun_map = {1: tomita_1, 2: tomita_2, 3: tomita_3, 4: tomita_4, 5: tomita_5, 6: tomita_6, 7: tomita_7, -3: not_tomita_3} @@ -17,15 +23,27 @@ def __init__(self, tomita_level_fun): self.string = "" self.tomita_level = num_fun_map[tomita_level_fun] - def pre(self): + def pre(self) -> None: + """ + Resets the accumulated input string. + """ self.string = "" pass - def post(self): + def post(self) -> None: + """ + Resets the accumulated input string. + """ self.string = "" pass - def step(self, letter): + def step(self, letter: str) -> bool: + """ + Appends the letter to the accumulated string and checks it against the Tomita grammar. + + :param str letter: Single element of the input alphabet. + :return bool: Whether the current string (previous string + letter) is accepted by the grammar. + """ if input: self.string += str(letter) return self.tomita_level(self.string) @@ -34,35 +52,67 @@ def step(self, letter): _not_tomita_3 = re.compile("((0|1)*0)*1(11)*(0(0|1)*1)*0(00)*(1(0|1)*)*$") -def tomita_1(word): +def tomita_1(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: True if word contains no "0", False otherwise. + """ return "0" not in word -def tomita_2(word): +def tomita_2(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: True if word is "10" repeated, False otherwise. + """ return word == "10" * (int(len(word) / 2)) -def tomita_3(word): +def tomita_3(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: True if word does not match the Tomita 3 grammar's complement pattern, False otherwise. + """ if not _not_tomita_3.match(word): return True return False -def not_tomita_3(word): +def not_tomita_3(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: The negation of tomita_3(word). + """ return not tomita_3(word) -def tomita_4(word): +def tomita_4(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: True if word contains no "000", False otherwise. + """ return "000" not in word -def tomita_5(word): +def tomita_5(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: True if word has an even count of both "0" and "1", False otherwise. + """ return (word.count("0") % 2 == 0) and (word.count("1") % 2 == 0) -def tomita_6(word): +def tomita_6(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: True if the difference between the count of "0" and "1" is divisible by 3, False otherwise. + """ return ((word.count("0") - word.count("1")) % 3) == 0 -def tomita_7(word): +def tomita_7(word: str) -> bool: + """ + :param str word: Word to check. + :return bool: True if word contains at most one occurrence of "10", False otherwise. + """ return word.count("10") <= 1 diff --git a/aalpy/automata/Dfa.py b/aalpy/automata/Dfa.py index 66b6b65d2a2..028f897b686 100644 --- a/aalpy/automata/Dfa.py +++ b/aalpy/automata/Dfa.py @@ -1,4 +1,6 @@ -from typing import Generic, Dict +# Deterministic finite automaton (DFA) state and automaton implementation. +from collections.abc import Hashable +from typing import Generic from aalpy.base import AutomatonState, DeterministicAutomaton from aalpy.base.Automaton import InputType @@ -9,56 +11,98 @@ class DfaState(AutomatonState, Generic[InputType]): Single state of a deterministic finite automaton. """ - def __init__(self, state_id, is_accepting=False): + def __init__(self, state_id: Hashable, is_accepting: bool = False) -> None: + """ + Creates a DFA state. + + :param Hashable state_id: Unique identifier of the state. + :param bool is_accepting: Whether the state is an accepting state. + """ super().__init__(state_id) - self.transitions : Dict[InputType, DfaState] = dict() + self.transitions: dict[InputType, DfaState[InputType]] = dict() self.is_accepting = is_accepting @property - def output(self): + def output(self) -> bool: + """ + :return bool: True if the state is accepting, False otherwise. + """ return self.is_accepting + class Dfa(DeterministicAutomaton[DfaState[InputType]]): """ Deterministic finite automaton. """ - def __init__(self, initial_state: DfaState, states): - super().__init__(initial_state, states) - - def step(self, letter): + def __init__(self, initial_state: DfaState, states: list[DfaState]) -> None: """ - Args: + Creates a DFA. - letter: single input that is looked up in the transition table of the DfaState + :param DfaState initial_state: Initial state of the DFA. + :param list[DfaState] states: All states of the DFA. + """ + super().__init__(initial_state, states) - Returns: + def step(self, letter: InputType | None) -> bool: + """ + Performs a single step on the DFA. - True if the reached state is an accepting state, False otherwise + :param InputType | None letter: Single input that is looked up in the transition table of the DfaState. + :return bool: True if the reached state is an accepting state, False otherwise. """ if letter is not None: self.current_state = self.current_state.transitions[letter] return self.current_state.is_accepting - def compute_characterization_set(self, char_set_init=None, online_suffix_closure=True, split_all_blocks=True, - return_same_states=False, raise_warning=True): + def compute_characterization_set(self, char_set_init: list[tuple] | None = None, online_suffix_closure: bool = True, + split_all_blocks: bool = True, return_same_states: bool = False, + raise_warning: bool = True) -> list[tuple] | None: + """ + Computes a characterization set for the DFA. See DeterministicAutomaton.compute_characterization_set for details. + + :param list[tuple] | None char_set_init: Sequences to include in the characterization set. + :param bool online_suffix_closure: If true, ensures suffix closedness at every computation step. + :param bool split_all_blocks: If true, sequences are used to distinguish all states. + :param bool return_same_states: If true, a single non-distinguishable pair of states is returned. + :param bool raise_warning: Whether to print a warning if the characterization set cannot be computed. + :return list[tuple] | None: The characterization set, or None if it cannot be computed. + """ return super(Dfa, self).compute_characterization_set(char_set_init if char_set_init else [()], online_suffix_closure, split_all_blocks, return_same_states, raise_warning) - def compute_output_seq(self, state, sequence): + def compute_output_seq(self, state: DfaState, sequence: list[InputType]) -> list[bool]: + """ + Computes the output response of the DFA for a given input sequence from a given state. + + :param DfaState state: State from which the output response shall be computed. + :param list[InputType] sequence: Input sequence over the alphabet. + :return list[bool]: The output response. + """ if not sequence: return [state.is_accepting] return super(Dfa, self).compute_output_seq(state, sequence) - def execute_sequence(self, origin_state, seq): + def execute_sequence(self, origin_state: DfaState, seq: list[InputType]) -> list[bool] | bool: + """ + Executes an input sequence on the DFA starting from a given state. + + :param DfaState origin_state: State from which the sequence execution starts. + :param list[InputType] seq: Input sequence to execute. + :return list[bool] | bool: The output response for the executed sequence. + """ if not seq: self.current_state = origin_state return self.current_state.output return super(Dfa, self).execute_sequence(origin_state, seq) + def to_state_setup(self) -> dict: + """ + Converts the DFA to a state setup dictionary. - def to_state_setup(self): + :return dict: Map from state_id to tuple(is_accepting, transitions_dict). + """ state_setup_dict = {} # ensure prefixes are computed @@ -71,10 +115,12 @@ def to_state_setup(self): return state_setup_dict @staticmethod - def from_state_setup(state_setup : dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'Dfa': """ - First state in the state setup is the initial state. - Example state setup: + Creates a DFA from a state setup dictionary. The first state in the state setup is the initial state. + + Example state setup:: + state_setup = { "a": (True, {"x": "b1", "y": "a"}), "b1": (False, {"x": "b2", "y": "a"}), @@ -84,14 +130,9 @@ def from_state_setup(state_setup : dict, **kwargs): "c": (True, {"x": "a", "y": "a"}), } - Args: - - state_setup: map from state_id to tuple(output and transitions_dict) - - Returns: - - DFA - """ + :param dict state_setup: Map from state_id to tuple(is_accepting, transitions_dict). + :return Dfa: The constructed DFA. + """ # state_setup should map from state_id to tuple(is_accepting and transitions_dict) # build states with state_id and output @@ -111,4 +152,4 @@ def from_state_setup(state_setup : dict, **kwargs): for state in states: state.prefix = dfa.get_shortest_path(dfa.initial_state, state) - return dfa \ No newline at end of file + return dfa diff --git a/aalpy/automata/MarkovChain.py b/aalpy/automata/MarkovChain.py index 1280edd2a9a..2b4c3ab426d 100644 --- a/aalpy/automata/MarkovChain.py +++ b/aalpy/automata/MarkovChain.py @@ -1,37 +1,53 @@ +# Markov chain state and automaton implementation, where transitions are probabilistic and input-independent. import random -from typing import Generic, Tuple, List +from collections.abc import Hashable +from typing import Generic from aalpy.base import Automaton, AutomatonState from aalpy.base.Automaton import OutputType class McState(AutomatonState, Generic[OutputType]): - def __init__(self, state_id, output): + """ + Single state of a Markov chain. Each state has an output value and a list of probabilistic transitions. + """ + + def __init__(self, state_id: Hashable, output: OutputType) -> None: + """ + Creates a Markov chain state. + + :param Hashable state_id: Unique identifier of the state. + :param OutputType output: Output value associated with the state. + """ super().__init__(state_id) self.output: OutputType = output # transitions is a list of tuples (Node(output), probability) - self.transitions: List[Tuple[McState, float]] = list() + self.transitions: list[tuple[McState[OutputType], float]] = list() class MarkovChain(Automaton[McState[OutputType]]): """Markov Decision Process.""" - def __init__(self, initial_state, states: list): + def __init__(self, initial_state: McState, states: list) -> None: + """ + Creates a Markov chain. + + :param McState initial_state: Initial state of the Markov chain. + :param list states: All states of the Markov chain. + """ super().__init__(initial_state, states) - def reset_to_initial(self): + def reset_to_initial(self) -> None: + """ + Resets the current state of the Markov chain to the initial state. + """ self.current_state = self.initial_state - def step(self, letter=None): + def step(self, letter: None = None) -> OutputType: """Next step is determined based on transition probabilities of the current state. - Args: - - letter: input - - Returns: - - output of the current state + :param None letter: Unused input, kept for interface compatibility. + :return OutputType: Output of the reached state. """ if not self.current_state.transitions: @@ -45,16 +61,11 @@ def step(self, letter=None): self.current_state = new_state return self.current_state.output - def step_to(self, input): - """Performs a step on the automaton based on the input `inp` and output `out`. - - Args: - - input: input + def step_to(self, input: OutputType) -> OutputType | None: + """Performs a step on the automaton based on the output value to transition to. - Returns: - - output of the reached state, None otherwise + :param OutputType input: Output value identifying the target state among the current state's transitions. + :return OutputType | None: Output of the reached state, None otherwise. """ for s in self.current_state.transitions: if s[0].output == input: @@ -63,8 +74,18 @@ def step_to(self, input): return None @staticmethod - def from_state_setup(state_setup: dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'MarkovChain': + """ + Not yet implemented. + + :param dict state_setup: Map from state_id to state configuration. + """ raise NotImplementedError() # TODO implement - def to_state_setup(self): + def to_state_setup(self) -> dict: + """ + Not yet implemented. + + :return dict: Map from state_id to state configuration. + """ raise NotImplementedError() # TODO implement diff --git a/aalpy/automata/Mdp.py b/aalpy/automata/Mdp.py index 8f530adc794..d8c21fc5ab6 100644 --- a/aalpy/automata/Mdp.py +++ b/aalpy/automata/Mdp.py @@ -1,6 +1,8 @@ +# Markov decision process state and automaton implementation, where transitions are probabilistic given an input. import random from collections import defaultdict -from typing import Dict, Generic, List, Tuple +from collections.abc import Hashable +from typing import Generic from aalpy.base import Automaton, AutomatonState from aalpy.base.Automaton import OutputType, InputType @@ -8,34 +10,45 @@ class MdpState(AutomatonState, Generic[InputType, OutputType]): """ - For transitions, each transition is a tuple (Node(output), probability) + Single state of an MDP. For transitions, each transition is a tuple (Node(output), probability). """ - def __init__(self, state_id, output=None): + + def __init__(self, state_id: Hashable, output: OutputType | None = None) -> None: + """ + Creates an MDP state. + + :param Hashable state_id: Unique identifier of the state. + :param OutputType | None output: Output value associated with the state. + """ super().__init__(state_id) - self.output: OutputType = output + self.output: OutputType | None = output # each transition is a tuple (Node(output), probability) - self.transitions: Dict[InputType, List[Tuple[MdpState, float]]] = defaultdict(list) + self.transitions: dict[InputType, list[tuple[MdpState[InputType, OutputType], float]]] = defaultdict(list) class Mdp(Automaton[MdpState[InputType, OutputType]]): """Markov Decision Process.""" - def __init__(self, initial_state: MdpState, states: list): + def __init__(self, initial_state: MdpState, states: list) -> None: + """ + Creates an MDP. + + :param MdpState initial_state: Initial state of the MDP. + :param list states: All states of the MDP. + """ super().__init__(initial_state, states) - def reset_to_initial(self): + def reset_to_initial(self) -> None: + """ + Resets the current state of the MDP to the initial state. + """ self.current_state = self.initial_state - def step(self, letter): + def step(self, letter: InputType | None) -> OutputType: """Next step is determined based on transition probabilities of the current state. - Args: - - letter: input - - Returns: - - output of the current state + :param InputType | None letter: Input. + :return OutputType: Output of the current state. """ if letter is None: return self.current_state.output @@ -48,17 +61,12 @@ def step(self, letter): self.current_state = new_state return self.current_state.output - def step_to(self, inp, out): + def step_to(self, inp: InputType, out: OutputType) -> OutputType | None: """Performs a step on the automaton based on the input `inp` and output `out`. - Args: - - inp: input - out: output - - Returns: - - output of the reached state, None otherwise + :param InputType inp: Input. + :param OutputType out: Output. + :return OutputType | None: Output of the reached state, None otherwise. """ for new_state in self.current_state.transitions[inp]: if new_state[0].output == out: @@ -66,7 +74,12 @@ def step_to(self, inp, out): return out return None - def to_state_setup(self): + def to_state_setup(self) -> dict: + """ + Converts the MDP to a state setup dictionary. + + :return dict: Map from state_id to tuple(output, transitions_dict). + """ state_setup_dict = {} # ensure initial state is first in the list @@ -81,7 +94,13 @@ def to_state_setup(self): return state_setup_dict @staticmethod - def from_state_setup(state_setup: dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'Mdp': + """ + Creates an MDP from a state setup dictionary. The first state in the state setup is the initial state. + + :param dict state_setup: Map from state_id to tuple(output, transitions_dict). + :return Mdp: The constructed MDP. + """ states_map = {key: MdpState(key, output=value[0]) for key, value in state_setup.items()} for key, values in state_setup.items(): diff --git a/aalpy/automata/MealyMachine.py b/aalpy/automata/MealyMachine.py index d498c56914e..31466f6d8a5 100644 --- a/aalpy/automata/MealyMachine.py +++ b/aalpy/automata/MealyMachine.py @@ -1,4 +1,6 @@ -from typing import Generic, Dict +# Mealy machine state and automaton implementation, where outputs are associated with transitions. +from collections.abc import Hashable +from typing import Generic from aalpy.base import AutomatonState, DeterministicAutomaton from aalpy.base.Automaton import OutputType, InputType @@ -9,34 +11,49 @@ class MealyState(AutomatonState, Generic[InputType, OutputType]): Single state of a Mealy machine. Each state has an output_fun dictionary that maps inputs to outputs. """ - def __init__(self, state_id): + def __init__(self, state_id: Hashable) -> None: + """ + Creates a Mealy machine state. + + :param Hashable state_id: Unique identifier of the state. + """ super().__init__(state_id) - self.transitions : Dict[InputType, MealyState] = dict() - self.output_fun : Dict[InputType, OutputType] = dict() + self.transitions: dict[InputType, MealyState[InputType, OutputType]] = dict() + self.output_fun: dict[InputType, OutputType] = dict() class MealyMachine(DeterministicAutomaton[MealyState[InputType, OutputType]]): + """ + Deterministic Mealy machine, where outputs depend on the input and the current state. + """ - def __init__(self, initial_state: MealyState, states): - super().__init__(initial_state, states) - - def step(self, letter): + def __init__(self, initial_state: MealyState, states: list[MealyState]) -> None: """ - In Mealy machines, outputs depend on the input and the current state. - - Args: + Creates a Mealy machine. - letter: single input that is looked up in the transition and output functions + :param MealyState initial_state: Initial state of the Mealy machine. + :param list[MealyState] states: All states of the Mealy machine. + """ + super().__init__(initial_state, states) - Returns: + def step(self, letter: InputType) -> OutputType: + """ + Performs a single step on the Mealy machine. In Mealy machines, outputs depend on the input and the + current state. - output corresponding to the input from the current state + :param InputType letter: Single input that is looked up in the transition and output functions. + :return OutputType: Output corresponding to the input from the current state. """ output = self.current_state.output_fun[letter] self.current_state = self.current_state.transitions[letter] return output - def to_state_setup(self): + def to_state_setup(self) -> dict: + """ + Converts the Mealy machine to a state setup dictionary. + + :return dict: Map from state_id to a transitions_dict mapping input to (output, target_state_id). + """ state_setup_dict = {} # ensure prefixes are computed @@ -49,9 +66,13 @@ def to_state_setup(self): return state_setup_dict @staticmethod - def from_state_setup(state_setup : dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'MealyMachine': """ - First state in the state setup is the initial state. + Creates a Mealy machine from a state setup dictionary. The first state in the state setup is the initial + state. + + Example state setup:: + state_setup = { "a": {"x": ("o1", "b1"), "y": ("o2", "a")}, "b1": {"x": ("o3", "b2"), "y": ("o1", "a")}, @@ -61,15 +82,8 @@ def from_state_setup(state_setup : dict, **kwargs): "c": {"x": ("o3", "a"), "y": ("o5", "a")}, } - - Args: - - state_setup: - state_setup should map from state_id to tuple(transitions_dict). - - Returns: - - Mealy Machine + :param dict state_setup: Map from state_id to a transitions_dict mapping input to (output, target_state_id). + :return MealyMachine: The constructed Mealy machine. """ # state_setup should map from state_id to tuple(transitions_dict). # Each entry in transition dict is : @@ -92,4 +106,4 @@ def from_state_setup(state_setup : dict, **kwargs): for state in states: state.prefix = mm.get_shortest_path(mm.initial_state, state) - return mm \ No newline at end of file + return mm diff --git a/aalpy/automata/MooreMachine.py b/aalpy/automata/MooreMachine.py index 6ff8c562f80..beb086045a2 100644 --- a/aalpy/automata/MooreMachine.py +++ b/aalpy/automata/MooreMachine.py @@ -1,60 +1,103 @@ -from typing import Generic, Dict +# Moore machine state and automaton implementation, where outputs are associated with states. +from collections.abc import Hashable +from typing import Generic from aalpy.base import AutomatonState, DeterministicAutomaton from aalpy.base.Automaton import InputType, OutputType -class MooreState(AutomatonState, Generic[InputType,OutputType]): +class MooreState(AutomatonState, Generic[InputType, OutputType]): """ Single state of a Moore machine. Each state has an output value. """ - def __init__(self, state_id, output=None): + def __init__(self, state_id: Hashable, output: OutputType | None = None) -> None: + """ + Creates a Moore machine state. + + :param Hashable state_id: Unique identifier of the state. + :param OutputType | None output: Output value associated with the state. + """ super().__init__(state_id) - self.output : OutputType = output - self.transitions : Dict[InputType, MooreState] = dict() + self.output: OutputType | None = output + self.transitions: dict[InputType, MooreState[InputType, OutputType]] = dict() class MooreMachine(DeterministicAutomaton[MooreState[InputType, OutputType]]): + """ + Deterministic Moore machine, where outputs depend only on the current state. + """ - def __init__(self, initial_state: AutomatonState, states: list): - super().__init__(initial_state, states) - - def step(self, letter): + def __init__(self, initial_state: AutomatonState, states: list) -> None: """ - In Moore machines outputs depend on the current state. - - Args: + Creates a Moore machine. - letter: single input that is looked up in the transition function leading to a new state - - Returns: + :param AutomatonState initial_state: Initial state of the Moore machine. + :param list states: All states of the Moore machine. + """ + super().__init__(initial_state, states) - the output of the reached state + def step(self, letter: InputType | None) -> OutputType: + """ + Performs a single step on the Moore machine. In Moore machines outputs depend on the current state. + :param InputType | None letter: Single input that is looked up in the transition function leading to a + new state. + :return OutputType: The output of the reached state. """ if letter is not None: self.current_state = self.current_state.transitions[letter] return self.current_state.output - def compute_characterization_set(self, char_set_init=None, online_suffix_closure=True, split_all_blocks=True, - return_same_states=False, raise_warning=True): + def compute_characterization_set(self, char_set_init: list[tuple] | None = None, online_suffix_closure: bool = True, + split_all_blocks: bool = True, return_same_states: bool = False, + raise_warning: bool = True) -> list[tuple] | None: + """ + Computes a characterization set for the Moore machine. See + DeterministicAutomaton.compute_characterization_set for details. + + :param list[tuple] | None char_set_init: Sequences to include in the characterization set. + :param bool online_suffix_closure: If true, ensures suffix closedness at every computation step. + :param bool split_all_blocks: If true, sequences are used to distinguish all states. + :param bool return_same_states: If true, a single non-distinguishable pair of states is returned. + :param bool raise_warning: Whether to print a warning if the characterization set cannot be computed. + :return list[tuple] | None: The characterization set, or None if it cannot be computed. + """ return super(MooreMachine, self).compute_characterization_set(char_set_init if char_set_init else [()], online_suffix_closure, split_all_blocks, return_same_states, raise_warning) - def compute_output_seq(self, state, sequence): + def compute_output_seq(self, state: MooreState, sequence: list[InputType]) -> list[OutputType]: + """ + Computes the output response of the Moore machine for a given input sequence from a given state. + + :param MooreState state: State from which the output response shall be computed. + :param list[InputType] sequence: Input sequence over the alphabet. + :return list[OutputType]: The output response. + """ if not sequence: return [state.output] return super(MooreMachine, self).compute_output_seq(state, sequence) - def execute_sequence(self, origin_state, seq): + def execute_sequence(self, origin_state: MooreState, seq: list[InputType]) -> list[OutputType] | OutputType: + """ + Executes an input sequence on the Moore machine starting from a given state. + + :param MooreState origin_state: State from which the sequence execution starts. + :param list[InputType] seq: Input sequence to execute. + :return list[OutputType] | OutputType: The output response for the executed sequence. + """ if not seq: self.current_state = origin_state return self.current_state.output return super(MooreMachine, self).execute_sequence(origin_state, seq) - def to_state_setup(self): + def to_state_setup(self) -> dict: + """ + Converts the Moore machine to a state setup dictionary. + + :return dict: Map from state_id to tuple(output, transitions_dict). + """ state_setup_dict = {} # ensure prefixes are computed @@ -67,26 +110,24 @@ def to_state_setup(self): return state_setup_dict @staticmethod - def from_state_setup(state_setup : dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'MooreMachine': """ - First state in the state setup is the initial state. - Example state setup: - state_setup = { - "a": ("a", {"x": "b1", "y": "a"}), - "b1": ("b", {"x": "b2", "y": "a"}), - "b2": ("b", {"x": "b3", "y": "a"}), - "b3": ("b", {"x": "b4", "y": "a"}), - "b4": ("b", {"x": "c", "y": "a"}), - "c": ("c", {"x": "a", "y": "a"}), - } - - Args: - - state_setup: map from state_id to tuple(output and transitions_dict) - - Returns: - - Moore machine + Creates a Moore machine from a state setup dictionary. The first state in the state setup is the initial + state. + + Example state setup:: + + state_setup = { + "a": ("a", {"x": "b1", "y": "a"}), + "b1": ("b", {"x": "b2", "y": "a"}), + "b2": ("b", {"x": "b3", "y": "a"}), + "b3": ("b", {"x": "b4", "y": "a"}), + "b4": ("b", {"x": "c", "y": "a"}), + "c": ("c", {"x": "a", "y": "a"}), + } + + :param dict state_setup: Map from state_id to tuple(output, transitions_dict). + :return MooreMachine: The constructed Moore machine. """ # build states with state_id and output @@ -109,7 +150,13 @@ def from_state_setup(state_setup : dict, **kwargs): return mm @staticmethod - def to_dfa(moore_machine): + def to_dfa(moore_machine: 'MooreMachine') -> 'Dfa': + """ + Converts a Moore machine with boolean state outputs to a DFA. + + :param MooreMachine moore_machine: Moore machine to convert. All states must have boolean outputs. + :return Dfa: The equivalent DFA. + """ from aalpy.automata.Dfa import Dfa, DfaState if not all(isinstance(state.output, bool) for state in moore_machine.states): @@ -127,4 +174,4 @@ def to_dfa(moore_machine): dfa = Dfa(dfa_state_map[moore_machine.initial_state], list(dfa_state_map.values())) dfa.current_state = dfa.initial_state - return dfa \ No newline at end of file + return dfa diff --git a/aalpy/automata/NonDeterministicMooreMachine.py b/aalpy/automata/NonDeterministicMooreMachine.py index 97d474d73fb..1d14492407a 100644 --- a/aalpy/automata/NonDeterministicMooreMachine.py +++ b/aalpy/automata/NonDeterministicMooreMachine.py @@ -1,6 +1,8 @@ +# Non-deterministic Moore machine state and automaton implementation. import random from collections import defaultdict -from typing import List, Dict, Generic +from collections.abc import Hashable +from typing import Generic from aalpy.base import AutomatonState, Automaton from aalpy.base.Automaton import OutputType, InputType @@ -11,18 +13,36 @@ class NDMooreState(AutomatonState, Generic[InputType, OutputType]): Single state of a non-deterministic Moore machine. Each state has an output value. """ - def __init__(self, state_id, output=None): + def __init__(self, state_id: Hashable, output: OutputType | None = None) -> None: + """ + Creates a non-deterministic Moore machine state. + + :param Hashable state_id: Unique identifier of the state. + :param OutputType | None output: Output value associated with the state. + """ super().__init__(state_id) - self.transitions: Dict[InputType, List['NDMooreState']] = defaultdict(lambda: list()) - self.output: OutputType = output + self.transitions: dict[InputType, list[NDMooreState[InputType, OutputType]]] = defaultdict(lambda: list()) + self.output: OutputType | None = output class NDMooreMachine(Automaton[NDMooreState[InputType, OutputType]]): + """ + Non-deterministic Moore machine, where outputs depend on the current state and transitions are chosen + non-deterministically. + """ - def to_state_setup(self): + def to_state_setup(self) -> None: + """ + Builds a state setup dictionary for the non-deterministic Moore machine. + """ state_setup = dict() - def set_dict_entry(state: NDMooreState): + def set_dict_entry(state: NDMooreState) -> None: + """ + Adds a single state's configuration to the enclosing state_setup dictionary. + + :param NDMooreState state: State to add. + """ state_setup[state.state_id] = (state.output, {in_sym: [target.state_id for target in trans] for in_sym, trans in state.transitions.items()}) @@ -35,6 +55,13 @@ def set_dict_entry(state: NDMooreState): @staticmethod def from_state_setup(state_setup: dict, **kwargs) -> 'NDMooreMachine': + """ + Creates a non-deterministic Moore machine from a state setup dictionary. The first state in the state + setup is the initial state. + + :param dict state_setup: Map from state_id to tuple(output, transitions_dict). + :return NDMooreMachine: The constructed non-deterministic Moore machine. + """ states_map = {key: NDMooreState(key, output=value[0]) for key, value in state_setup.items()} for key, values in state_setup.items(): @@ -46,21 +73,21 @@ def from_state_setup(state_setup: dict, **kwargs) -> 'NDMooreMachine': initial_state = states_map[list(state_setup.keys())[0]] return NDMooreMachine(initial_state, list(states_map.values())) - def __init__(self, initial_state: AutomatonState, states: list): + def __init__(self, initial_state: AutomatonState, states: list) -> None: + """ + Creates a non-deterministic Moore machine. + + :param AutomatonState initial_state: Initial state of the non-deterministic Moore machine. + :param list states: All states of the non-deterministic Moore machine. + """ super().__init__(initial_state, states) - def step(self, letter): + def step(self, letter: InputType) -> OutputType: """ In Moore machines outputs depend on the current state. - Args: - - letter: single input that is looked up in the transition function leading to a new state - - Returns: - - the output of the reached state - + :param InputType letter: Single input that is looked up in the transition function leading to a new state. + :return OutputType: The output of the reached state. """ options = self.current_state.transitions[letter] self.current_state = random.choice(options) diff --git a/aalpy/automata/Onfsm.py b/aalpy/automata/Onfsm.py index fb0bc9a3f8e..3c0103d9672 100644 --- a/aalpy/automata/Onfsm.py +++ b/aalpy/automata/Onfsm.py @@ -1,41 +1,48 @@ +# Observable non-deterministic finite state automaton (ONFSM) state and automaton implementation. from collections import defaultdict +from collections.abc import Hashable from random import choice -from typing import Generic, Tuple, Dict, List +from typing import Generic from aalpy.base import Automaton, AutomatonState from aalpy.base.Automaton import OutputType, InputType class OnfsmState(AutomatonState, Generic[InputType, OutputType]): - """ """ - def __init__(self, state_id): + """ + Single state of an observable non-deterministic finite state automaton. + """ + + def __init__(self, state_id: Hashable) -> None: + """ + Creates an ONFSM state. + + :param Hashable state_id: Unique identifier of the state. + """ super().__init__(state_id) # TODO this order is inconsistent with probabilistic models # key/input maps to the list of tuples of possible output/new state [(output1, state1), (output2, state2)] - self.transitions : Dict[InputType, List[Tuple[OutputType, OnfsmState]]] = defaultdict(list) + self.transitions: dict[InputType, list[tuple[OutputType, OnfsmState[InputType, OutputType]]]] = defaultdict(list) - def add_transition(self, inp, out, new_state) : + def add_transition(self, inp: InputType, out: OutputType, new_state: 'OnfsmState[InputType, OutputType]') -> None: """ + Adds a transition from this state. - Args: - inp: - out: - new_state: - - Returns: - + :param InputType inp: Input triggering the transition. + :param OutputType out: Output produced by the transition. + :param OnfsmState new_state: Target state of the transition. """ self.transitions[inp].append((out, new_state)) - def get_transition(self, input, output=None): + def get_transition(self, input: InputType, output: OutputType | None = None) \ + -> list[tuple[OutputType, 'OnfsmState[InputType, OutputType]']] | tuple[OutputType, 'OnfsmState[InputType, OutputType]'] | None: """ + Looks up the possible transitions for a given input, optionally filtered by output. - Args: - input: - output: (Default value = None) - - Returns: - + :param InputType input: Input to look up. + :param OutputType | None output: If given, only the transition matching this output is returned. + :return list[tuple[OutputType, OnfsmState]] | tuple[OutputType, OnfsmState] | None: All possible + transitions for the input, or the single matching transition if output is given, or None if not found. """ possible_transitions = self.transitions[input] if output: @@ -48,52 +55,41 @@ class Onfsm(Automaton[OnfsmState[InputType, OutputType]]): """ Observable non-deterministic finite state automaton. """ - def __init__(self, initial_state: OnfsmState, states: list): - super().__init__(initial_state, states) - - def step(self, letter): - """Next step is determined based on a uniform distribution over all transitions with the input 'letter'. - - Args: - letter: input + def __init__(self, initial_state: OnfsmState, states: list) -> None: + """ + Creates an ONFSM. - Returns: + :param OnfsmState initial_state: Initial state of the ONFSM. + :param list states: All states of the ONFSM. + """ + super().__init__(initial_state, states) - output of the probabilistically chosen transition + def step(self, letter: InputType) -> OutputType: + """Next step is determined based on a uniform distribution over all transitions with the input 'letter'. + :param InputType letter: Input. + :return OutputType: Output of the probabilistically chosen transition. """ transition = choice(self.current_state.transitions[letter]) output = transition[0] self.current_state = transition[1] return output - def outputs_on_input(self, letter): + def outputs_on_input(self, letter: InputType) -> list[OutputType]: """All possible observable outputs after executing the current input 'letter'. - Args: - - letter: input - - Returns: - - list of observable outputs - + :param InputType letter: Input. + :return list[OutputType]: List of observable outputs. """ return [trans[0] for trans in self.current_state.transitions[letter]] - def step_to(self, inp, out): + def step_to(self, inp: InputType, out: OutputType) -> OutputType | None: """Performs a step on the automaton based on the input `inp` and output `out`. - Args: - - inp: input - out: output - - Returns: - - output of the reached state, None otherwise - + :param InputType inp: Input. + :param OutputType out: Output. + :return OutputType | None: Output of the reached state, None otherwise. """ for new_state in self.current_state.transitions[inp]: if new_state[0] == out: @@ -102,8 +98,18 @@ def step_to(self, inp, out): return None @staticmethod - def from_state_setup(state_setup : dict, **kwargs): - raise NotImplementedError() # TODO implement + def from_state_setup(state_setup: dict, **kwargs) -> 'Onfsm': + """ + Not yet implemented. + + :param dict state_setup: Map from state_id to state configuration. + """ + raise NotImplementedError() # TODO implement + + def to_state_setup(self) -> dict: + """ + Not yet implemented. - def to_state_setup(self): - raise NotImplementedError # TODO implement \ No newline at end of file + :return dict: Map from state_id to state configuration. + """ + raise NotImplementedError() # TODO implement diff --git a/aalpy/automata/Sevpa.py b/aalpy/automata/Sevpa.py index f9b080d073f..8bd29c80d69 100644 --- a/aalpy/automata/Sevpa.py +++ b/aalpy/automata/Sevpa.py @@ -1,6 +1,7 @@ +# 1-Module Single Entry Visibly Pushdown Automaton (1-SEVPA) state and automaton implementation. import random from collections import defaultdict, deque -from typing import Union, List, Dict +from collections.abc import Hashable from aalpy.base import Automaton, AutomatonState @@ -10,26 +11,33 @@ class SevpaAlphabet: The Alphabet of a 1-SEVPA. Attributes: - internal_alphabet (List[str]): Letters for internal transitions. - call_alphabet (List[str]): Letters for push transitions. - return_alphabet (List[str]): Letters for pop transitions. - exclusive_call_return_pairs (Dict[str, str]): A dictionary representing exclusive pairs + internal_alphabet (list[str]): Letters for internal transitions. + call_alphabet (list[str]): Letters for push transitions. + return_alphabet (list[str]): Letters for pop transitions. + exclusive_call_return_pairs (dict[str, str]): A dictionary representing exclusive pairs of call and return symbols. """ - def __init__(self, internal_alphabet: List[str], call_alphabet: List[str], return_alphabet: List[str], - exclusive_call_return_pairs: Dict[str, str] = None): + def __init__(self, internal_alphabet: list[str], call_alphabet: list[str], return_alphabet: list[str], + exclusive_call_return_pairs: dict[str, str] | None = None) -> None: + """ + Creates a 1-SEVPA alphabet. + + :param list[str] internal_alphabet: Letters for internal transitions. + :param list[str] call_alphabet: Letters for push transitions. + :param list[str] return_alphabet: Letters for pop transitions. + :param dict[str, str] | None exclusive_call_return_pairs: Exclusive pairs of call and return symbols. + """ self.internal_alphabet = internal_alphabet self.call_alphabet = call_alphabet self.return_alphabet = return_alphabet self.exclusive_call_return_pairs = exclusive_call_return_pairs - def get_merged_alphabet(self) -> List[str]: + def get_merged_alphabet(self) -> list[str]: """ Get the merged alphabet, including internal, call, and return symbols. - Returns: - List[str]: A list of all symbols in the alphabet. + :return list[str]: A list of all symbols in the alphabet. """ alphabet = list() alphabet.extend(self.internal_alphabet) @@ -39,8 +47,7 @@ def get_merged_alphabet(self) -> List[str]: def __str__(self) -> str: """ - Returns: - str: A string representation of the alphabet. + :return str: A string representation of the alphabet. """ return f'Internal: {self.internal_alphabet} Call: {self.call_alphabet} Return: {self.return_alphabet}' @@ -50,10 +57,16 @@ class SevpaState(AutomatonState): Single state of a 1-SEVPA. """ - def __init__(self, state_id, is_accepting=False): + def __init__(self, state_id: Hashable, is_accepting: bool = False) -> None: + """ + Creates a 1-SEVPA state. + + :param Hashable state_id: Unique identifier of the state. + :param bool is_accepting: Whether the state is an accepting state. + """ super().__init__(state_id) # list of SevpaTransition - self.transitions = defaultdict(list) + self.transitions: dict[str, list['SevpaTransition']] = defaultdict(list) self.is_accepting = is_accepting @@ -68,16 +81,23 @@ class SevpaTransition: stack_guard: Pair of (automaton_state_id, call_letter) """ - def __init__(self, target: SevpaState, letter, action, stack_guard=None): + def __init__(self, target: SevpaState, letter: str, action: str | None, stack_guard: tuple | None = None) -> None: + """ + Creates a 1-SEVPA transition. + + :param SevpaState target: The target state of the transition. + :param str letter: The symbol associated with the transition. + :param str | None action: The action performed during the transition (pop | None). + :param tuple | None stack_guard: Pair of (automaton_state_id, call_letter). + """ self.target_state = target self.letter = letter self.action = action self.stack_guard = stack_guard - def __str__(self): + def __str__(self) -> str: """ - Returns: - str: A string representation of the transition. + :return str: A string representation of the transition. """ return f'{self.letter} --> {self.target_state.state_id}' + \ f' | {self.action}: {self.stack_guard}' if self.stack_guard else '' @@ -89,7 +109,13 @@ class Sevpa(Automaton): """ empty = "_" - def __init__(self, initial_state: SevpaState, states: List[SevpaState]): + def __init__(self, initial_state: SevpaState, states: list[SevpaState]) -> None: + """ + Creates a 1-SEVPA. + + :param SevpaState initial_state: Initial state of the 1-SEVPA. + :param list[SevpaState] states: All states of the 1-SEVPA. + """ super().__init__(initial_state, states) self.initial_state = initial_state self.states = states @@ -103,22 +129,24 @@ def __init__(self, initial_state: SevpaState, states: List[SevpaState]): self.call_set = set(self.input_alphabet.call_alphabet) self.return_set = set(self.input_alphabet.return_alphabet) - def reset_to_initial(self): + def reset_to_initial(self) -> bool: + """ + Resets the current state and stack of the 1-SEVPA to the initial configuration. + + :return bool: True if the initial state is accepting and the stack is empty, False otherwise. + """ super().reset_to_initial() self.current_state = self.initial_state self.stack = [self.empty] self.error_state_reached = False return self.current_state.is_accepting and self.stack[-1] == self.empty - def step(self, letter): + def step(self, letter: str | None) -> bool: """ Perform a single step on the 1-SEVPA by transitioning with the given input letter. - Args: - letter: A single input that is looked up in the transition table of the SevpaState. - - Returns: - bool: True if the reached state is an accepting state and the stack is empty, False otherwise. + :param str | None letter: A single input that is looked up in the transition table of the SevpaState. + :return bool: True if the reached state is an accepting state and the stack is empty, False otherwise. """ if self.error_state_reached: return False @@ -161,23 +189,45 @@ def step(self, letter): return self.current_state.is_accepting and self.stack[-1] == self.empty - def get_state_by_id(self, state_id) -> Union[SevpaState, None]: + def get_state_by_id(self, state_id: Hashable) -> SevpaState | None: + """ + Looks up a state by its state_id. + + :param Hashable state_id: Identifier of the state to look up. + :return SevpaState | None: The state with the given id, or None if not found. + """ for state in self.states: if state.state_id == state_id: return state return None def is_input_complete(self) -> bool: + """ + Not yet implemented. + """ pass - def execute_sequence(self, origin_state, seq): + def execute_sequence(self, origin_state: SevpaState, seq: list[str]) -> list[bool]: + """ + Executes an input sequence on the 1-SEVPA starting from the initial state. + + :param SevpaState origin_state: State from which the sequence execution starts, must share the initial + state's prefix. + :param list[str] seq: Input sequence to execute. + :return list[bool]: The output response for each step of the executed sequence. + """ if origin_state.prefix != self.initial_state.prefix: assert False, 'execute_sequence for Sevpa only is only supported from the initial state.' self.reset_to_initial() self.current_state = origin_state return [self.step(s) for s in seq] - def to_state_setup(self): + def to_state_setup(self) -> dict: + """ + Converts the 1-SEVPA to a state setup dictionary. + + :return dict: Map from state_id to tuple(is_accepting, transitions_dict). + """ state_setup_dict = {} sorted_states = sorted(self.states, key=lambda x: x.state_id) @@ -195,7 +245,14 @@ def to_state_setup(self): return state_setup_dict @staticmethod - def from_state_setup(state_setup: dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'Sevpa': + """ + Creates a 1-SEVPA from a state setup dictionary. + + :param dict state_setup: Map from state_id to tuple(is_accepting, transitions_dict). + :param init_state_id: State id of the initial state, passed via kwargs. + :return Sevpa: The constructed 1-SEVPA. + """ init_state_id = kwargs['init_state_id'] @@ -223,7 +280,7 @@ def from_state_setup(state_setup: dict, **kwargs): init_state = states[init_state_id] return Sevpa(init_state, [state for state in states.values()]) - def transform_access_string(self, state=None, stack_content=None) -> List[str]: + def transform_access_string(self, state: SevpaState | None = None, stack_content: list | None = None) -> list[str]: """ Transform the access string by omitting redundant call and return letters, as well as internal letters. @@ -233,12 +290,11 @@ def transform_access_string(self, state=None, stack_content=None) -> List[str]: - Append the call letter Append the state prefix from the state where you are calling this function from. - Args: - state: The state from which the transformation is initiated (default: initial state). - stack_content: The content of the stack for transformation (default: Current Stack content). - - Returns: - List[str]: The transformed access string. + :param SevpaState | None state: The state from which the transformation is initiated (default: initial + state). + :param list | None stack_content: The content of the stack for transformation (default: current stack + content). + :return list[str]: The transformed access string. """ word = [] calling_state = self.initial_state if not state else state @@ -258,19 +314,16 @@ def transform_access_string(self, state=None, stack_content=None) -> List[str]: return word @staticmethod - def create_daisy_hypothesis(initial_state, alphabet): + def create_daisy_hypothesis(initial_state: SevpaState, alphabet: SevpaAlphabet) -> 'Sevpa': """ Create a Daisy Hypothesis 1-SEVPA using the given initial state and alphabet. This function creates self-loop transitions for the internal state on every internal letter. Additionally, it creates self-loop transitions with a pop action for every call letter. - Args: - initial_state (SevpaState): The initial state of the 1-SEVPA. - alphabet (SevpaAlphabet): The alphabet for the 1-SEVPA. - - Returns: - Sevpa: The created 1-SEVPA with the specified initial state and alphabet. + :param SevpaState initial_state: The initial state of the 1-SEVPA. + :param SevpaAlphabet alphabet: The alphabet for the 1-SEVPA. + :return Sevpa: The created 1-SEVPA with the specified initial state and alphabet. """ for i in alphabet.internal_alphabet: trans = SevpaTransition(target=initial_state, letter=i, action=None) @@ -284,7 +337,12 @@ def create_daisy_hypothesis(initial_state, alphabet): return Sevpa(initial_state, [initial_state]) - def get_input_alphabet(self): + def get_input_alphabet(self) -> SevpaAlphabet: + """ + Computes the input alphabet of the 1-SEVPA from its transitions. + + :return SevpaAlphabet: The input alphabet. + """ int_alphabet, ret_alphabet, call_alphabet = [], [], [] for state in self.states: @@ -301,7 +359,7 @@ def get_input_alphabet(self): return SevpaAlphabet(int_alphabet, call_alphabet, ret_alphabet) - def get_error_state(self): + def get_error_state(self) -> SevpaState | None: """ A state is an error state iff: - if all transitions self loop to itself @@ -311,6 +369,8 @@ def get_error_state(self): - the pop transitions from the initial state which pop the q2+call-symbol from the stack lead to q2 as well - Not an error state if it is the initial state or an accepting state + + :return SevpaState | None: The error state, or None if there is none. """ for state in self.states: @@ -353,7 +413,12 @@ def get_error_state(self): return None - def delete_state(self, state_to_remove): + def delete_state(self, state_to_remove: SevpaState | None) -> None: + """ + Removes a state and all transitions referencing it from the 1-SEVPA. + + :param SevpaState | None state_to_remove: The state to remove. If None, the method returns without effect. + """ if state_to_remove is not None: self.states.remove(state_to_remove) @@ -377,7 +442,7 @@ def delete_state(self, state_to_remove): del state.transitions[letter] state.transitions[letter] = cleaned_transitions - def get_allowed_call_transitions(self): + def get_allowed_call_transitions(self) -> dict[str, set]: """ Returns a dict of states that are allowed to push a call letters on the stack. @@ -387,8 +452,8 @@ def get_allowed_call_transitions(self): States are not allowed to push something somthing on the stack if there is no possibility to pop the stack guard, where their state_id is used, from the stack, which would lead into a dead-end otherwise. - Returns: - - dict: A dictionary where keys are the call_letters and values are sets of the allowed states. + :return dict[str, set]: A dictionary where keys are the call_letters and values are sets of the allowed + states. """ # get all states that are connected via internal transitions by using BFS @@ -412,16 +477,13 @@ def get_allowed_call_transitions(self): return allowed_call_transitions - def get_accepting_words_bfs(self, min_word_length: int = 0, num_words: int = 1) -> list: + def get_accepting_words_bfs(self, min_word_length: int = 0, num_words: int = 1) -> list[tuple]: """ Generate a list of random words that are accepted by the automaton using the breadth-first search approach. - Args: - - min_word_length (int): Minimum length of the generated words. - - amount_words (int): Number of words to generate. - - Returns: - - set: A set of randomly generated words that are accepted by the automaton. + :param int min_word_length: Minimum length of the generated words. + :param int num_words: Number of words to generate. + :return list[tuple]: A list of randomly generated words that are accepted by the automaton. """ allowed_call_trans = self.get_allowed_call_transitions() self.reset_to_initial() @@ -461,12 +523,9 @@ def get_random_accepting_word(self, return_letter_prob: float = 0.5, min_len: in Only internal letters and return letters will be chosen. If a return letter is randomly chosen a random stack guard will be selected. Then the stack needed stack configuration will be searched by using BFS - Args: - - return_letter_prob (float): Probability for selecting a letter from the return alphabet. - - min_len (int): Minimum length of the generated word. - - Returns: - - list: A randomly generated word that gets accepted by the automaton. + :param float return_letter_prob: Probability for selecting a letter from the return alphabet. + :param int min_len: Minimum length of the generated word. + :return list: A randomly generated word that gets accepted by the automaton. """ assert return_letter_prob <= 1.0 word = [] diff --git a/aalpy/automata/StochasticMealyMachine.py b/aalpy/automata/StochasticMealyMachine.py index ae033d96f41..8da34f2db38 100644 --- a/aalpy/automata/StochasticMealyMachine.py +++ b/aalpy/automata/StochasticMealyMachine.py @@ -1,6 +1,8 @@ +# Stochastic Mealy machine state and automaton implementation, and conversion to an equivalent MDP. import random from collections import defaultdict -from typing import Generic, Tuple, List, Dict +from collections.abc import Hashable +from typing import Generic from aalpy.automata import MdpState, Mdp from aalpy.base import Automaton, AutomatonState @@ -8,32 +10,48 @@ class StochasticMealyState(AutomatonState, Generic[InputType, OutputType]): + """ + Single state of a stochastic Mealy machine. Each transition is a tuple (newNode, output, probability). + """ + + def __init__(self, state_id: Hashable) -> None: + """ + Creates a stochastic Mealy machine state. - def __init__(self, state_id): + :param Hashable state_id: Unique identifier of the state. + """ super().__init__(state_id) # Each transition is a tuple (newNode, output, probability) - self.transitions: Dict[InputType, List[Tuple[StochasticMealyState, OutputType, float]]] = defaultdict(list) + self.transitions: dict[InputType, list[tuple[StochasticMealyState[InputType, OutputType], OutputType, float]]] = defaultdict(list) class StochasticMealyMachine(Automaton[StochasticMealyState[InputType, OutputType]]): + """ + Stochastic Mealy machine, where outputs and successor states depend on the input and a probability + distribution over the current state's transitions. + """ + + def __init__(self, initial_state: StochasticMealyState, states: list) -> None: + """ + Creates a stochastic Mealy machine. - def __init__(self, initial_state: StochasticMealyState, states: list): + :param StochasticMealyState initial_state: Initial state of the stochastic Mealy machine. + :param list states: All states of the stochastic Mealy machine. + """ super().__init__(initial_state, states) - def reset_to_initial(self): + def reset_to_initial(self) -> None: + """ + Resets the current state of the stochastic Mealy machine to the initial state. + """ self.current_state = self.initial_state - def step(self, letter): + def step(self, letter: InputType) -> OutputType: """ Next step is determined based on transition probabilities of the current state. - Args: - - letter: input - - Returns: - - output of the current state + :param InputType letter: Input. + :return OutputType: Output of the current state. """ prob = random.random() probability_distributions = [i[2] for i in self.current_state.transitions[letter]] @@ -48,18 +66,12 @@ def step(self, letter): self.current_state = transition[0] return transition[1] - def step_to(self, inp, out): + def step_to(self, inp: InputType, out: OutputType) -> OutputType | None: """Performs a step on the automaton based on the input `inp` and output `out`. - Args: - - inp: input - out: output - - Returns: - - output of the reached state, None otherwise - + :param InputType inp: Input. + :param OutputType out: Output. + :return OutputType | None: Output of the reached state, None otherwise. """ for (new_state, output, prob) in self.current_state.transitions[inp]: if output == out: @@ -67,10 +79,21 @@ def step_to(self, inp, out): return out return None - def to_mdp(self): + def to_mdp(self) -> Mdp: + """ + Converts the stochastic Mealy machine to an equivalent MDP. + + :return Mdp: The equivalent MDP. + """ return smm_to_mdp_conversion(self) - def to_state_setup(self): + def to_state_setup(self) -> dict: + """ + Converts the stochastic Mealy machine to a state setup dictionary. + + :return dict: Map from state_id to transitions_dict mapping input to a list of (target_state_id, output, + probability) tuples. + """ state_setup_dict = {} # ensure initial state is first in the list @@ -85,7 +108,15 @@ def to_state_setup(self): return state_setup_dict @staticmethod - def from_state_setup(state_setup : dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'StochasticMealyMachine': + """ + Creates a stochastic Mealy machine from a state setup dictionary. The first state in the state setup is + the initial state. + + :param dict state_setup: Map from state_id to transitions_dict mapping input to a list of + (target_state_id, output, probability) tuples. + :return StochasticMealyMachine: The constructed stochastic Mealy machine. + """ states_map = {key: StochasticMealyState(key) for key in state_setup.keys()} for key, values in state_setup.items(): @@ -98,17 +129,12 @@ def from_state_setup(state_setup : dict, **kwargs): return StochasticMealyMachine(initial_state, list(states_map.values())) -def smm_to_mdp_conversion(smm: StochasticMealyMachine): +def smm_to_mdp_conversion(smm: StochasticMealyMachine) -> Mdp: """ Convert SMM to MDP. - Args: - smm: StochasticMealyMachine: SMM to convert - - Returns: - - equivalent MDP - + :param StochasticMealyMachine smm: SMM to convert. + :return Mdp: Equivalent MDP. """ inputs = smm.get_input_alphabet() mdp_states = [] diff --git a/aalpy/automata/Vpa.py b/aalpy/automata/Vpa.py index ca64bb7829a..0168165289b 100644 --- a/aalpy/automata/Vpa.py +++ b/aalpy/automata/Vpa.py @@ -1,7 +1,9 @@ +# Visibly Pushdown Automaton (VPA) state and automaton implementation. import random from collections import defaultdict -from typing import List, Dict +from collections.abc import Hashable +from aalpy.automata import Dfa from aalpy.base import Automaton, AutomatonState @@ -10,26 +12,33 @@ class VpaAlphabet: The Alphabet of a VPA. Attributes: - internal_alphabet (List[str]): Letters for internal transitions. - call_alphabet (List[str]): Letters for push transitions. - return_alphabet (List[str]): Letters for pop transitions. - exclusive_call_return_pairs (Dict[str, str]): A dictionary representing exclusive pairs + internal_alphabet (list[str]): Letters for internal transitions. + call_alphabet (list[str]): Letters for push transitions. + return_alphabet (list[str]): Letters for pop transitions. + exclusive_call_return_pairs (dict[str, str]): A dictionary representing exclusive pairs of call and return symbols. """ - def __init__(self, internal_alphabet: List[str], call_alphabet: List[str], return_alphabet: List[str], - exclusive_call_return_pairs: Dict[str, str] = None): + def __init__(self, internal_alphabet: list[str], call_alphabet: list[str], return_alphabet: list[str], + exclusive_call_return_pairs: dict[str, str] | None = None) -> None: + """ + Creates a VPA alphabet. + + :param list[str] internal_alphabet: Letters for internal transitions. + :param list[str] call_alphabet: Letters for push transitions. + :param list[str] return_alphabet: Letters for pop transitions. + :param dict[str, str] | None exclusive_call_return_pairs: Exclusive pairs of call and return symbols. + """ self.internal_alphabet = internal_alphabet self.call_alphabet = call_alphabet self.return_alphabet = return_alphabet self.exclusive_call_return_pairs = exclusive_call_return_pairs - def get_merged_alphabet(self) -> List[str]: + def get_merged_alphabet(self) -> list[str]: """ Get the merged alphabet, including internal, call, and return symbols. - Returns: - List[str]: A list of all symbols in the alphabet. + :return list[str]: A list of all symbols in the alphabet. """ alphabet = list() alphabet.extend(self.internal_alphabet) @@ -39,8 +48,7 @@ def get_merged_alphabet(self) -> List[str]: def __str__(self) -> str: """ - Returns: - str: A string representation of the alphabet. + :return str: A string representation of the alphabet. """ return f'Internal: {self.internal_alphabet} Call: {self.call_alphabet} Return: {self.return_alphabet}' @@ -50,9 +58,15 @@ class VpaState(AutomatonState): Single state of a VPA. """ - def __init__(self, state_id, is_accepting=False): + def __init__(self, state_id: Hashable, is_accepting: bool = False) -> None: + """ + Creates a VPA state. + + :param Hashable state_id: Unique identifier of the state. + :param bool is_accepting: Whether the state is an accepting state. + """ super().__init__(state_id) - self.transitions = defaultdict(list) + self.transitions: dict[str, list[VpaTransition]] = defaultdict(list) self.is_accepting = is_accepting @@ -68,14 +82,27 @@ class VpaTransition: stack_guard: The stack symbol to be pushed/popped. """ - def __init__(self, start: VpaState, target: VpaState, symbol, action, stack_guard=None): + def __init__(self, start: VpaState, target: VpaState, symbol: str, action: str | None, + stack_guard: str | None = None) -> None: + """ + Creates a VPA transition. + + :param VpaState start: The starting state of the transition. + :param VpaState target: The target state of the transition. + :param str symbol: The symbol associated with the transition. + :param str | None action: The action performed during the transition (push | pop | None). + :param str | None stack_guard: The stack symbol to be pushed/popped. + """ self.start = start self.target_state = target self.letter = symbol self.action = action self.stack_guard = stack_guard - def __str__(self): + def __str__(self) -> str: + """ + :return str: A string representation of the transition. + """ return f"{self.letter}: {self.start.state_id} --> {self.target_state.state_id} | {self.action}: {self.stack_guard}" @@ -85,7 +112,13 @@ class Vpa(Automaton): """ error_state = VpaState("ErrorSinkState", False) - def __init__(self, initial_state: VpaState, states): + def __init__(self, initial_state: VpaState, states: list[VpaState]) -> None: + """ + Creates a VPA. + + :param VpaState initial_state: Initial state of the VPA. + :param list[VpaState] states: All states of the VPA. + """ super().__init__(initial_state, states) self.initial_state = initial_state self.states = states @@ -98,22 +131,25 @@ def __init__(self, initial_state: VpaState, states): self.call_set = set(self.input_alphabet.call_alphabet) self.return_set = set(self.input_alphabet.return_alphabet) - def reset_to_initial(self): + def reset_to_initial(self) -> None: + """ + Resets the current state and stack of the VPA to the initial configuration. + """ self.current_state = self.initial_state self.stack = [] - def top(self): + def top(self) -> str | list: + """ + :return str | list: The top of the stack, or an empty list if the stack is empty. + """ return self.stack[-1] if self.stack else [] - def step(self, letter): + def step(self, letter: str | None) -> bool: """ Perform a single step on the VPA by transitioning with the given input letter. - Args: - letter: A single input that is looked up in the transition table of the VpaState. - - Returns: - bool: True if the reached state is an accepting state and the stack is empty, False otherwise. + :param str | None letter: A single input that is looked up in the transition table of the VpaState. + :return bool: True if the reached state is an accepting state and the stack is empty, False otherwise. """ if self.current_state == Vpa.error_state: return False @@ -150,7 +186,12 @@ def step(self, letter): return self.current_state.is_accepting and self.stack == [] - def to_state_setup(self): + def to_state_setup(self) -> dict: + """ + Converts the VPA to a state setup dictionary. + + :return dict: Map from state_id to tuple(is_accepting, transitions_dict). + """ state_setup_dict = {} # ensure prefixes are computed @@ -164,6 +205,11 @@ def to_state_setup(self): return state_setup_dict def get_input_alphabet(self) -> VpaAlphabet: + """ + Computes the input alphabet of the VPA from its transitions. + + :return VpaAlphabet: The input alphabet. + """ int_alphabet, ret_alphabet, call_alphabet = [], [], [] for state in self.states: for transition_list in state.transitions.values(): @@ -181,13 +227,9 @@ def get_input_alphabet(self) -> VpaAlphabet: def is_input_complete(self) -> bool: """ - Check whether all states have defined transition for all inputs - :return: true if automaton is input complete - - Returns: - - True if input complete, False otherwise + Check whether all states have defined transition for all inputs. + :return bool: True if automaton is input complete, False otherwise. """ alphabet = set(self.get_input_alphabet().get_merged_alphabet()) for state in self.states: @@ -196,7 +238,7 @@ def is_input_complete(self) -> bool: return True @staticmethod - def from_state_setup(state_setup: dict, **kwargs): + def from_state_setup(state_setup: dict, **kwargs) -> 'Vpa': """ Create a VPA from a state setup. @@ -214,16 +256,12 @@ def from_state_setup(state_setup: dict, **kwargs): "]": [("q2", 'pop', "[")] }), - Args: - state_setup (dict): A dictionary mapping from state IDs to tuples containing - (is_accepting: bool, transitions_dict: dict), where transitions_dict maps input symbols to - lists of tuples (target_state_id, action, stack_guard). - init_state_id (str): The state ID for the initial state of the VPA. - input_alphabet (VpaAlphabet): The alphabet for the VPA. - - Returns: - Vpa: The constructed Variable Pushdown Automaton. - """ + :param dict state_setup: A dictionary mapping from state IDs to tuples containing + (is_accepting: bool, transitions_dict: dict), where transitions_dict maps input symbols to + lists of tuples (target_state_id, action, stack_guard). + :param init_state_id: The state ID for the initial state of the VPA, passed via kwargs. + :return Vpa: The constructed Visibly Pushdown Automaton. + """ # state_setup should map from state_id to tuple(is_accepting and transitions_dict) init_state_id = kwargs['init_state_id'] @@ -248,21 +286,24 @@ def from_state_setup(state_setup: dict, **kwargs): vpa = Vpa(init_state, states) return vpa - def is_balanced(self, seq): + def is_balanced(self, seq: list[str]) -> bool: + """ + Checks whether an input sequence has balanced call and return symbols with respect to the VPA's alphabet. + + :param list[str] seq: The input sequence to check. + :return bool: True if the sequence is balanced, False otherwise. + """ from aalpy.utils import is_balanced return is_balanced(seq, self.input_alphabet) - def generate_random_accepting_word(self, min_steps=4, max_steps=20): + def generate_random_accepting_word(self, min_steps: int = 4, max_steps: int = 20) -> list[str] | None: """ Generate a random valid sequence for a given VPDA. - Args: - - min_steps : Minimum number of steps - max_steps : Maximum number of steps before the process terminates - - Returns: - list: A list of input symbols (the generated sequence) leading to an accepting state. + :param int min_steps: Minimum number of steps. + :param int max_steps: Maximum number of steps before the process terminates. + :return list[str] | None: A list of input symbols (the generated sequence) leading to an accepting state, + or None if a sequence could not be generated. """ sequence = [] @@ -301,7 +342,15 @@ def generate_random_accepting_word(self, min_steps=4, max_steps=20): return None -def vpa_from_dfa_representation(dfa_repr, vpa_alphabet): +def vpa_from_dfa_representation(dfa_repr: Dfa, vpa_alphabet: VpaAlphabet) -> Vpa: + """ + Converts a DFA representation of a VPA (where call/return symbols may be encoded as tuples with the top of + stack) into an equivalent Vpa. + + :param Dfa dfa_repr: The DFA representation to convert. + :param VpaAlphabet vpa_alphabet: The alphabet of the resulting VPA. + :return Vpa: The constructed VPA. + """ vpa_states = dict() for dfa_state in dfa_repr.states: vpa_state = VpaState(state_id=dfa_state.state_id, is_accepting=dfa_state.is_accepting) diff --git a/aalpy/base/Automaton.py b/aalpy/base/Automaton.py index 279745c8b73..c84bd32417a 100644 --- a/aalpy/base/Automaton.py +++ b/aalpy/base/Automaton.py @@ -1,22 +1,25 @@ +# Abstract base classes shared by all automata types: states, deterministic/non-deterministic automata. import copy import warnings from abc import ABC, abstractmethod from collections import defaultdict -from typing import Union, TypeVar, Generic, List +from collections.abc import Hashable +from typing import TypeVar, Generic class AutomatonState(ABC): + """ + Abstract single state of an automaton. + """ - def __init__(self, state_id): + def __init__(self, state_id: Hashable) -> None: """ Single state of an automaton. Each state consists of a state id, a dictionary of transitions, where the keys are inputs and the values are the corresponding target states, and a prefix that leads to the state from the initial state. - Args: - - state_id(Any): used for graphical representation of the state. A good practice is to keep it unique. - + :param Hashable state_id: Used for graphical representation of the state. A good practice is to keep it + unique. """ self.state_id = state_id self.transitions = None @@ -25,6 +28,8 @@ def __init__(self, state_id): def get_diff_state_transitions(self) -> list: """ Returns a list of transitions that lead to new states, not same-state transitions. + + :return list: Transitions that lead to a different state. """ transitions = [] for trans, state in self.transitions.items(): @@ -35,6 +40,8 @@ def get_diff_state_transitions(self) -> list: def get_same_state_transitions(self) -> list: """ Get all transitions that lead to the same state (self loops). + + :return list: Transitions that lead back to this state. """ dst = self.get_diff_state_transitions() all_trans = set(self.transitions.keys()) @@ -52,26 +59,28 @@ class Automaton(ABC, Generic[AutomatonStateType]): Abstract class representing an automaton. """ - def __init__(self, initial_state: AutomatonStateType, states: List[AutomatonStateType]): + def __init__(self, initial_state: AutomatonStateType, states: list[AutomatonStateType]) -> None: """ - Args: - - initial_state (AutomatonState): initial state of the automaton - states (list) : list containing all states of the automaton + Creates an automaton. + :param AutomatonState initial_state: Initial state of the automaton. + :param list[AutomatonStateType] states: List containing all states of the automaton. """ self.initial_state: AutomatonStateType = initial_state - self.states: List[AutomatonStateType] = states + self.states: list[AutomatonStateType] = states self.characterization_set: list = [] self.current_state: AutomatonStateType = initial_state @property - def size(self): + def size(self) -> int: + """ + :return int: Number of states in the automaton. + """ return len(self.states) - def reset_to_initial(self): + def reset_to_initial(self) -> None: """ - Resets the current state of the automaton to the initial state + Resets the current state of the automaton to the initial state. """ self.current_state = self.initial_state @@ -80,26 +89,16 @@ def step(self, letter): """ Performs a single step on the automaton changing its current state. - Args: - - letter: element of the input alphabet to be executed on the system under learning - - Returns: - - Output produced when executing the input letter from the current state - + :param letter: Element of the input alphabet to be executed on the system under learning. + :return: Output produced when executing the input letter from the current state. """ pass def is_input_complete(self) -> bool: """ - Check whether all states have defined transition for all inputs - :return: true if automaton is input complete - - Returns: - - True if input complete, False otherwise + Check whether all states have defined transition for all inputs. + :return bool: True if input complete, False otherwise. """ alphabet = set(self.get_input_alphabet()) for state in self.states: @@ -107,10 +106,11 @@ def is_input_complete(self) -> bool: return False return True - # returns a list which is input alphabet, or a sevpa alphabet in case of VPAs - def get_input_alphabet(self): + def get_input_alphabet(self) -> list: """ - Returns the input alphabet + Returns the input alphabet. + + :return list: The input alphabet. """ alphabet = list() for s in self.states: @@ -119,81 +119,125 @@ def get_input_alphabet(self): alphabet.append(i) return list(alphabet) - def get_state_by_id(self, state_id) -> Union[AutomatonStateType, None]: + def get_state_by_id(self, state_id: Hashable) -> AutomatonStateType | None: + """ + Looks up a state by its state_id. + + :param Hashable state_id: Identifier of the state to look up. + :return AutomatonStateType | None: The state with the given id, or None if not found. + """ for state in self.states: if state.state_id == state_id: return state return None - def __str__(self): + def __str__(self) -> str: """ - :return: A string representation of the automaton + :return str: A string representation of the automaton. """ from aalpy.utils import save_automaton_to_file return save_automaton_to_file(self, path='learnedModel', file_type='string', round_floats=2) - def make_input_complete(self, missing_transition_go_to='self_loop'): + def make_input_complete(self, missing_transition_go_to: str = 'self_loop') -> None: """ - For more details check the implementation of this method in utils.HelperFunctions + For more details check the implementation of this method in utils.HelperFunctions. - missing_transition_go_to: either 'self_loop' or 'sink_state'. + :param str missing_transition_go_to: Either 'self_loop' or 'sink_state'. """ from aalpy.utils.HelperFunctions import make_input_complete make_input_complete(self, missing_transition_go_to) - def execute_sequence(self, origin_state, seq): + def execute_sequence(self, origin_state: AutomatonStateType, seq: list) -> list: """ - Note that execute sequance changes the state! + Executes an input sequence on the automaton starting from a given state. + Note that execute sequence CHANGES the state! + + :param AutomatonStateType origin_state: State from which the sequence execution starts. + :param list seq: Input sequence to execute. + :return list: The output response for the executed sequence. """ self.current_state = origin_state return [self.step(s) for s in seq] - def save(self, file_path='LearnedModel', file_type='dot'): + def save(self, file_path: str = 'LearnedModel', file_type: str = 'dot') -> None: + """ + Saves the automaton to a file. + + :param str file_path: Path (without extension) where the automaton shall be saved. + :param str file_type: Format to save the automaton in, e.g. 'dot'. + """ from aalpy.utils import save_automaton_to_file save_automaton_to_file(self, path=file_path, file_type=file_type) - def visualize(self, path='LearnedModel', file_type='pdf', display_same_state_transitions=True): + def visualize(self, path: str = 'LearnedModel', file_type: str = 'pdf', + display_same_state_transitions: bool = True) -> None: + """ + Visualizes the automaton. + + :param str path: Path (without extension) where the visualization shall be saved. + :param str file_type: Format to render the visualization in, e.g. 'pdf'. + :param bool display_same_state_transitions: Whether self-loop transitions should be displayed. + """ from aalpy.utils import visualize_automaton visualize_automaton(self, path, file_type, display_same_state_transitions) @staticmethod @abstractmethod def from_state_setup(state_setup: dict, **kwargs) -> 'Automaton': + """ + Creates an automaton from a state setup dictionary. + + :param dict state_setup: Map from state_id to state configuration. + :return Automaton: The constructed automaton. + """ pass @abstractmethod def to_state_setup(self): + """ + Converts the automaton to a state setup dictionary. + + :return dict: Map from state_id to state configuration. + """ pass def copy(self) -> 'Automaton': + """ + :return Automaton: A deep copy of the automaton, built via its state setup. + """ return self.from_state_setup(self.to_state_setup()) - def __reduce__(self): + def __reduce__(self) -> tuple: + """ + :return tuple: Callable and arguments used to reconstruct the automaton, for pickling. + """ return self.from_state_setup, (self.to_state_setup(),) class DeterministicAutomaton(Automaton[AutomatonStateType]): + """ + Abstract class representing a deterministic automaton. + """ @abstractmethod def step(self, letter): - pass - - def get_shortest_path(self, origin_state: AutomatonStateType, target_state: AutomatonStateType) -> Union[ - tuple, None]: """ - Breath First Search over the automaton to find the shortest path - - Args: - - origin_state (AutomatonState): state from which the BFS will start - target_state (AutomatonState): state that will be reached with the return value + Performs a single step on the automaton changing its current state. - Returns: + :param letter: Element of the input alphabet to be executed on the system under learning. + :return: Output produced when executing the input letter from the current state. + """ + pass - sequence of inputs that lead from origin_state to target state, or None if target state is not reachable - from origin state + def get_shortest_path(self, origin_state: AutomatonStateType, target_state: AutomatonStateType) -> tuple | None: + """ + Breath First Search over the automaton to find the shortest path. + :param AutomatonStateType origin_state: State from which the BFS will start. + :param AutomatonStateType target_state: State that will be reached with the return value. + :return tuple | None: Sequence of inputs that lead from origin_state to target state, or None if target + state is not reachable from origin state. """ if origin_state not in self.states or target_state not in self.states: warnings.warn('Origin or target state not in automaton. Returning None.') @@ -233,11 +277,8 @@ def is_strongly_connected(self) -> bool: Check whether the automaton is strongly connected, meaning that every state can be reached from every other state. - Returns: - - True if strongly connected, False otherwise - - """ + :return bool: True if strongly connected, False otherwise. + """ if not self.states: return True @@ -272,15 +313,13 @@ def is_strongly_connected(self) -> bool: return visited == all_states - def output_step(self, state, letter): + def output_step(self, state: AutomatonStateType, letter): """ - Given an input letter, compute the output response from a given state. - Args: - state: state from which the output response shall be computed - letter: an input letter from the alphabet - - Returns: the single-step output response + Given an input letter, compute the output response from a given state. + :param AutomatonStateType state: State from which the output response shall be computed. + :param letter: An input letter from the alphabet. + :return: The single-step output response. """ state_save = self.current_state self.current_state = state @@ -288,18 +327,16 @@ def output_step(self, state, letter): self.current_state = state_save return output - def find_distinguishing_seq(self, state1, state2, alphabet): + def find_distinguishing_seq(self, state1: AutomatonStateType, state2: AutomatonStateType, alphabet: list) -> list | None: """ A BFS to determine an input sequence that distinguishes two states in the automaton, i.e., a sequence such that the output response from the given states is different. In a minimal automaton, this function always returns a - sequence different from None - Args: - state1: first state - state2: second state to distinguish - alphabet: input alphabet of the automaton - - Returns: an input sequence distinguishing two states, or None if the states are equivalent + sequence different from None. + :param AutomatonStateType state1: First state. + :param AutomatonStateType state2: Second state to distinguish. + :param list alphabet: Input alphabet of the automaton. + :return list | None: An input sequence distinguishing two states, or None if the states are equivalent. """ visited = set() to_explore = [(state1, state2, [])] @@ -320,32 +357,35 @@ def find_distinguishing_seq(self, state1, state2, alphabet): return None - def compute_output_seq(self, state, sequence): + def compute_output_seq(self, state: AutomatonStateType, sequence: list) -> list: """ Given an input sequence, compute the output response from a given state. - Args: - state: state from which the output response shall be computed - sequence: an input sequence over the alphabet - - Returns: the output response + :param AutomatonStateType state: State from which the output response shall be computed. + :param list sequence: An input sequence over the alphabet. + :return list: The output response. """ state_save = self.current_state output = self.execute_sequence(state, sequence) self.current_state = state_save return output - def is_minimal(self): + def is_minimal(self) -> bool: + """ + Checks whether the automaton is minimal, i.e., whether a characterization set can be computed for it. + + :return bool: True if the automaton is minimal, False otherwise. + """ if not self.is_input_complete(): warnings.warn('Minimization of non input complete automata is not yet supported. Returning False.') return False return self.compute_characterization_set(raise_warning=False) is not None - def compute_characterization_set(self, char_set_init=None, - online_suffix_closure=True, - split_all_blocks=True, - return_same_states=False, - raise_warning=True): + def compute_characterization_set(self, char_set_init: list | None = None, + online_suffix_closure: bool = True, + split_all_blocks: bool = True, + return_same_states: bool = False, + raise_warning: bool = True) -> list | tuple | None: """ Computation of a characterization set, that is, a set of sequences that can distinguish all states in the automation. The implementation follows the approach for finding multiple preset diagnosing experiments described @@ -353,22 +393,21 @@ def compute_characterization_set(self, char_set_init=None, Some optional parameterized adaptations, e.g., for computing suffix-closed sets target the application in L*-based learning and conformance testing. The function only works for minimal automata. - Args: - char_set_init: a list of sequence that will be included in the characterization set, e.g., the input - alphabet. A empty sequance is added to this list when using automata with state labels - (DFA and Moore) - online_suffix_closure: if true, ensures suffix closedness of the characterization set at every computation - step - split_all_blocks: if false, the computation follows the original tree-based strategy, where newly computed - sequences are only checked on a subset of the states to be distinguished - if true, sequences are used to distinguish all states, yielding a potentially smaller set, which - is useful for conformance testing and learning - return_same_states: if True, a single distinguishable pair of states will be returned, or None None if there - are no non-distinguishable states - raise_warning: prints warning message if characterization set cannot be computed - - Returns: a characterization set or None if a non-minimal automaton is passed to the function + :param list | None char_set_init: A list of sequence that will be included in the characterization set, e.g., + the input alphabet. An empty sequence is added to this list when using automata with state labels + (DFA and Moore). + :param bool online_suffix_closure: If true, ensures suffix closedness of the characterization set at every + computation step. + :param bool split_all_blocks: If false, the computation follows the original tree-based strategy, where + newly computed sequences are only checked on a subset of the states to be distinguished. If true, + sequences are used to distinguish all states, yielding a potentially smaller set, which is useful for + conformance testing and learning. + :param bool return_same_states: If True, a single distinguishable pair of states will be returned, or + None, None if there are no non-distinguishable states. + :param bool raise_warning: Prints warning message if characterization set cannot be computed. + :return list | tuple | None: A characterization set, a pair of non-distinguishable states (if + return_same_states is True), or None if a non-minimal automaton is passed to the function. """ blocks = list() blocks.append(copy.copy(self.states)) @@ -436,15 +475,13 @@ def compute_characterization_set(self, char_set_init=None, return None, None return char_set - def _split_blocks(self, blocks, seq): + def _split_blocks(self, blocks: list, seq: tuple) -> list: """ Refines a partition of states (blocks) using the output response to a given input sequence seq. - Args: - blocks: a partition of states - seq: an input sequence - - Returns: a refined partition of states + :param list blocks: A partition of states. + :param tuple seq: An input sequence. + :return list: A refined partition of states. """ new_blocks = [] for block in blocks: @@ -456,12 +493,19 @@ def _split_blocks(self, blocks, seq): new_blocks.append(new_block) return new_blocks - def compute_prefixes(self): + def compute_prefixes(self) -> None: + """ + Computes and assigns the shortest access sequence (prefix) from the initial state for every state that + does not already have one. + """ for s in self.states: if not s.prefix: s.prefix = self.get_shortest_path(self.initial_state, s) - def minimize(self): + def minimize(self) -> None: + """ + Minimizes the automaton in place by merging non-distinguishable states. + """ if not self.is_input_complete(): warnings.warn('Minimization of non input complete automata is not yet supported.\n Model not minimized.') return @@ -477,6 +521,10 @@ def minimize(self): self.compute_prefixes() - def __eq__(self, other): + def __eq__(self, other: 'Automaton') -> bool: + """ + :param Automaton other: Automaton to compare against. + :return bool: True if this automaton and other are bisimilar, False otherwise. + """ from aalpy.utils import bisimilar return bisimilar(self, other) diff --git a/aalpy/base/CacheTree.py b/aalpy/base/CacheTree.py index c733318c183..a728ce3ac7c 100644 --- a/aalpy/base/CacheTree.py +++ b/aalpy/base/CacheTree.py @@ -1,9 +1,21 @@ -class Node(object): +# Cache structures storing membership queries and their outputs, used to avoid redundant queries to the SUL. +from typing import Any + + +class Node: + """ + Single node of a CacheTree. + """ __slots__ = ['value', 'children'] - def __init__(self, value=None): + def __init__(self, value: Any = None) -> None: + """ + Creates a cache tree node. + + :param Any value: Output value associated with the node. + """ self.value = value - self.children = {} + self.children: dict[Any, 'Node'] = {} class CacheTree: @@ -15,26 +27,30 @@ class CacheTree: child. """ - def __init__(self): + def __init__(self) -> None: + """ + Creates an empty cache tree. + """ self.root_node = Node() - self.curr_node = None - self.inputs = () - self.outputs = () + self.curr_node: Node | None = None + self.inputs: tuple = () + self.outputs: tuple = () - def reset(self): + def reset(self) -> None: + """ + Resets the current node and recorded inputs/outputs to the root of the cache tree. + """ self.curr_node = self.root_node self.inputs = () self.outputs = () - def step_in_cache(self, inp, out): + def step_in_cache(self, inp: Any, out: Any) -> None: """ Preform a step in the cache. If output exist for the current state, and is not the same as `out`, throw the non-determinism violation error and abort learning. - Args: - - inp: input - out: output + :param Any inp: Input. + :param Any out: Output. """ self.inputs += (inp,) self.outputs += (out,) @@ -58,19 +74,13 @@ def step_in_cache(self, inp, out): raise SystemExit(msg) self.curr_node = node - def in_cache(self, input_seq: tuple): + def in_cache(self, input_seq: tuple) -> tuple | None: """ Check if the result of the membership query for input_seq is cached is in the tree. If it is, return the corresponding output sequence. - Args: - - input_seq: corresponds to the membership query - - Returns: - - outputs associated with inputs if it is in the query, None otherwise - + :param tuple input_seq: Corresponds to the membership query. + :return tuple | None: Outputs associated with inputs if it is in the query, None otherwise. """ curr_node = self.root_node @@ -84,9 +94,12 @@ def in_cache(self, input_seq: tuple): return output_seq - def add_to_cache(self, input_sequence, output_sequence): + def add_to_cache(self, input_sequence: tuple, output_sequence: tuple) -> None: """ - Add input-output sequence to cache + Add input-output sequence to cache. + + :param tuple input_sequence: Sequence of inputs. + :param tuple output_sequence: Sequence of outputs corresponding to the inputs. """ self.reset() for i, o in zip(input_sequence, output_sequence): @@ -102,23 +115,28 @@ class CacheDict: child. """ - def __init__(self): - self.cache_dict = dict() - self.inputs = () + def __init__(self) -> None: + """ + Creates an empty cache dictionary. + """ + self.cache_dict: dict[tuple, Any] = dict() + self.inputs: tuple = () - def reset(self): + def reset(self) -> None: + """ + Resets the recorded inputs. + """ self.inputs = () pass - def step_in_cache(self, inp, out): + def step_in_cache(self, inp: Any, out: Any) -> Any | None: """ Preform a step in the cache. If output exist for the current state, and is not the same as `out`, throw the non-determinism violation error and abort learning. - Args: - - inp: input - out: output + :param Any inp: Input. + :param Any out: Output. + :return Any | None: The cached output for the empty input sequence if inp is None, otherwise None. """ if inp is None: @@ -140,30 +158,33 @@ def step_in_cache(self, inp, out): f'Received output: {received_seq}' raise SystemExit(msg) - def in_cache(self, input_seq: tuple): + def in_cache(self, input_seq: tuple) -> tuple | None: """ Check if the result of the membership query for input_seq is cached is in the tree. If it is, return the corresponding output sequence. - Args: - - input_seq: corresponds to the membership query - - Returns: - - outputs associated with inputs if it is in the query, None otherwise - + :param tuple input_seq: Corresponds to the membership query. + :return tuple | None: Outputs associated with inputs if it is in the query, None otherwise. """ if input_seq in self.cache_dict.keys(): return self.get_output_sequence(input_seq) return None - def add_to_cache(self, input_sequence, output_sequence): + def add_to_cache(self, input_sequence: tuple, output_sequence: tuple) -> None: """ - Add input-output sequence to cache + Add input-output sequence to cache. + + :param tuple input_sequence: Sequence of inputs. + :param tuple output_sequence: Sequence of outputs corresponding to the inputs. """ for i in range(1, len(input_sequence) + 1): self.cache_dict[input_sequence[:i]] = output_sequence[i-1] - def get_output_sequence(self, input_seq): + def get_output_sequence(self, input_seq: tuple) -> tuple: + """ + Reconstructs the output sequence for a cached input sequence. + + :param tuple input_seq: Input sequence whose outputs shall be retrieved. + :return tuple: The output sequence corresponding to input_seq. + """ return tuple(self.cache_dict[input_seq[:i]] for i in range(1, len(input_seq) + 1)) diff --git a/aalpy/base/Oracle.py b/aalpy/base/Oracle.py index 225118299a8..84567e06441 100644 --- a/aalpy/base/Oracle.py +++ b/aalpy/base/Oracle.py @@ -1,19 +1,19 @@ +# Abstract base class for all equivalence oracles. from abc import ABC, abstractmethod from aalpy.base import SUL +from aalpy.base.Automaton import Automaton, InputType class Oracle(ABC): """Abstract class implemented by all equivalence oracles.""" - def __init__(self, alphabet: list, sul: SUL): + def __init__(self, alphabet: list, sul: SUL) -> None: """ Default constructor for all equivalence oracles. - Args: - - alphabet: input alphabet - sul: system under learning + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. """ self.alphabet = alphabet @@ -22,30 +22,22 @@ def __init__(self, alphabet: list, sul: SUL): self.num_steps = 0 @abstractmethod - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple[InputType, ...] | None: """ Return a counterexample (inputs) that displays different behavior on system under learning and current hypothesis. - Args: - - hypothesis: current hypothesis - - Returns: - - tuple or list containing counterexample inputs, None if no counterexample is found + :param Automaton hypothesis: Current hypothesis. + :return tuple[InputType, ...] | None: Counterexample inputs, None if no counterexample is found. """ pass - def reset_hyp_and_sul(self, hypothesis): + def reset_hyp_and_sul(self, hypothesis: Automaton) -> None: """ Reset SUL and hypothesis to initial state. - Args: - - hypothesis: current hypothesis - + :param Automaton hypothesis: Current hypothesis. """ hypothesis.reset_to_initial() self.sul.pre() - self.num_queries += 1 \ No newline at end of file + self.num_queries += 1 diff --git a/aalpy/base/SUL.py b/aalpy/base/SUL.py index 7d85a4a48ee..dd1e991900f 100644 --- a/aalpy/base/SUL.py +++ b/aalpy/base/SUL.py @@ -1,4 +1,6 @@ +# Abstract base class for systems under learning (SULs), plus a caching SUL decorator. from abc import ABC, abstractmethod +from typing import Any from aalpy.base.CacheTree import CacheTree, CacheDict @@ -10,7 +12,10 @@ class SUL(ABC): passed to the learning algorithm and the equivalence oracle. """ - def __init__(self): + def __init__(self) -> None: + """ + Creates a SUL with zeroed query/step counters. + """ self.num_queries = 0 self.num_steps = 0 self.num_cached_queries = 0 @@ -20,14 +25,9 @@ def query(self, word: tuple) -> list: Performs a membership query on the SUL. Before the query, pre() method is called and after the query post() method is called. Each letter in the word (input in the input sequence) is executed using the step method. - Args: - - word: membership query (word consisting of letters/inputs) - - Returns: - - list of outputs, where the i-th output corresponds to the output of the system after the i-th input - + :param tuple word: Membership query (word consisting of letters/inputs). + :return list: List of outputs, where the i-th output corresponds to the output of the system after the + i-th input. """ self.pre() # Empty string for DFA @@ -40,24 +40,24 @@ def query(self, word: tuple) -> list: self.num_steps += len(word) return out - def io_query(self, word : tuple): - return list(zip(word, self.query(word))) + def io_query(self, word: tuple) -> list[tuple]: + """ + Performs a membership query and pairs each input with its corresponding output. - def adaptive_query(self, word, ads): + :param tuple word: Membership query (word consisting of letters/inputs). + :return list[tuple]: List of (input, output) pairs. """ + return list(zip(word, self.query(word))) + def adaptive_query(self, word: list, ads: Any) -> tuple[list, list]: + """ Performs an adaptive output query on the SUL. Before the query, pre() method is called and after the query post() method is called. The ADS is a tree like object, the next input depends on the previous input-output pairs. Each input is executed using the step method. Currently only implemented for Mealy machines - Args: - - word: membership query (word consisting of letters/inputs) - - ads: adaptive distinguishing suffix - - Returns: - - list of outputs, where the i-th output corresponds to the output of the system after the i-th input + :param list word: Membership query (word consisting of letters/inputs). + :param Any ads: Adaptive distinguishing suffix. + :return tuple[list, list]: The (possibly extended) word and the list of outputs received, where the i-th + output corresponds to the output of the system after the i-th input. """ self.pre() @@ -81,7 +81,7 @@ def adaptive_query(self, word, ads): last_output = self.step(None) else: word.append(next_input) - output = self.step(next_input) + output = self.step(next_input) outputs_received.append(output) last_output = output self.num_steps += 1 @@ -92,32 +92,26 @@ def adaptive_query(self, word, ads): return word, outputs_received @abstractmethod - def pre(self): + def pre(self) -> None: """ Resets the system. Called after post method in the equivalence query. """ pass @abstractmethod - def post(self): + def post(self) -> None: """ Performs additional cleanup on the system in necessary. Called before pre method in the equivalence query. """ pass @abstractmethod - def step(self, letter): + def step(self, letter: Any) -> Any: """ Executes an action on the system under learning and returns its result. - Args: - - letter: Single input that is executed on the SUL. - - Returns: - - Output received after executing the input. - + :param Any letter: Single input that is executed on the SUL. + :return Any: Output received after executing the input. """ pass @@ -128,25 +122,26 @@ class CacheSUL(SUL): This multiset/cache is encoded as a tree. """ - def __init__(self, sul: SUL, cache_type='tree'): + def __init__(self, sul: SUL, cache_type: str = 'tree') -> None: + """ + Creates a caching wrapper around a SUL. + + :param SUL sul: The wrapped system under learning. + :param str cache_type: Either 'tree' for a CacheTree or any other value for a CacheDict. + """ super().__init__() self.sul = sul self.cache = CacheTree() if cache_type == 'tree' else CacheDict() - def query(self, word): + def query(self, word: tuple) -> list: """ Performs a membership query on the SUL if and only if `word` is not a prefix of any trace in the cache. Before the query, pre() method is called and after the query post() method is called. Each letter in the word (input in the input sequence) is executed using the step method. - Args: - - word: membership query (word consisting of letters/inputs) - - Returns: - - list of outputs, where the i-th output corresponds to the output of the system after the i-th input - + :param tuple word: Membership query (word consisting of letters/inputs). + :return list: List of outputs, where the i-th output corresponds to the output of the system after the + i-th input. """ cached_query = self.cache.in_cache(word) if cached_query: @@ -165,28 +160,25 @@ def query(self, word): self.num_steps += len(word) return out - def pre(self): + def pre(self) -> None: """ Reset the system under learning and current node in the cache tree. """ self.cache.reset() self.sul.pre() - def post(self): + def post(self) -> None: + """ + Performs cleanup on the wrapped system under learning. + """ self.sul.post() - def step(self, letter): + def step(self, letter: Any) -> Any: """ Executes an action on the system under learning, adds it to the cache and returns its result. - Args: - - letter: Single input that is executed on the SUL. - - Returns: - - Output received after executing the input. - + :param Any letter: Single input that is executed on the SUL. + :return Any: Output received after executing the input. """ out = self.sul.step(letter) self.cache.step_in_cache(letter, out) diff --git a/aalpy/learning_algs/adaptive/AdaptiveLSharp.py b/aalpy/learning_algs/adaptive/AdaptiveLSharp.py index 4f7b4307960..c0c9e0faeb5 100644 --- a/aalpy/learning_algs/adaptive/AdaptiveLSharp.py +++ b/aalpy/learning_algs/adaptive/AdaptiveLSharp.py @@ -1,65 +1,52 @@ +# Top-level entry point for the Adaptive L# active automata learning algorithm. import time -from aalpy.base import Oracle, SUL +from aalpy.base import Automaton, Oracle, SUL from aalpy.utils.HelperFunctions import print_learning_info from .AdaptiveObservationTree import AdaptiveObservationTree from ...base.SUL import CacheSUL -def run_adaptive_Lsharp(alphabet: list, sul: SUL, references: list, eq_oracle: Oracle, automaton_type, - extension_rule=None, separation_rule="SepSeq", - rebuilding=True, state_matching="Approximate", - samples=None, max_learning_rounds=None, - cache_and_non_det_check=True, return_data=False, print_level=2): +def run_adaptive_Lsharp(alphabet: list, sul: SUL, references: list, eq_oracle: Oracle, automaton_type: str, + extension_rule: str | None = None, separation_rule: str = "SepSeq", + rebuilding: bool = True, state_matching: str | None = "Approximate", + samples: list | None = None, max_learning_rounds: int | None = None, + cache_and_non_det_check: bool = True, return_data: bool = False, + print_level: int = 2) -> Automaton | tuple[Automaton, dict]: """ Based on ''State Matching and Multiple References in Adaptive Active Automata Learning'' from Kruger, Junges and Rot. - The algorithm learns a Mealy machine using a set of references. These references are used by two procedures + The algorithm learns a Mealy machine using a set of references. These references are used by two procedures 1) Rebuilding which kickstarts the learning process using the references and 2) State Matching which matches the basis states and references states to find new basis states faster. - Args: - - alphabet: input alphabet - - sul: system under learning - - references: a list of references - - eq_oracle: equivalence oracle - - automaton_type: type of automaton to be learned. Either 'dfa', 'mealy' or 'moore' - - extension_rule: strategy used during the extension rule. Options: "Nothing" (default), "SepSeq" and "ADS". - - separation_rule: strategy used during the extension rule. Options: "SepSeq" (default) and "ADS". - - rebuilding: procedure that poses output queries to rebuild the observation tree based on prefixes and separating sequences from the reference(s). - Only executes at the start of adaptive L#. default value: True. - - state_matching: if not None, the learner maintains a matching relation between basis states (in the observation tree) and reference model states. - This matching relation is used in three rules added on top of L# to either identify a frontier state faster or isolate it when the matching indicates - that the frontier state corresponds to a reference model state not yet present in the basis. default value: "Approximate". - - Two states match according to "total matching" if all output over the defined and shared alphabet are exactly the same. - - Two states match according to "approximate matching" if they have the highest ratio of equivalent outputs to defined outputs over the shared alphabet. + :param list alphabet: input alphabet + :param SUL sul: system under learning + :param list references: a list of references + :param Oracle eq_oracle: equivalence oracle + :param str automaton_type: type of automaton to be learned. Either 'dfa', 'mealy' or 'moore' + :param str | None extension_rule: strategy used during the extension rule. Options: "Nothing" (default), "SepSeq" and "ADS". + :param str separation_rule: strategy used during the extension rule. Options: "SepSeq" (default) and "ADS". + :param bool rebuilding: procedure that poses output queries to rebuild the observation tree based on prefixes and + separating sequences from the reference(s). Only executes at the start of adaptive L#. default value: True. + :param str | None state_matching: if not None, the learner maintains a matching relation between basis states (in + the observation tree) and reference model states. This matching relation is used in three rules added on top + of L# to either identify a frontier state faster or isolate it when the matching indicates that the frontier + state corresponds to a reference model state not yet present in the basis. default value: "Approximate". + - Two states match according to "total matching" if all output over the defined and shared alphabet are + exactly the same. + - Two states match according to "approximate matching" if they have the highest ratio of equivalent outputs + to defined outputs over the shared alphabet. - None can be used if only the rebuilding procedure is needed. - - samples: input output traces provided to the learning algorithm. They are added to cache and could reduce - total interaction with the system. Syntax: list of [(input_sequence, output_sequence)] or None - - max_learning_rounds: number of learning rounds after which learning will terminate (Default value = None) - - cache_and_non_det_check: Use caching and non-determinism checks (Default value = True) - - return_data: if True, a map containing all information(runtime/#queries/#steps) will be returned - (Default value = False) - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - Returns: - - automaton of type automaton_type (dict containing all information about learning if 'return_data' is True) - + :param list | None samples: input output traces provided to the learning algorithm. They are added to cache and + could reduce total interaction with the system. Syntax: list of [(input_sequence, output_sequence)] or None + :param int | None max_learning_rounds: number of learning rounds after which learning will terminate + (Default value = None) + :param bool cache_and_non_det_check: Use caching and non-determinism checks (Default value = True) + :param bool return_data: if True, a map containing all information(runtime/#queries/#steps) will be returned + (Default value = False) + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug + (Default value = 2) + :return: automaton of type automaton_type (dict containing all information about learning if 'return_data' is True) """ assert extension_rule in {None, "SepSeq", "ADS"} assert separation_rule in {"SepSeq", "ADS"} diff --git a/aalpy/learning_algs/adaptive/AdaptiveObservationTree.py b/aalpy/learning_algs/adaptive/AdaptiveObservationTree.py index 94dc206e0c1..c71e626682a 100644 --- a/aalpy/learning_algs/adaptive/AdaptiveObservationTree.py +++ b/aalpy/learning_algs/adaptive/AdaptiveObservationTree.py @@ -1,3 +1,6 @@ +# Observation tree implementation for Adaptive L#, extending L#'s tree with rebuilding and state matching support. +from typing import Any + from aalpy.automata import MealyMachine, MealyState from aalpy.learning_algs.adaptive.StateMatching import TotalStateMatching, ApproximateStateMatching from aalpy.learning_algs.deterministic.Apartness import Apartness @@ -8,12 +11,23 @@ class AdaptiveObservationTree(ObservationTree): - def __init__(self, alphabet, sul, references, automaton_type, extension_rule, separation_rule, rebuilding=True, state_matching="Approximate"): + def __init__(self, alphabet: list, sul: SUL, references: list, automaton_type: str, + extension_rule: str | None, separation_rule: str, rebuilding: bool = True, + state_matching: str | None = "Approximate") -> None: """ Initialize the tree with a root node and the alphabet A temporary new basis is needed for the prioritized promotion rule The rebuild states counter counts the number of states found with rebuilding excluding the root The matching states counter counts the number of states found with match refinement and match separation (NOT prioritized separation) + + :param list alphabet: input alphabet + :param SUL sul: system under learning + :param list references: a list of reference models + :param str automaton_type: type of automaton to be learned. Either 'dfa', 'mealy' or 'moore' + :param str | None extension_rule: strategy used during the extension rule + :param str separation_rule: strategy used during the separation rule + :param bool rebuilding: whether to rebuild the observation tree from the references + :param str | None state_matching: state matching strategy, either None, "Total" or "Approximate" """ super().__init__(alphabet, sul, automaton_type, extension_rule, separation_rule) self.references = references @@ -50,10 +64,12 @@ def __init__(self, alphabet, sul, references, automaton_type, extension_rule, se self.state_matcher.initialize_matching(self) - def build_hypothesis(self): + def build_hypothesis(self) -> Automaton: """ Builds the hypothesis which will be sent to the SUL and checks consistency This is either done with or without matching rules + + :return Automaton: the constructed hypothesis """ while True: if self.state_matching: @@ -69,9 +85,9 @@ def build_hypothesis(self): cex_outputs = self.get_observation(counter_example) self.process_counter_example(hypothesis, counter_example, cex_outputs) - def make_observation_tree_adequate_matching(self): + def make_observation_tree_adequate_matching(self) -> None: """ - Updates the frontier and basis based on several rules + Updates the frontier and basis based on several rules Terminates when the observation tree is adequate and no progress has been made in one round The separation rule is only used when prioritized separation did not make progress The matching rules are only used when the observation tree is already adequate @@ -97,7 +113,7 @@ def make_observation_tree_adequate_matching(self): if old_basis < len(self.basis): self.matching_states += len(self.basis) - old_basis - def make_frontiers_identified_with_matching(self): + def make_frontiers_identified_with_matching(self) -> None: """ Loop over all frontier states to identify them using prioritized identification, Only enabled when L# is running with the SepSeq separation rule @@ -106,11 +122,13 @@ def make_frontiers_identified_with_matching(self): for frontier_state in self.frontier_to_basis_dict: self.identify_frontier_with_matching(frontier_state) - def identify_frontier_with_matching(self, frontier_state): + def identify_frontier_with_matching(self, frontier_state: Any) -> None: """ Determines the reference state which matches the frontier state (by looking at the basis parent) - Then finds the state identifiers for the matched reference state + Then finds the state identifiers for the matched reference state Tries to identify the frontier state using the state identifiers of the matched state + + :param Any frontier_state: frontier state to be identified """ if frontier_state not in self.frontier_to_basis_dict: raise Exception( @@ -129,10 +147,13 @@ def identify_frontier_with_matching(self, frontier_state): identifiers = self.characterization_map[frontier_match] self.identify_frontier_with_identifiers(frontier_state, identifiers) - def identify_frontier_with_identifiers(self, frontier_state, identifiers): - """ + def identify_frontier_with_identifiers(self, frontier_state: Any, identifiers: list) -> None: + """ Loops through all candidates states and checks whether they can be separated using one of the state identifiers of the state matched with the frontier state + + :param Any frontier_state: frontier state to be identified + :param list identifiers: state identifiers of the matched reference state """ basis_candidates = self.frontier_to_basis_dict.get(frontier_state) @@ -160,7 +181,7 @@ def identify_frontier_with_identifiers(self, frontier_state, identifiers): if len(self.frontier_to_basis_dict.get(frontier_state)) < 2: return - def match_refinement(self): + def match_refinement(self) -> None: # Loops over the basis states to refine the match for each basis state old_basis = list(self.basis) for basis_state in old_basis: @@ -169,10 +190,16 @@ def match_refinement(self): self.refine_matches_basis(basis_state, matches) self.update_frontier_and_basis() - def find_distinguishing_seq_partial(self, model, state1, state2, alphabet): + def find_distinguishing_seq_partial(self, model: Automaton, state1: Any, state2: Any, alphabet: list) -> list | None: """ A BFS to determine an input sequence that distinguishes two states in the automaton Can handle partial models + + :param Automaton model: automaton in which the states live + :param Any state1: first state + :param Any state2: second state + :param list alphabet: input alphabet + :return list | None: distinguishing sequence, or None if none was found """ visited = set() to_explore = [(state1, state2, [])] @@ -194,10 +221,13 @@ def find_distinguishing_seq_partial(self, model, state1, state2, alphabet): return None - def refine_matches_basis(self, basis_state, matches): - """ + def refine_matches_basis(self, basis_state: Any, matches: list) -> None: + """ Loops over the matched reference states and separates them using a separating sequence Returns when only one matching reference state remains, or some states are not distinguishable + + :param Any basis_state: basis state whose matches are refined + :param list matches: matched reference states """ for i in range(0, len(matches)): for j in range(i+1, len(matches)): @@ -223,8 +253,8 @@ def refine_matches_basis(self, basis_state, matches): if len(current_matches) < 2: return - def match_separation(self): - """ + def match_separation(self) -> None: + """ Loops over frontier states and calls the match separation with as goal isolation of the frontier state """ matched_states = [] @@ -248,9 +278,13 @@ def match_separation(self): matched_states, frontier_state, basis_candidates) self.update_frontier_and_basis() - def match_separation_frontier(self, matched_states, frontier_state, basis_candidates): - """ + def match_separation_frontier(self, matched_states: list, frontier_state: Any, basis_candidates: list) -> None: + """ Tries to isolate the frontier state if it matches a reference state that currently is not matched to any basis state + + :param list matched_states: reference states already matched to a basis state + :param Any frontier_state: frontier state to be isolated + :param list basis_candidates: candidate basis states for the frontier state """ parent_basis = frontier_state.parent inp = frontier_state.input_to_parent @@ -276,7 +310,7 @@ def match_separation_frontier(self, matched_states, frontier_state, basis_candid self.insert_observation(inputs, outputs) self.update_basis_candidates(frontier_state) - def promote_frontier_state(self): + def promote_frontier_state(self) -> None: """ Searches for an isolated frontier state and adds it to the basis states if it is not associated with another basis state @@ -294,10 +328,13 @@ def promote_frontier_state(self): new_basis_list.append(new_basis) break - def insert_observation(self, inputs, outputs): + def insert_observation(self, inputs: list, outputs: list) -> None: """ Insert an observation into the tree using sequences of inputs and outputs If state matching is enabled, ensure that the matching is updated + + :param list inputs: input sequence + :param list outputs: output sequence """ if len(inputs) != len(outputs): raise ValueError("Inputs and outputs must have the same length.") @@ -311,11 +348,14 @@ def insert_observation(self, inputs, outputs): input_val, output_val) - def extend_node_and_update_matching(self, inputs, outputs): - """ - Extends the observation tree with new inputs + def extend_node_and_update_matching(self, inputs: list, outputs: list) -> None: + """ + Extends the observation tree with new inputs Splits the input sequence in "already defined" part and the "new inputs" part If the inputs are not already present in the tree, we update the matching + + :param list inputs: input sequence + :param list outputs: output sequence """ to_recalc = [] split = None @@ -334,8 +374,8 @@ def extend_node_and_update_matching(self, inputs, outputs): # Functions related to rebuilding the observation tree - def rebuild_obs_tree(self): - """ + def rebuild_obs_tree(self) -> None: + """ Rebuilds the observation tree by finding pairs of frontier and basis states that occur in the same reference model Then posing output queries to try to distinguish them in the SUL Try to apply the prioritized promotion rule @@ -353,7 +393,7 @@ def rebuild_obs_tree(self): self.basis = self.new_basis self.update_frontier_and_basis() - def prioritized_promotion(self): + def prioritized_promotion(self) -> None: """ Promotes an isolated frontier state with an access sequence in the prefix set of one of the references """ @@ -367,10 +407,12 @@ def prioritized_promotion(self): self.new_basis.append(ob_tree_state) self.rebuild_states += 1 - def find_frontier_new_basis(self): - """ - This function find a frontier and basis state pair which both occur in one of the reference models - Because they occur in the same reference model, we have a separating sequence to distinguish them + def find_frontier_new_basis(self) -> tuple | None: + """ + This function find a frontier and basis state pair which both occur in one of the reference models + Because they occur in the same reference model, we have a separating sequence to distinguish them + + :return tuple | None: (basis_state_access, frontier_state_access, separating_sequence), or None if not found """ for basis_state_one in self.new_basis: for inp in self.alphabet: @@ -383,11 +425,15 @@ def find_frontier_new_basis(self): return self.find_basis_frontier_pair(frontier_state, frontier_state_access) return None - def find_basis_frontier_pair(self, frontier_state, frontier_state_access): - """ + def find_basis_frontier_pair(self, frontier_state: Any, frontier_state_access: tuple) -> tuple | None: + """ Find a basis state and reference model such that the prefixes of the basis state and frontier state are in the reference model prefix set Find a separating sequence that separates the frontier and basis state + + :param Any frontier_state: frontier state + :param tuple frontier_state_access: access sequence of the frontier state + :return tuple | None: (basis_state_access, frontier_state_access, separating_sequence), or None if not found """ for basis_state in self.new_basis: basis_state_access = self.get_access_sequence(basis_state) @@ -411,9 +457,12 @@ def find_basis_frontier_pair(self, frontier_state, frontier_state_access): return basis_state_access, frontier_state_access, tuple(sep_seq) return None - def insert_observation_rebuilding(self, inputs, outputs): + def insert_observation_rebuilding(self, inputs: list, outputs: list) -> None: """ Insert an observation into the tree using sequences of inputs and outputs + + :param list inputs: input sequence + :param list outputs: output sequence """ if len(inputs) != len(outputs): raise ValueError("Inputs and outputs must have the same length.") @@ -429,9 +478,12 @@ def insert_observation_rebuilding(self, inputs, outputs): if split: self.initial_OQs.append(split) - def apart_from_all(self, frontier_state): - """ + def apart_from_all(self, frontier_state: Any) -> bool: + """ Checks if a frontier state is apart from all new basis states + + :param Any frontier_state: frontier state to check + :return bool: True if the frontier state is apart from all new basis states """ for basis_state in self.new_basis: if not Apartness.states_are_apart(basis_state, frontier_state, self): @@ -439,10 +491,14 @@ def apart_from_all(self, frontier_state): return True # Functions related to finding the combined model - - def add_ref_transitions_to_states(self, reference, reference_id): - """ + + def add_ref_transitions_to_states(self, reference: Automaton, reference_id: int) -> list: + """ Makes a copy of the states of a reference with a unique state id and only transitions with the new input alphabet + + :param Automaton reference: reference model + :param int reference_id: index of the reference model + :return list: copied states of the reference model """ automaton_state = {'dfa': DfaState, 'mealy': MealyState, 'moore': MooreState} states = [automaton_state[self.automaton_type](f"s({reference_id},{ref_state})") @@ -461,29 +517,37 @@ def add_ref_transitions_to_states(self, reference, reference_id): states[state_id].transitions[inp] = states[old_index] return states - def compute_prefix_map(self, reference, reference_id): - """ + def compute_prefix_map(self, reference: Automaton, reference_id: int) -> None: + """ Computes the prefixes of a reference model and stores them in a prefix map + + :param Automaton reference: reference model + :param int reference_id: index of the reference model """ for state in reference.states: state.prefix = reference.get_shortest_path( reference.initial_state, state) self.prefixes_map[reference_id] = [state.prefix for state in reference.states if state.prefix is not None] - def compute_characterization_map(self, reference, states): - """ + def compute_characterization_map(self, reference: Automaton, states: list) -> None: + """ Computes the separating sequences of a reference model and stores them in a characterization map + + :param Automaton reference: reference model + :param list states: copied states corresponding to the reference model's states """ for state, ref_state in zip(states, reference.states): all_sepseqs = state_characterization_set(reference, reference.get_input_alphabet(), ref_state) unique_sepseqs = list(dict.fromkeys(all_sepseqs)) self.characterization_map[state] = unique_sepseqs - def get_combined_model(self): - """ + def get_combined_model(self) -> Automaton | None: + """ Builds a combined model from the reference models Compute the prefix and characterization maps used during construction of the combined model The resulting mealy machine may be partial + + :return Automaton | None: the combined model, or None if no usable references were found """ automaton_class = {'dfa': Dfa, 'mealy': MealyMachine, 'moore': MooreMachine} all_states = [] diff --git a/aalpy/learning_algs/adaptive/StateMatching.py b/aalpy/learning_algs/adaptive/StateMatching.py index 0841ad3b9df..36c9bcea69d 100644 --- a/aalpy/learning_algs/adaptive/StateMatching.py +++ b/aalpy/learning_algs/adaptive/StateMatching.py @@ -1,11 +1,16 @@ +# State matching strategies used by Adaptive L# to match observation tree basis states to reference model states. from abc import abstractmethod +from typing import Any class StateMatching: - def __init__(self, alphabet, combined_model): + def __init__(self, alphabet: list, combined_model: Any) -> None: """ Initializes the super class for state matching - """ + + :param list alphabet: input alphabet + :param Any combined_model: combined model built from the reference models + """ self.alphabet = alphabet self.combined_model = combined_model @@ -14,25 +19,28 @@ def __init__(self, alphabet, combined_model): self.best_score = {} @abstractmethod - def add_entry_basis(self, basis_state): + def add_entry_basis(self, basis_state: Any, aut_type: str) -> None: pass @abstractmethod - def update_best_score(self, basis_state): + def update_best_score(self, basis_state: Any) -> float: pass @abstractmethod - def update_score(self, ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part): + def update_score(self, ob_tree: Any, basis_state: Any, reference_state: Any, basis_state_access: tuple, + defined_after_access: tuple, new_part: tuple) -> None: pass @abstractmethod - def update_best_match(self, basis_state, score): + def update_best_match(self, basis_state: Any, score: float) -> None: pass - def initialize_matching(self, ob_tree): - """ - Initializes the matching by adding an entry for every basis state found during rebuilding + def initialize_matching(self, ob_tree: Any) -> None: + """ + Initializes the matching by adding an entry for every basis state found during rebuilding Updates the matching for the output queries posed during rebuilding + + :param Any ob_tree: adaptive observation tree """ for basis_state in ob_tree.basis: self.add_entry_basis(basis_state, ob_tree.automaton_type) @@ -46,14 +54,18 @@ def initialize_matching(self, ob_tree): self.update_matching(to_recalc, (defined_part, new_part), ob_tree) - def update_matching(self, to_recalc, split, ob_tree): - """ + def update_matching(self, to_recalc: list, split: tuple, ob_tree: Any) -> None: + """ Updates the matching for the to be recalculated basis states The split contains the already defined part and the new part of an output query For every basis state, we determine the defined part and new part AFTER accessing the basis state Then for every reference model check if the defined part uses only inputs valid in the reference model If these are all valid, we calculate the score of the new part + + :param list to_recalc: basis states to recalculate the matching for + :param tuple split: (defined_part, new_part) of an output query + :param Any ob_tree: adaptive observation tree """ defined_part, orig_new_part = split @@ -78,9 +90,12 @@ def update_matching(self, to_recalc, split, ob_tree): - def update_matching_basis(self, basis_state, ob_tree): - """ + def update_matching_basis(self, basis_state: Any, ob_tree: Any) -> None: + """ Initializes and updates the matching for a newly added basis state + + :param Any basis_state: newly added basis state + :param Any ob_tree: adaptive observation tree """ self.add_entry_basis(basis_state, ob_tree.automaton_type) longest_words = list(self.find_longest_words(basis_state, ob_tree, [])) @@ -94,10 +109,15 @@ def update_matching_basis(self, basis_state, ob_tree): self.update_matching([basis_state], split, ob_tree) - def find_longest_words(self, current_state, ob_tree, all_seqs): - """ + def find_longest_words(self, current_state: Any, ob_tree: Any, all_seqs: list) -> list: + """ Finds prefix closed words in the observation tree starting from the current state DFS-like procedure + + :param Any current_state: current node in the observation tree + :param Any ob_tree: adaptive observation tree + :param list all_seqs: accumulator of found sequences + :return list: prefix closed input sequences """ leaf = True for inp in self.alphabet: @@ -112,17 +132,27 @@ def find_longest_words(self, current_state, ob_tree, all_seqs): all_seqs.append(ob_tree.get_access_sequence(current_state)) return all_seqs - def validate_reference_input(self, inputs, reference_state): + def validate_reference_input(self, inputs: tuple, reference_state: Any) -> bool: """ Check if all inputs are valid (part of the alphabet of the reference model) + + :param tuple inputs: input sequence to validate + :param Any reference_state: reference model state + :return bool: True if all inputs are valid for the reference state """ for input_val in inputs: if input_val not in reference_state.transitions: return False return True - def is_prefix_of(self, str1, str2): - """ Checks if input sequence str1 is a prefix of str2 """ + def is_prefix_of(self, str1: tuple, str2: tuple) -> bool: + """ + Checks if input sequence str1 is a prefix of str2 + + :param tuple str1: candidate prefix sequence + :param tuple str2: sequence to check against + :return bool: True if str1 is a prefix of str2 + """ if len(str1) > len(str2): return False for i in range(0, len(str1)): @@ -130,8 +160,14 @@ def is_prefix_of(self, str1, str2): return False return True - def find_longest_common_part(self, str1, str2): - """ Finds the longest common prefix of input sequences str1 and str2 """ + def find_longest_common_part(self, str1: tuple, str2: tuple) -> tuple: + """ + Finds the longest common prefix of input sequences str1 and str2 + + :param tuple str1: first sequence + :param tuple str2: second sequence + :return tuple: (common_prefix, remaining_suffix_of_str2) + """ so_far = [] for i in range(0, len(str2)): if str2[i] == str1[i]: @@ -140,10 +176,12 @@ def find_longest_common_part(self, str1, str2): return tuple(so_far), tuple(str2[i:]) return tuple(so_far), tuple() - def print_match_table(self, ob_tree): + def print_match_table(self, ob_tree: Any) -> None: """ Prints the match table Code based on https://stackoverflow.com/questions/13214809/pretty-print-2d-list + + :param Any ob_tree: adaptive observation tree """ print(f"Mapping of basis state ids to access sequences") for basis_state in ob_tree.basis: @@ -165,23 +203,34 @@ def print_match_table(self, ob_tree): class TotalStateMatching(StateMatching): - """ + """ Total State Matching is an instance of State Matching - A basis and reference state match if all inputs sequences (over the shared alphabet) defined from the basis state + A basis and reference state match if all inputs sequences (over the shared alphabet) defined from the basis state give EXACTLY the same outputs """ - def __init__(self, alphabet, combined_model): + def __init__(self, alphabet: list, combined_model: Any) -> None: + """ + Initializes total state matching. + + :param list alphabet: input alphabet + :param Any combined_model: combined model built from the reference models + """ super().__init__(alphabet, combined_model) - def add_entry_basis(self, basis_state, aut_type): - """ Initializes a new matching row with 1 or empty word evaluation""" + def add_entry_basis(self, basis_state: Any, aut_type: str) -> None: + """ + Initializes a new matching row with 1 or empty word evaluation + + :param Any basis_state: basis state to add an entry for + :param str aut_type: automaton type ('dfa', 'mealy' or 'moore') + """ if aut_type == 'mealy': self.matchings[basis_state] = {ref_state: 1 for ref_state in self.combined_model.states} else: self.matchings[basis_state] = dict() - basis_out = basis_state.output + basis_out = basis_state.output for ref_state in self.combined_model.states: if aut_type == 'dfa': ref_out = ref_state.is_accepting @@ -192,14 +241,24 @@ def add_entry_basis(self, basis_state, aut_type): else: self.matchings[basis_state][ref_state] = 0 - def update_best_score(self, basis_state): - """ Updates the best score for a basis state """ + def update_best_score(self, basis_state: Any) -> float: + """ + Updates the best score for a basis state + + :param Any basis_state: basis state to update the best score for + :return float: best score for the basis state + """ score = max([self.matchings[basis_state][ref_state] for ref_state in self.combined_model.states]) self.best_score[basis_state] = score return score - def update_best_match(self, basis_state, score): - """ Updates the best match for a basis state """ + def update_best_match(self, basis_state: Any, score: float) -> None: + """ + Updates the best match for a basis state + + :param Any basis_state: basis state to update the best match for + :param float score: best score computed for the basis state + """ if score == 0: self.best_match[basis_state] = [] else: @@ -210,21 +269,39 @@ def update_best_match(self, basis_state, score): self.best_match[basis_state] = matches - def update_score(self, ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part): - # calls update score for either mealy or moore/dfa + def update_score(self, ob_tree: Any, basis_state: Any, reference_state: Any, basis_state_access: tuple, + defined_after_access: tuple, new_part: tuple) -> None: + """ + Calls update score for either mealy or moore/dfa + + :param Any ob_tree: adaptive observation tree + :param Any basis_state: basis state being scored + :param Any reference_state: reference model state being compared + :param tuple basis_state_access: access sequence of the basis state + :param tuple defined_after_access: already defined part after accessing the basis state + :param tuple new_part: new part of the output query + """ if ob_tree.automaton_type == 'mealy': return self.update_score_mealy(ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part) else: return self.update_score_moore(ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part) - def update_score_mealy(self, ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part): - """ - Updates the matching score for a basis and reference state based on the: + def update_score_mealy(self, ob_tree: Any, basis_state: Any, reference_state: Any, basis_state_access: tuple, + defined_after_access: tuple, new_part: tuple) -> None: + """ + Updates the matching score for a basis and reference state based on the: basis access, already defined part after access and the new part For every input in the new part, we take a step in the ob_tree and in the combined model If the combined model has no transition for some input (because it is not in the reference alphabet), we return If the outputs differ, we set the matching to 0 + + :param Any ob_tree: adaptive observation tree + :param Any basis_state: basis state being scored + :param Any reference_state: reference model state being compared + :param tuple basis_state_access: access sequence of the basis state + :param tuple defined_after_access: already defined part after accessing the basis state + :param tuple new_part: new part of the output query """ if self.matchings[basis_state][reference_state] == 0: return @@ -245,12 +322,20 @@ def update_score_mealy(self, ob_tree, basis_state, reference_state, basis_state_ return current_ob_state = current_ob_state.get_successor(inp) - def update_score_moore(self, ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part): - """ - Updates the matching score for a basis and reference state based on the: + def update_score_moore(self, ob_tree: Any, basis_state: Any, reference_state: Any, basis_state_access: tuple, + defined_after_access: tuple, new_part: tuple) -> None: + """ + Updates the matching score for a basis and reference state based on the: basis access, already defined part after access and the new part For every input in the new part, we take a step in the ob_tree and in the combined model If the outputs differ, we set the matching to 0 + + :param Any ob_tree: adaptive observation tree + :param Any basis_state: basis state being scored + :param Any reference_state: reference model state being compared + :param tuple basis_state_access: access sequence of the basis state + :param tuple defined_after_access: already defined part after accessing the basis state + :param tuple new_part: new part of the output query """ orig_ref = reference_state if self.matchings[basis_state][reference_state] == 0: @@ -262,7 +347,7 @@ def update_score_moore(self, ob_tree, basis_state, reference_state, basis_state_ reference_state = self.combined_model.current_state # need to test the empty word - if defined_after_access == (): + if defined_after_access == (): if reference_state.is_accepting != current_ob_state.output: self.matchings[basis_state][orig_ref] = 0 return @@ -283,25 +368,36 @@ def update_score_moore(self, ob_tree, basis_state, reference_state, basis_state_ class ApproximateStateMatching(StateMatching): - """ + """ Approximate State Matching is an instance of State Matching - A basis matches a reference state if the reference state has the highest ratio of matching outputs over + A basis matches a reference state if the reference state has the highest ratio of matching outputs over all inputs sequences (over the shared alphabet) defined from the basis state compared to the other reference states """ - def __init__(self, alphabet, combined_model): + def __init__(self, alphabet: list, combined_model: Any) -> None: + """ + Initializes approximate state matching. + + :param list alphabet: input alphabet + :param Any combined_model: combined model built from the reference models + """ super().__init__(alphabet, combined_model) self.unmatched = set() - def add_entry_basis(self, basis_state, aut_type): - """ Initializes a new matching row with [0,0] or empty word evaluation """ + def add_entry_basis(self, basis_state: Any, aut_type: str) -> None: + """ + Initializes a new matching row with [0,0] or empty word evaluation + + :param Any basis_state: basis state to add an entry for + :param str aut_type: automaton type ('dfa', 'mealy' or 'moore') + """ if aut_type == 'mealy': self.matchings[basis_state] = {ref_state: [0, 0] for ref_state in self.combined_model.states} else: self.matchings[basis_state] = dict() - basis_out = basis_state.output + basis_out = basis_state.output for ref_state in self.combined_model.states: if aut_type == 'dfa': ref_out = ref_state.is_accepting @@ -312,21 +408,37 @@ def add_entry_basis(self, basis_state, aut_type): else: self.matchings[basis_state][ref_state] = [0,1] - def get_score(self, basis_state, ref_state): - """ Gets the score for a basis and reference states """ + def get_score(self, basis_state: Any, ref_state: Any) -> float: + """ + Gets the score for a basis and reference states + + :param Any basis_state: basis state + :param Any ref_state: reference model state + :return float: matching score between the two states + """ if self.matchings[basis_state][ref_state][1] == 0: return 0 return self.matchings[basis_state][ref_state][0]/self.matchings[basis_state][ref_state][1] - def update_best_score(self, basis_state): - """ Updates the best score for a basis state """ + def update_best_score(self, basis_state: Any) -> float: + """ + Updates the best score for a basis state + + :param Any basis_state: basis state to update the best score for + :return float: best score for the basis state + """ score = max([self.get_score(basis_state, ref_state) for ref_state in self.combined_model.states]) self.best_score[basis_state] = round(score, 2) return score - def update_best_match(self, basis_state, score): - """ Updates the best match for a basis state """ + def update_best_match(self, basis_state: Any, score: float) -> None: + """ + Updates the best match for a basis state + + :param Any basis_state: basis state to update the best match for + :param float score: best score computed for the basis state + """ if score == 0: self.best_match[basis_state] = [] else: @@ -337,21 +449,39 @@ def update_best_match(self, basis_state, score): self.best_match[basis_state] = matches - def update_score(self, ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part): - # calls update score for either mealy or moore/dfa + def update_score(self, ob_tree: Any, basis_state: Any, reference_state: Any, basis_state_access: tuple, + defined_after_access: tuple, new_part: tuple) -> None: + """ + Calls update score for either mealy or moore/dfa + + :param Any ob_tree: adaptive observation tree + :param Any basis_state: basis state being scored + :param Any reference_state: reference model state being compared + :param tuple basis_state_access: access sequence of the basis state + :param tuple defined_after_access: already defined part after accessing the basis state + :param tuple new_part: new part of the output query + """ if ob_tree.automaton_type == 'mealy': return self.update_score_mealy(ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part) else: return self.update_score_moore(ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part) - def update_score_mealy(self, ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part): - """ - Updates the matching score for a basis and reference state based on the: + def update_score_mealy(self, ob_tree: Any, basis_state: Any, reference_state: Any, basis_state_access: tuple, + defined_after_access: tuple, new_part: tuple) -> None: + """ + Updates the matching score for a basis and reference state based on the: basis access, already defined part after access and the new part For every input in the new part, we take a step in the ob_tree and in the combined model If the combined model has no transition for some input (because it is not in the reference alphabet), we return If the outputs are equivalent, we add [1,1] If the outputs differ, we add [0,1] + + :param Any ob_tree: adaptive observation tree + :param Any basis_state: basis state being scored + :param Any reference_state: reference model state being compared + :param tuple basis_state_access: access sequence of the basis state + :param tuple defined_after_access: already defined part after accessing the basis state + :param tuple new_part: new part of the output query """ current_ob_state = ob_tree.get_successor(tuple(basis_state_access) + tuple(defined_after_access)) @@ -368,14 +498,22 @@ def update_score_mealy(self, ob_tree, basis_state, reference_state, basis_state_ self.matchings[basis_state][reference_state][0] += 1 current_ob_state = current_ob_state.get_successor(inp) - def update_score_moore(self, ob_tree, basis_state, reference_state, basis_state_access, defined_after_access, new_part): - """ - Updates the matching score for a basis and reference state based on the: + def update_score_moore(self, ob_tree: Any, basis_state: Any, reference_state: Any, basis_state_access: tuple, + defined_after_access: tuple, new_part: tuple) -> None: + """ + Updates the matching score for a basis and reference state based on the: basis access, already defined part after access and the new part For every input in the new part, we take a step in the ob_tree and in the combined model If the combined model has no transition for some input (because it is not in the reference alphabet), we return If the outputs are equivalent, we add [1,1] If the outputs differ, we add [0,1] + + :param Any ob_tree: adaptive observation tree + :param Any basis_state: basis state being scored + :param Any reference_state: reference model state being compared + :param tuple basis_state_access: access sequence of the basis state + :param tuple defined_after_access: already defined part after accessing the basis state + :param tuple new_part: new part of the output query """ orig_ref = reference_state current_ob_state = ob_tree.get_successor(tuple(basis_state_access) + tuple(defined_after_access)) diff --git a/aalpy/learning_algs/adaptive/__init__.py b/aalpy/learning_algs/adaptive/__init__.py index e69de29bb2d..fdc817a9a01 100644 --- a/aalpy/learning_algs/adaptive/__init__.py +++ b/aalpy/learning_algs/adaptive/__init__.py @@ -0,0 +1 @@ +# Package for the Adaptive L# active automata learning algorithm. diff --git a/aalpy/learning_algs/deterministic/ADS.py b/aalpy/learning_algs/deterministic/ADS.py index 20eada353e1..53aac082cc3 100644 --- a/aalpy/learning_algs/deterministic/ADS.py +++ b/aalpy/learning_algs/deterministic/ADS.py @@ -1,41 +1,92 @@ +# Adaptive distinguishing sequence (ADS) construction used to identify frontier states in L#. from collections import defaultdict +from typing import Any class AdsNode: + """ + Single node of an ADS tree, holding the input to send next, a mapping from observed output to the child node, + and a score describing how well this (sub)tree splits its associated block of states. + """ + __slots__ = ['input', 'children', 'score'] - def __init__(self, input_val=None, children=None, score=0): + def __init__(self, input_val: Any = None, children: dict | None = None, score: float = 0) -> None: + """ + Creates an ADS node. + + :param Any input_val: Input to send at this node, or None for a leaf. + :param dict | None children: Map from observed output to child AdsNode. + :param float score: Score of this (sub)tree. + """ self.input = input_val self.children = children if children else {} self.score = score @staticmethod - def create_leaf(): + def create_leaf() -> 'AdsNode': + """ + Creates a leaf node (no input, no children). + + :return AdsNode: The created leaf node. + """ return AdsNode() - def get_input(self): + def get_input(self) -> Any: + """ + :return Any: Input to be sent at this node. + """ return self.input - def get_child_node(self, output): + def get_child_node(self, output: Any) -> 'AdsNode | None': + """ + Looks up the child node reached after observing a given output. + + :param Any output: Observed output. + :return AdsNode | None: Child node for this output, or None if it does not exist. + """ if output in self.children: return self.children[output] return None - def get_score(self): + def get_score(self) -> float: + """ + :return float: Score of this (sub)tree. + """ return self.score class Ads: - def __init__(self, ob_tree, current_block): + """ + Adaptive distinguishing sequence for a block of observation-tree states. Builds a decision tree of inputs that + incrementally splits the block based on observed outputs, and can be walked input-by-input using next_input. + """ + + def __init__(self, ob_tree, current_block: list) -> None: + """ + Constructs the ADS tree for a block of observation-tree nodes. + + :param ObservationTree ob_tree: Observation tree the block belongs to. + :param list current_block: List of observation-tree nodes to be distinguished. + """ self.initial_node = self.construct_ads(ob_tree, current_block) self.current_node = self.initial_node - def get_score(self): + def get_score(self) -> float: + """ + :return float: Score of the whole ADS tree. + """ return self.initial_node.get_score() - def construct_ads(self, ob_tree, current_block): - # Builds the ADS tree recursively by selecting optimal inputs for splitting states - # For DFA/Moore we have to consider the output for the empty word + def construct_ads(self, ob_tree, current_block: list) -> AdsNode: + """ + Builds the ADS tree recursively by selecting optimal inputs for splitting states. + For DFA/Moore we have to consider the output for the empty word. + + :param ObservationTree ob_tree: Observation tree the block belongs to. + :param list current_block: List of observation-tree nodes to be distinguished. + :return AdsNode: Root node of the constructed ADS tree. + """ if ob_tree.automaton_type == 'mealy': return self.construct_ads_rec(ob_tree, current_block) else: @@ -53,14 +104,20 @@ def construct_ads(self, ob_tree, current_block): children = {} for output, partition in empty_part.items(): - output_score, subtree = self.compute_output_subtree(ob_tree, partition, u_i) + output_score, subtree = self.compute_output_subtree(ob_tree, partition, u_i) score += output_score children[output] = subtree return AdsNode(input, children, score) - def construct_ads_rec(self, ob_tree, current_block): - # Builds the ADS tree recursively by selecting optimal inputs for splitting states + def construct_ads_rec(self, ob_tree, current_block: list) -> AdsNode: + """ + Builds the ADS tree recursively by selecting optimal inputs for splitting states. + + :param ObservationTree ob_tree: Observation tree the block belongs to. + :param list current_block: List of observation-tree nodes to be distinguished. + :return AdsNode: Root node of the constructed (sub)tree. + """ if len(current_block) == 1: return AdsNode.create_leaf() @@ -82,7 +139,7 @@ def construct_ads_rec(self, ob_tree, current_block): children = {} for output, partition in input_partitions.items(): - output_score, subtree = self.compute_output_subtree(ob_tree, partition, u_i) + output_score, subtree = self.compute_output_subtree(ob_tree, partition, u_i) input_score += output_score children[output] = subtree @@ -104,27 +161,53 @@ def construct_ads_rec(self, ob_tree, current_block): # child_score = self.construct_ads_rec(ob_tree, partition).get_score() # return self.compute_reg_score(partition_size, sub_trees, child_score) - def compute_output_subtree(self, ob_tree, partition, u_i): - # Computes and scores a subtree for a specific output partition + def compute_output_subtree(self, ob_tree, partition: list, u_i: int) -> tuple[float, AdsNode]: + """ + Computes and scores a subtree for a specific output partition. + + :param ObservationTree ob_tree: Observation tree the partition belongs to. + :param list partition: Nodes belonging to this output partition. + :param int u_i: Total number of nodes in the parent partition. + :return tuple[float, AdsNode]: The computed score and the constructed subtree. + """ output_subtree = self.construct_ads_rec(ob_tree, partition) output_score = self.compute_score(len(partition), u_i, output_subtree.get_score()) return output_score, output_subtree - def compute_score(self, u_io, u_i, child_score): - # Calculates a score based on partition size and subtree characteristics + def compute_score(self, u_io: int, u_i: int, child_score: float) -> float: + """ + Calculates a score based on partition size and subtree characteristics. + + :param int u_io: Number of nodes in the output partition. + :param int u_i: Total number of nodes in the parent partition. + :param float child_score: Score of the subtree built from the output partition. + :return float: Computed score. + """ return (u_io * (u_i - u_io + child_score)) / u_i - def partition_on_output_empty(self, block, automaton_type): - # Partitions states in the block based on their output for the empty word - # Only use during the initial call + def partition_on_output_empty(self, block: list, automaton_type: str) -> defaultdict: + """ + Partitions states in the block based on their output for the empty word. Only used during the initial call. + + :param list block: Observation-tree nodes to partition. + :param str automaton_type: Automaton type, one of ['dfa', 'mealy', 'moore']. + :return defaultdict: Map from output to list of nodes with that output. + """ partition = defaultdict(list) for node in block: output = node.output partition[output].append(node) return partition - def partition_on_output(self, block, input_val, automaton_type): - # Partitions states in the block based on their output for a given input + def partition_on_output(self, block: list, input_val: Any, automaton_type: str) -> defaultdict: + """ + Partitions states in the block based on their output for a given input. + + :param list block: Observation-tree nodes to partition. + :param Any input_val: Input on which to partition. + :param str automaton_type: Automaton type, one of ['dfa', 'mealy', 'moore']. + :return defaultdict: Map from output to list of successor nodes with that output. + """ partition = defaultdict(list) for node in block: if automaton_type == 'mealy': @@ -136,13 +219,18 @@ def partition_on_output(self, block, input_val, automaton_type): else: successor = node.get_successor(input_val) if successor is not None: - output = successor.output + output = successor.output if output is not None: partition[output].append(successor) return partition - def next_input(self, prev_output): - # Returns the next input based on the previous output and updates the current node + def next_input(self, prev_output: Any) -> Any: + """ + Returns the next input based on the previous output and updates the current node. + + :param Any prev_output: Output observed for the previous input, or None on the first call. + :return Any: Next input to send, or None if the ADS has no further input for this output. + """ if prev_output is not None: child = self.current_node.get_child_node(prev_output) if child is None: @@ -150,9 +238,16 @@ def next_input(self, prev_output): self.current_node = child return self.current_node.get_input() - def maximal_base_input(self, alphabet, block, automaton_type): - # Identifies the input with the highest ability to split the state block based on apartness - # Does not use the recursive part of the formula + def maximal_base_input(self, alphabet: list, block: list, automaton_type: str) -> tuple[Any, float]: + """ + Identifies the input with the highest ability to split the state block based on apartness. + Does not use the recursive part of the formula. + + :param list alphabet: Input alphabet. + :param list block: Observation-tree nodes to split. + :param str automaton_type: Automaton type, one of ['dfa', 'mealy', 'moore']. + :return tuple[Any, float]: The best input found and its score. + """ best_input = alphabet[0] best_score = 0 @@ -172,6 +267,8 @@ def maximal_base_input(self, alphabet, block, automaton_type): return best_input, best_score - def reset_to_root(self): - # Resets the current ADS node to the initial root node - self.current_node = self.initial_node \ No newline at end of file + def reset_to_root(self) -> None: + """ + Resets the current ADS node to the initial root node. + """ + self.current_node = self.initial_node diff --git a/aalpy/learning_algs/deterministic/Apartness.py b/aalpy/learning_algs/deterministic/Apartness.py index 7d5adf0077f..6b628a3c062 100644 --- a/aalpy/learning_algs/deterministic/Apartness.py +++ b/aalpy/learning_algs/deterministic/Apartness.py @@ -1,10 +1,23 @@ +# Apartness checks and witness computation between observation-tree nodes (used by L# and related algorithms). from collections import deque class Apartness: + """ + Collection of static helper methods for checking apartness (a distinguishable-by-some-suffix relation) between + nodes of an observation tree, and between observation-tree nodes and hypothesis states. + """ + @staticmethod - def compute_witness(state1, state2, ob_tree): - # Finds a distinguishing sequence between two states if they are apart based on the observation tree + def compute_witness(state1, state2, ob_tree) -> list | None: + """ + Finds a distinguishing sequence between two states if they are apart based on the observation tree. + + :param state1: First observation-tree node (MealyNode or MooreNode). + :param state2: Second observation-tree node (MealyNode or MooreNode). + :param ObservationTree ob_tree: Observation tree the states belong to. + :return list | None: Distinguishing input sequence, or None if the states are not apart. + """ if ob_tree.automaton_type == 'mealy': state1_destination = Apartness._show_states_are_apart_mealy( state1, state2, ob_tree.alphabet) @@ -12,20 +25,35 @@ def compute_witness(state1, state2, ob_tree): state1_destination = Apartness._show_states_are_apart_moore( state1, state2, ob_tree.alphabet) if not state1_destination: - return + return None return ob_tree.get_transfer_sequence(state1, state1_destination) @staticmethod - def states_are_apart(state1, state2, ob_tree): - # Checks if two states are apart by checking any output difference in the observation tree + def states_are_apart(state1, state2, ob_tree) -> bool: + """ + Checks if two states are apart by checking any output difference in the observation tree. + + :param state1: First observation-tree node (MealyNode or MooreNode). + :param state2: Second observation-tree node (MealyNode or MooreNode). + :param ObservationTree ob_tree: Observation tree the states belong to. + :return bool: True if the states are apart, False otherwise. + """ if ob_tree.automaton_type == 'mealy': return Apartness._show_states_are_apart_mealy(state1, state2, ob_tree.alphabet) is not None else: return Apartness._show_states_are_apart_moore(state1, state2, ob_tree.alphabet) is not None @staticmethod - def _show_states_are_apart_mealy(first, second, alphabet): - # Identifies if two states can be distinguished by any input-output pair in the provided alphabet + def _show_states_are_apart_mealy(first, second, alphabet: list): + """ + Identifies if two Mealy observation-tree nodes can be distinguished by any input-output pair in the + provided alphabet. + + :param first: First observation-tree node (MealyNode). + :param second: Second observation-tree node (MealyNode). + :param list alphabet: Input alphabet. + :return MealyNode | None: The node reached from `first` where a difference was observed, or None. + """ pairs = deque([(first, second)]) while pairs: @@ -44,8 +72,16 @@ def _show_states_are_apart_mealy(first, second, alphabet): return None @staticmethod - def _show_states_are_apart_moore(first, second, alphabet): - # Identifies if two states can be distinguished by any input-output pair in the provided alphabet + def _show_states_are_apart_moore(first, second, alphabet: list): + """ + Identifies if two Moore/DFA observation-tree nodes can be distinguished by any input-output pair in the + provided alphabet. + + :param first: First observation-tree node (MooreNode). + :param second: Second observation-tree node (MooreNode). + :param list alphabet: Input alphabet. + :return MooreNode | None: The node where a difference in output was observed, or None. + """ pairs = deque([(first, second)]) while pairs: @@ -63,9 +99,14 @@ def _show_states_are_apart_moore(first, second, alphabet): return None @staticmethod - def compute_witness_in_tree_and_hypothesis_states(ob_tree, ob_tree_state, hyp_state): + def compute_witness_in_tree_and_hypothesis_states(ob_tree, ob_tree_state, hyp_state) -> list | None: """ - Determines if the observation tree and the hypothesis are distinguishable based on their state outputs + Determines if the observation tree and the hypothesis are distinguishable based on their state outputs. + + :param ObservationTree ob_tree: Observation tree. + :param ob_tree_state: Observation-tree node to compare from. + :param AutomatonState hyp_state: Hypothesis state to compare from. + :return list | None: Distinguishing input sequence, or None if not distinguishable. """ if ob_tree.automaton_type == 'mealy': return Apartness.compute_witness_in_tree_and_hypothesis_states_mealy(ob_tree, ob_tree_state, hyp_state) @@ -73,9 +114,14 @@ def compute_witness_in_tree_and_hypothesis_states(ob_tree, ob_tree_state, hyp_st return Apartness.compute_witness_in_tree_and_hypothesis_states_moore(ob_tree, ob_tree_state, hyp_state) @staticmethod - def compute_witness_in_tree_and_hypothesis_states_mealy(ob_tree, ob_tree_state, hyp_state): + def compute_witness_in_tree_and_hypothesis_states_mealy(ob_tree, ob_tree_state, hyp_state) -> list | None: """ - Determines if the observation tree and the hypothesis are distinguishable based on their state outputs + Determines if the observation tree and the Mealy hypothesis are distinguishable based on their state outputs. + + :param ObservationTree ob_tree: Observation tree. + :param ob_tree_state: Observation-tree node (MealyNode) to compare from. + :param MealyState hyp_state: Hypothesis state to compare from. + :return list | None: Distinguishing input sequence, or None if not distinguishable. """ pairs = deque([(ob_tree_state, hyp_state)]) @@ -97,9 +143,15 @@ def compute_witness_in_tree_and_hypothesis_states_mealy(ob_tree, ob_tree_state, return None @staticmethod - def compute_witness_in_tree_and_hypothesis_states_moore(ob_tree, ob_tree_state, hyp_state): + def compute_witness_in_tree_and_hypothesis_states_moore(ob_tree, ob_tree_state, hyp_state) -> list | None: """ - Determines if the observation tree and the hypothesis are distinguishable based on their state outputs + Determines if the observation tree and the Moore/DFA hypothesis are distinguishable based on their state + outputs. + + :param ObservationTree ob_tree: Observation tree. + :param ob_tree_state: Observation-tree node (MooreNode) to compare from. + :param AutomatonState hyp_state: Hypothesis state to compare from. + :return list | None: Distinguishing input sequence, or None if not distinguishable. """ pairs = deque([(ob_tree_state, hyp_state)]) diff --git a/aalpy/learning_algs/deterministic/ClassificationTree.py b/aalpy/learning_algs/deterministic/ClassificationTree.py index acfdaf41e2c..1321bdb4386 100644 --- a/aalpy/learning_algs/deterministic/ClassificationTree.py +++ b/aalpy/learning_algs/deterministic/ClassificationTree.py @@ -1,10 +1,11 @@ +# Classification tree (discrimination tree) used by the KV learning algorithm. from collections import defaultdict from itertools import product -from typing import Union from aalpy.automata import DfaState, Dfa, MealyState, MealyMachine, MooreState, MooreMachine, \ SevpaAlphabet, SevpaState, SevpaTransition, Sevpa from aalpy.base import SUL + from aalpy.learning_algs.deterministic.CounterExampleProcessing import rs_cex_processing, linear_cex_processing, \ exponential_cex_processing @@ -12,40 +13,82 @@ class CTNode: + """ + Base class for a classification tree node. + """ + __slots__ = ['parent', 'path_to_node'] - def __init__(self, parent, path_to_node): + def __init__(self, parent: 'CTNode | None', path_to_node) -> None: + """ + Creates a classification tree node. + + :param CTNode | None parent: Parent node, or None for the root. + :param path_to_node: Output value labeling the edge from the parent to this node. + """ self.parent = parent self.path_to_node = path_to_node - def is_leaf(self): + def is_leaf(self) -> bool: + """ + :return bool: True if this node is a leaf node. + """ pass class CTInternalNode(CTNode): + """ + Internal node of a classification tree, labeled with a distinguishing string and holding a child per + observed output. + """ + __slots__ = ['distinguishing_string', 'children'] - def __init__(self, distinguishing_string: tuple, parent, path_to_node): + def __init__(self, distinguishing_string: tuple, parent: 'CTNode | None', path_to_node) -> None: + """ + Creates an internal classification tree node. + + :param tuple distinguishing_string: Sequence used to distinguish the states in this subtree. + :param CTNode | None parent: Parent node, or None for the root. + :param path_to_node: Output value labeling the edge from the parent to this node. + """ super().__init__(parent, path_to_node) self.distinguishing_string = distinguishing_string self.children = defaultdict(None) # {True: None, False: None} - def is_leaf(self): + def is_leaf(self) -> bool: + """ + :return bool: Always False for internal nodes. + """ return False class CTLeafNode(CTNode): + """ + Leaf node of a classification tree, corresponding to a single hypothesis state identified by its access string. + """ + __slots__ = ['access_string'] - def __init__(self, access_string: tuple, parent, path_to_node): + def __init__(self, access_string: tuple, parent: 'CTNode | None', path_to_node) -> None: + """ + Creates a leaf classification tree node. + + :param tuple access_string: Access string of the hypothesis state represented by this leaf. + :param CTNode | None parent: Parent node, or None for the root. + :param path_to_node: Output value labeling the edge from the parent to this node. + """ super().__init__(parent, path_to_node) self.access_string = access_string - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__} '{self.access_string}'" @property def output(self): + """ + :return: The output value labeling the edge from this leaf's topmost ancestor's child down to it. + """ c, p = self, self.parent while p.parent: c = p @@ -55,12 +98,29 @@ def output(self): return output assert False - def is_leaf(self): + def is_leaf(self) -> bool: + """ + :return bool: Always True for leaf nodes. + """ return True class ClassificationTree: - def __init__(self, alphabet: Union[list, SevpaAlphabet], sul: SUL, automaton_type: str, cex: tuple): + """ + Classification tree used by the KV algorithm to sift words to hypothesis states and to construct/update the + hypothesis based on counterexamples. + """ + + def __init__(self, alphabet: list | SevpaAlphabet, sul: SUL, automaton_type: str, cex: tuple) -> None: + """ + Creates a classification tree, initialized with a root distinguishing the initial hypothesis state and the + state reached by the initial counterexample. + + :param list | SevpaAlphabet alphabet: Input alphabet. + :param SUL sul: System under learning. + :param str automaton_type: Automaton type, one of ['dfa', 'mealy', 'moore', 'vpa']. + :param tuple cex: Initial counterexample used to seed the tree. + """ self.sul = sul self.alphabet = alphabet self.automaton_type = automaton_type @@ -108,7 +168,7 @@ def __init__(self, alphabet: Union[list, SevpaAlphabet], sul: SUL, automaton_typ self.new_states = list(self.leaf_nodes.values()) self.transitions_to_update = [] - def _sift(self, word): + def _sift(self, word: tuple) -> CTLeafNode: """ Sifting a word into the classification tree. Starting at the root, at every inner node (a CTInternalNode), @@ -116,13 +176,8 @@ def _sift(self, word): membership query (word * node.distinguishing_string). Repeated until a leaf (a CTLeafNode) is reached, which is the result of the sifting. - Args: - - word: the word to sift into the discrimination tree (a tuple of all letters) - - Returns: - - the CTLeafNode that is reached by the sifting operation. + :param tuple word: The word to sift into the discrimination tree (a tuple of all letters). + :return CTLeafNode: The CTLeafNode that is reached by the sifting operation. """ node = self.root @@ -146,6 +201,12 @@ def _sift(self, word): return node def update_hypothesis(self): + """ + For each CTLeafNode of this CT, creates a state in the hypothesis that is labeled by that node's access + string (the start state is the empty word), then computes transitions by sifting. + + :return: The constructed hypothesis automaton. + """ # for each CTLeafNode of this CT, # create a state in the hypothesis that is labeled by that # node's access string. The start state is the empty word @@ -248,24 +309,18 @@ def update_hypothesis(self): return automaton_class[self.automaton_type](initial_state=self.initial_state, states=list(self.hypothesis_states.values())) - def _least_common_ancestor(self, node_1_id, node_2_id): + def _least_common_ancestor(self, node_1_id: tuple, node_2_id: tuple) -> tuple: """ Find the distinguishing string of the least common ancestor of the leaf nodes node_1 and node_2. Both nodes have to exist. Adapted from https://www.geeksforgeeks.org/lowest-common-ancestor-binary-tree-set-1/ - Args: - - node_1_id: first leaf node's id - node_2_id: second leaf node's id - - Returns: - - the distinguishing string of the lca - + :param tuple node_1_id: First leaf node's access string (id). + :param tuple node_2_id: Second leaf node's access string (id). + :return tuple: The distinguishing string of the LCA. """ - def ancestor(parent, node): + def ancestor(parent: CTNode, node: tuple) -> bool: for child in parent.children.values(): if child.is_leaf(): if child.access_string == node: @@ -276,7 +331,7 @@ def ancestor(parent, node): return True return False - def findLCA(n1_id, n2_id): + def findLCA(n1_id: tuple, n2_id: tuple) -> CTInternalNode | None: node = self.leaf_nodes[n1_id] parent = node.parent while parent: @@ -290,7 +345,7 @@ def findLCA(n1_id, n2_id): return findLCA(node_1_id, node_2_id).distinguishing_string - def update(self, cex: tuple, hypothesis): + def update(self, cex: tuple, hypothesis) -> None: """ Updates the classification tree based on a counterexample. - For each prefix cex[:i] of the counterexample, get @@ -305,10 +360,8 @@ def update(self, cex: tuple, hypothesis): The internal node is labeled with the distinguishing string (cex[j-1],*d), where d is the distinguishing string of the LCA of s_i and s_star_i. - Args: - cex: the counterexample used to update the tree - hypothesis: the former (wrong) hypothesis - + :param tuple cex: The counterexample used to update the tree. + :param hypothesis: The former (wrong) hypothesis. """ j = d = None for i in range(1, len(cex) + 1): @@ -332,7 +385,7 @@ def update(self, cex: tuple, hypothesis): new_leaf_access_string=tuple(cex[:j - 1]) or tuple(), new_leaf_position=self.sul.query((*cex[:j - 1], *(cex[j - 1], *d)))[-1]) - def process_counterexample(self, cex: tuple, hypothesis, cex_processing_fun): + def process_counterexample(self, cex: tuple, hypothesis, cex_processing_fun: str) -> None: """ Updates the classification tree based on a counterexample, using Rivest & Schapire counterexample processing @@ -343,11 +396,9 @@ def process_counterexample(self, cex: tuple, hypothesis, cex_processing_fun): The internal node is labeled with the distinguishing string (cex[j-1],*d), where d is the distinguishing string of the LCA of s_i and s_star_i. - Args: - cex: the counterexample used to update the tree - hypothesis: the former (wrong) hypothesis - cex_processing_fun: string choosing which cex_processing to use - + :param tuple cex: The counterexample used to update the tree. + :param hypothesis: The former (wrong) hypothesis. + :param str cex_processing_fun: String choosing which cex_processing to use. """ v = None if 'linear' in cex_processing_fun: @@ -400,7 +451,8 @@ def process_counterexample(self, cex: tuple, hypothesis, cex_processing_fun): new_leaf_access_string=new_leaf_access_string, new_leaf_position=new_leaf_position) - def _insert_new_leaf(self, discriminator, old_leaf_access_string, new_leaf_access_string, new_leaf_position): + def _insert_new_leaf(self, discriminator: tuple, old_leaf_access_string: tuple, new_leaf_access_string: tuple, + new_leaf_position) -> None: """ Inserts a new leaf in the classification tree by: - moving the leaf node specified by down one level @@ -411,14 +463,12 @@ def _insert_new_leaf(self, discriminator, old_leaf_access_string, new_leaf_acces where one of the resulting nodes keeps the old node's access string and the other gets new_leaf_access_string. - Args: - discriminator: The distinguishing string of the new internal node - old_leaf_access_string: The access string specifying the leaf node to be 'split' (or rather moved down) - new_leaf_access_string: The access string of the leaf node that will be created - new_leaf_position: The path from the new internal node to the new leaf node - - Returns: + :param tuple discriminator: The distinguishing string of the new internal node. + :param tuple old_leaf_access_string: The access string specifying the leaf node to be 'split' (or rather + moved down). + :param tuple new_leaf_access_string: The access string of the leaf node that will be created. + :param new_leaf_position: The path from the new internal node to the new leaf node. """ if self.automaton_type == "dfa" or self.automaton_type == 'vpa': other_leaf_position = not new_leaf_position diff --git a/aalpy/learning_algs/deterministic/CounterExampleProcessing.py b/aalpy/learning_algs/deterministic/CounterExampleProcessing.py index d1bf2971f62..190694f0149 100644 --- a/aalpy/learning_algs/deterministic/CounterExampleProcessing.py +++ b/aalpy/learning_algs/deterministic/CounterExampleProcessing.py @@ -1,32 +1,34 @@ +# Counterexample processing strategies (Rivest-Schapire, longest-prefix, linear, exponential) for L*/KV. from aalpy.base import SUL from aalpy.utils.HelperFunctions import all_suffixes, all_prefixes -def counterexample_successfully_processed(sul, cex, hypothesis): +def counterexample_successfully_processed(sul: SUL, cex: tuple, hypothesis) -> bool: + """ + Checks whether a counterexample is still a counterexample for the given hypothesis, i.e. whether the last + output of the SUL and the hypothesis differ. + + :param SUL sul: System under learning. + :param tuple cex: Counterexample to check. + :param hypothesis: Hypothesis on which the counterexample was found. + :return bool: True if the last outputs agree (counterexample successfully processed), False otherwise. + """ cex_outputs = sul.query(cex) hyp_outputs = hypothesis.execute_sequence(hypothesis.initial_state, cex) return cex_outputs[-1] == hyp_outputs[-1] -def longest_prefix_cex_processing(s_union_s_dot_a: list, cex: tuple, closedness='suffix'): +def longest_prefix_cex_processing(s_union_s_dot_a: list, cex: tuple, closedness: str = 'suffix') -> list: """ Suffix processing strategy found in Shahbaz-Groz paper 'Inferring Mealy Machines'. It splits the counterexample into prefix and suffix. The prefix is the longest element of the S union S.A that matches the beginning of the counterexample. By removing such prefixes from counterexample, no consistency check is needed. - Args: - - s_union_s_dot_a: list of all prefixes found in observation table sorted from shortest to longest - cex: counterexample - closedness: either 'suffix' or 'prefix'. (Default value = 'suffix') - s_union_s_dot_a: list: - cex: tuple: counterexample - - Returns: - - suffixes to add to the E set - + :param list s_union_s_dot_a: List of all prefixes found in observation table sorted from shortest to longest. + :param tuple cex: Counterexample. + :param str closedness: Either 'suffix' or 'prefix' (Default value = 'suffix'). + :return list: Suffixes to add to the E set. """ prefixes = s_union_s_dot_a prefixes.reverse() @@ -43,28 +45,20 @@ def longest_prefix_cex_processing(s_union_s_dot_a: list, cex: tuple, closedness= return suffixes -def rs_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness=True, closedness='suffix', - is_vpa=False, lower=None, upper=None): +def rs_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness: bool = True, closedness: str = 'suffix', + is_vpa: bool = False, lower: int | None = None, upper: int | None = None) -> list: """ Rivest-Schapire counter example processing. - Args: - - sul: system under learning - cex: found counterexample - hypothesis: hypothesis on which counterexample was found - suffix_closedness: If true all suffixes will be added, else just one (Default value = True) - closedness: either 'suffix' or 'prefix'. (Default value = 'suffix') - sul: SUL: system under learning - cex: tuple: counterexample - is_vpa: system under learning behaves as a context free language - upper: upper boarder for cex (from preprocessing), None will set it to 1 - lower: lower boarder for cex (from preprocessing), None will set it to len(cex_input) - 2 - - Returns: - - suffixes to be added to the E set - + :param SUL sul: System under learning. + :param tuple cex: Found counterexample. + :param hypothesis: Hypothesis on which counterexample was found. + :param bool suffix_closedness: If true all suffixes will be added, else just one (Default value = True). + :param str closedness: Either 'suffix' or 'prefix' (Default value = 'suffix'). + :param bool is_vpa: System under learning behaves as a context free language. + :param int | None lower: Lower border for cex (from preprocessing), None will set it to 1. + :param int | None upper: Upper border for cex (from preprocessing), None will set it to len(cex_input) - 2. + :return list: Suffixes to be added to the E set. """ cex_out = sul.query(cex) cex_input = list(cex) @@ -110,8 +104,21 @@ def rs_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness=True, return suffix_to_query -def linear_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness=True, closedness='suffix', - direction='fwd', is_vpa=False): +def linear_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness: bool = True, closedness: str = 'suffix', + direction: str = 'fwd', is_vpa: bool = False) -> list: + """ + Linear counterexample processing, scanning the counterexample from the front (or back) until the SUL and + hypothesis outputs diverge. + + :param SUL sul: System under learning. + :param tuple cex: Found counterexample. + :param hypothesis: Hypothesis on which counterexample was found. + :param bool suffix_closedness: If true all suffixes will be added, else just one (Default value = True). + :param str closedness: Either 'suffix' or 'prefix' (Default value = 'suffix'). + :param str direction: Direction of scanning, either 'fwd' or 'bwd'. + :param bool is_vpa: System under learning behaves as a context free language. + :return list: Suffixes to be added to the E set. + """ assert direction in {'fwd', 'bwd'} direction = 'fwd' @@ -157,8 +164,21 @@ def linear_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness=Tr return suffix_to_query -def exponential_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness=True, closedness='suffix', - direction='fwd', is_vpa=False): +def exponential_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness: bool = True, closedness: str = 'suffix', + direction: str = 'fwd', is_vpa: bool = False) -> list: + """ + Exponential (doubling) counterexample processing that searches for the divergence point using exponentially + growing steps before falling back to Rivest-Schapire binary search. + + :param SUL sul: System under learning. + :param tuple cex: Found counterexample. + :param hypothesis: Hypothesis on which counterexample was found. + :param bool suffix_closedness: If true all suffixes will be added, else just one (Default value = True). + :param str closedness: Either 'suffix' or 'prefix' (Default value = 'suffix'). + :param str direction: Direction of scanning, either 'fwd' or 'bwd'. + :param bool is_vpa: System under learning behaves as a context free language. + :return list: Suffixes to be added to the E set. + """ assert direction in {'fwd', 'bwd'} cex_out = sul.query(cex) @@ -215,5 +235,3 @@ def exponential_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedne return rs_cex_processing(sul, cex, hypothesis, suffix_closedness, closedness, is_vpa, lower=bp_recent) else: return rs_cex_processing(sul, cex, hypothesis, suffix_closedness, closedness, is_vpa, upper=bp_recent) - - diff --git a/aalpy/learning_algs/deterministic/KV.py b/aalpy/learning_algs/deterministic/KV.py index af998cfe8c6..2b596764fc6 100644 --- a/aalpy/learning_algs/deterministic/KV.py +++ b/aalpy/learning_algs/deterministic/KV.py @@ -1,9 +1,9 @@ +# KV active learning algorithm based on a classification tree. import time -from typing import Union from aalpy.automata import Dfa, DfaState, MealyState, MealyMachine, MooreState, MooreMachine, \ Sevpa, SevpaState, SevpaAlphabet -from aalpy.base import Oracle, SUL +from aalpy.base import Automaton, Oracle, SUL from aalpy.utils.HelperFunctions import print_learning_info, visualize_classification_tree from .ClassificationTree import ClassificationTree from .CounterExampleProcessing import counterexample_successfully_processed @@ -14,39 +14,27 @@ automaton_class = {'dfa': Dfa, 'mealy': MealyMachine, 'moore': MooreMachine, 'vpa': Sevpa} -def run_KV(alphabet: Union[list, SevpaAlphabet], sul: SUL, eq_oracle: Oracle, automaton_type, cex_processing='rs', - max_learning_rounds=None, cache_and_non_det_check=True, return_data=False, print_level=2): +def run_KV(alphabet: list | SevpaAlphabet, sul: SUL, eq_oracle: Oracle, automaton_type: str, + cex_processing: str = 'rs', max_learning_rounds: int | None = None, cache_and_non_det_check: bool = True, + return_data: bool = False, print_level: int = 2) -> Automaton | tuple[Automaton, dict]: """ Executes the KV algorithm. - Args: - - alphabet: input alphabet - - sul: system under learning - - eq_oracle: equivalence oracle - - automaton_type: type of automaton to be learned. One of 'dfa', 'mealy', 'moore', 'vpa' - - cex_processing: Counterexample processing strategy. Either 'rs' (Rivest-Schapire), 'longest_prefix'. - (Default value = 'rs'), 'longest_prefix', 'linear_fwd', 'linear_bwd', 'exponential_fwd', 'exponential_bwd' - - max_learning_rounds: number of learning rounds after which learning will terminate (Default value = None) - - cache_and_non_det_check: Use caching and non-determinism checks (Default value = True) - - return_data: if True, a map containing all information(runtime/#queries/#steps) will be returned - (Default value = False) - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - - Returns: - - automaton of type automaton_type (dict containing all information about learning if 'return_data' is True) - + :param list | SevpaAlphabet alphabet: Input alphabet. + :param SUL sul: System under learning. + :param Oracle eq_oracle: Equivalence oracle. + :param str automaton_type: Type of automaton to be learned. One of 'dfa', 'mealy', 'moore', 'vpa'. + :param str cex_processing: Counterexample processing strategy. Either 'rs' (Rivest-Schapire), 'longest_prefix'. + (Default value = 'rs'), 'longest_prefix', 'linear_fwd', 'linear_bwd', 'exponential_fwd', 'exponential_bwd'. + :param int | None max_learning_rounds: Number of learning rounds after which learning will terminate + (Default value = None). + :param bool cache_and_non_det_check: Use caching and non-determinism checks (Default value = True). + :param bool return_data: If True, a map containing all information (runtime/#queries/#steps) will be returned + (Default value = False). + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - + educational/debug (Default value = 2). + :return Automaton | tuple[Automaton, dict]: Automaton of type automaton_type (or a tuple of the automaton and + a dict containing all information about learning if 'return_data' is True). """ assert print_level in print_options diff --git a/aalpy/learning_algs/deterministic/LSharp.py b/aalpy/learning_algs/deterministic/LSharp.py index 541f419bc5a..bf0b46efd67 100644 --- a/aalpy/learning_algs/deterministic/LSharp.py +++ b/aalpy/learning_algs/deterministic/LSharp.py @@ -1,51 +1,41 @@ +# L# active learning algorithm based on apartness and an observation tree. import time -from aalpy.base import Oracle, SUL +from aalpy.base import Automaton, Oracle, SUL from aalpy.utils.HelperFunctions import print_learning_info from .ObservationTree import ObservationTree from ...base.SUL import CacheSUL -def run_Lsharp(alphabet: list, sul: SUL, eq_oracle: Oracle, automaton_type, - extension_rule='SepSeq', separation_rule="ADS", samples=None, - max_learning_rounds=None, cache_and_non_det_check=True, return_data=False, print_level=2): +def run_Lsharp(alphabet: list, sul: SUL, eq_oracle: Oracle, automaton_type: str, + extension_rule: str | None = 'SepSeq', separation_rule: str = "ADS", samples: list | None = None, + max_learning_rounds: int | None = None, cache_and_non_det_check: bool = True, + return_data: bool = False, print_level: int = 2) -> Automaton | tuple[Automaton, dict]: """ - Based on ''A New Approach for Active Automata Learning Based on Apartness'' from Vaandrager, Garhewal, Rot and Wissmann. + Based on ''A New Approach for Active Automata Learning Based on Apartness'' from Vaandrager, Garhewal, Rot and Wissmann. and ''L# for DFAs'' from Vaandrager, Sanders. - The algorithm learns a DFA/Moore machine/Mealy machine using apartness and an observation tree. - - Args: - - alphabet: input alphabet - - sul: system under learning - - eq_oracle: equivalence oracle - - automaton_type: type of automaton to be learned. Either 'dfa', 'mealy' or 'moore' - - extension_rule: strategy used during the extension rule. Options: None, "SepSeq" (default) and "ADS". - - separation_rule: strategy used during the extension rule. Options: "SepSeq" (default) and "ADS". - - samples: input output traces provided to the learning algorithm. They are added to cache and could reduce - total interaction with the system. Syntax: list of [(input_sequence, output_sequence)] or None - - max_learning_rounds: number of learning rounds after which learning will terminate (Default value = None) - - cache_and_non_det_check: Use caching and non-determinism checks (Default value = True) - - return_data: if True, a map containing all information(runtime/#queries/#steps) will be returned - (Default value = False) - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - Returns: - - automaton of type automaton_type (dict containing all information about learning if 'return_data' is True) - + The algorithm learns a DFA/Moore machine/Mealy machine using apartness and an observation tree. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param Oracle eq_oracle: Equivalence oracle. + :param str automaton_type: Type of automaton to be learned. Either 'dfa', 'mealy' or 'moore'. + :param str | None extension_rule: Strategy used during the extension rule. Options: None, "SepSeq" + (default) and "ADS". + :param str separation_rule: Strategy used during the extension rule. Options: "SepSeq" (default) and "ADS". + :param list | None samples: Input output traces provided to the learning algorithm. They are added to cache + and could reduce total interaction with the system. Syntax: list of [(input_sequence, output_sequence)] + or None. + :param int | None max_learning_rounds: Number of learning rounds after which learning will terminate + (Default value = None). + :param bool cache_and_non_det_check: Use caching and non-determinism checks (Default value = True). + :param bool return_data: If True, a map containing all information (runtime/#queries/#steps) will be returned + (Default value = False). + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - + educational/debug (Default value = 2). + :return Automaton | tuple[Automaton, dict]: Automaton of type automaton_type (or a tuple of the automaton and + a dict containing all information about learning if 'return_data' is True). """ assert extension_rule in {None, "SepSeq", "ADS"} assert separation_rule in {"SepSeq", "ADS"} diff --git a/aalpy/learning_algs/deterministic/LStar.py b/aalpy/learning_algs/deterministic/LStar.py index a19c207e80a..1624276f259 100644 --- a/aalpy/learning_algs/deterministic/LStar.py +++ b/aalpy/learning_algs/deterministic/LStar.py @@ -1,6 +1,7 @@ +# L* (Angluin) active learning algorithm based on the observation table. import time -from aalpy.base import Oracle, SUL +from aalpy.base import Automaton, Oracle, SUL from aalpy.utils.HelperFunctions import extend_set, print_learning_info, print_observation_table, all_prefixes from .CounterExampleProcessing import longest_prefix_cex_processing, rs_cex_processing, \ counterexample_successfully_processed, linear_cex_processing, exponential_cex_processing @@ -13,53 +14,40 @@ print_options = [0, 1, 2, 3] -def run_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, automaton_type, samples=None, - closing_strategy='shortest_first', cex_processing='rs', - e_set_suffix_closed=False, all_prefixes_in_obs_table=True, - max_learning_rounds=None, cache_and_non_det_check=True, return_data=False, print_level=2): +def run_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, automaton_type: str, samples: list | None = None, + closing_strategy: str = 'shortest_first', cex_processing: str | None = 'rs', + e_set_suffix_closed: bool = False, all_prefixes_in_obs_table: bool = True, + max_learning_rounds: int | None = None, cache_and_non_det_check: bool = True, + return_data: bool = False, print_level: int = 2) -> Automaton | tuple[Automaton, dict]: """ Executes L* algorithm. - Args: - - alphabet: input alphabet - - sul: system under learning - - eq_oracle: equivalence oracle - - automaton_type: type of automaton to be learned. Either 'dfa', 'mealy' or 'moore'. - - samples: input output traces provided to the learning algorithm. They are added to cache and could reduce - total interaction with the system. Syntax: list of [(input_sequence, output_sequence)] or None - - closing_strategy: closing strategy used in the close method. Either 'longest_first', 'shortest_first' or - 'single' (Default value = 'shortest_first') - - cex_processing: Counterexample processing strategy. Either None, 'rs' (Rivest-Schapire), 'longest_prefix'. - (Default value = 'rs'), 'longest_prefix', 'linear_fwd', 'linear_bwd', 'exponential_fwd', 'exponential_bwd' - - e_set_suffix_closed: True option ensures that E set is suffix closed, - False adds just a single suffix per counterexample. - - all_prefixes_in_obs_table: if True, entries of observation table will contain the whole output of the whole - suffix, otherwise just the last output meaning that all prefixes of the suffix will be added. - If False, just a single suffix will be added. - - max_learning_rounds: number of learning rounds after which learning will terminate (Default value = None) - - cache_and_non_det_check: Use caching and non-determinism checks (Default value = True) - - return_data: if True, a map containing all information(runtime/#queries/#steps) will be returned - (Default value = False) - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - Returns: - - automaton of type automaton_type (dict containing all information about learning if 'return_data' is True) - + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param Oracle eq_oracle: Equivalence oracle. + :param str automaton_type: Type of automaton to be learned. Either 'dfa', 'mealy' or 'moore'. + :param list | None samples: Input output traces provided to the learning algorithm. They are added to cache + and could reduce total interaction with the system. Syntax: list of [(input_sequence, output_sequence)] + or None. + :param str closing_strategy: Closing strategy used in the close method. Either 'longest_first', + 'shortest_first' or 'single' (Default value = 'shortest_first'). + :param str | None cex_processing: Counterexample processing strategy. Either None, 'rs' (Rivest-Schapire), + 'longest_prefix'. (Default value = 'rs'), 'longest_prefix', 'linear_fwd', 'linear_bwd', 'exponential_fwd', + 'exponential_bwd'. + :param bool e_set_suffix_closed: True option ensures that E set is suffix closed, False adds just a single + suffix per counterexample. + :param bool all_prefixes_in_obs_table: If True, entries of observation table will contain the whole output of + the whole suffix, otherwise just the last output meaning that all prefixes of the suffix will be added. + If False, just a single suffix will be added. + :param int | None max_learning_rounds: Number of learning rounds after which learning will terminate + (Default value = None). + :param bool cache_and_non_det_check: Use caching and non-determinism checks (Default value = True). + :param bool return_data: If True, a map containing all information (runtime/#queries/#steps) will be returned + (Default value = False). + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - + educational/debug (Default value = 2). + :return Automaton | tuple[Automaton, dict]: Automaton of type automaton_type (or a tuple of the automaton and + a dict containing all information about learning if 'return_data' is True). """ assert cex_processing in counterexample_processing_strategy diff --git a/aalpy/learning_algs/deterministic/ObservationTable.py b/aalpy/learning_algs/deterministic/ObservationTable.py index 124d1bdf8fd..f801a7380ce 100644 --- a/aalpy/learning_algs/deterministic/ObservationTable.py +++ b/aalpy/learning_algs/deterministic/ObservationTable.py @@ -1,3 +1,4 @@ +# Angluin-style observation table (S, E, T) used by the L* learning algorithm. from collections import defaultdict from aalpy.base import Automaton, SUL @@ -8,18 +9,20 @@ class ObservationTable: - def __init__(self, alphabet: list, sul: SUL, automaton_type, prefixes_in_cell=False): + """ + Angluin-style observation table over an S set of prefixes, an E set of suffixes, and a T function mapping + prefixes to rows of observed outputs. + """ + + def __init__(self, alphabet: list, sul: SUL, automaton_type: str, prefixes_in_cell: bool = False) -> None: """ Constructor of the observation table. Initial queries are asked in the constructor. - Args: - - alphabet: input alphabet - sul: system under learning - automaton_type: automaton type, one of ['dfa', 'mealy', 'moore'] - - Returns: - + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param str automaton_type: Automaton type, one of ['dfa', 'mealy', 'moore']. + :param bool prefixes_in_cell: If True add prefixes of each element of E set to a cell, else only add the + output (Default value = False). """ assert automaton_type in aut_type assert alphabet is not None and sul is not None @@ -46,7 +49,7 @@ def __init__(self, alphabet: list, sul: SUL, automaton_type, prefixes_in_cell=Fa if self.automaton_type == 'dfa' or self.automaton_type == 'moore': self.E.insert(0, empty_word) - def get_rows_to_close(self, closing_strategy='longest_first'): + def get_rows_to_close(self, closing_strategy: str = 'longest_first') -> list | None: """ Get rows for that need to be closed. Row selection is done according to closing_strategy. The length of the row is defined by the length of the prefix corresponding to the row in the S set. @@ -55,14 +58,9 @@ def get_rows_to_close(self, closing_strategy='longest_first'): single -> find and ask membership query for the single row single_longest -> returns single longest row to close - Args: - - closing_strategy: one of ['shortest_first', 'longest_first', 'single'] (Default value = 'longest_first') - - Returns: - - list if non-closed exist, None otherwise: rows that will be moved to S set and closed - + :param str closing_strategy: One of ['shortest_first', 'longest_first', 'single'] (Default value = + 'longest_first'). + :return list | None: Rows that will be moved to S set and closed, or None if all rows are already closed. """ assert closing_strategy in closing_options rows_to_close = [] @@ -91,16 +89,13 @@ def get_rows_to_close(self, closing_strategy='longest_first'): return rows_to_close - def get_causes_of_inconsistency(self): + def get_causes_of_inconsistency(self) -> list | None: """ - If the two rows in the S set are the same, but their one letter extensions are not, this method founds + If the two rows in the S set are the same, but their one letter extensions are not, this method finds the cause of inconsistency and returns it. - :return: - - Returns: - - a+e values that are the causes of inconsistency + :return list | None: A single-element list containing the a+e value that is the cause of inconsistency, + or None if the table is consistent. """ for i, s1 in enumerate(self.S): for s2 in self.S[i + 1:]: @@ -116,6 +111,8 @@ def get_causes_of_inconsistency(self): def s_dot_a(self): """ Helper generator function that returns extended S, or S.A set. + + :return: Generator over elements of S.A that are not already in S. """ s_set = set(self.S) for s in self.S: @@ -123,18 +120,14 @@ def s_dot_a(self): if s + a not in s_set: yield s + a - def update_obs_table(self, s_set: list = None, e_set: list = None): + def update_obs_table(self, s_set: list = None, e_set: list = None) -> None: """ Perform the membership queries. - Args: - - s_set: Prefixes of S set on which to preform membership queries. If None, then whole S set will be used. - - e_set: Suffixes of E set on which to perform membership queries. If None, then whole E set will be used. - - Returns: - + :param list s_set: Prefixes of S set on which to perform membership queries. If None, then whole S set + (plus S.A) will be used. + :param list e_set: Suffixes of E set on which to perform membership queries. If None, then whole E set + will be used. """ update_S = s_set if s_set else list(self.S) + list(self.s_dot_a()) @@ -153,19 +146,13 @@ def update_obs_table(self, s_set: list = None, e_set: list = None): obs_table_entry = (output[-1],) self.T[s] += obs_table_entry - def gen_hypothesis(self, no_cex_processing_used=False) -> Automaton: + def gen_hypothesis(self, no_cex_processing_used: bool = False) -> Automaton: """ Generate automaton based on the values found in the observation table. - :return: - - Args: - - check_for_duplicate_rows: (Default value = False) - - Returns: - - Automaton of type `automaton_type` + :param bool no_cex_processing_used: If True, row representatives (deduplicated by row value) are used + instead of the full S set, since no counterexample processing has narrowed it (Default value = False). + :return Automaton: Automaton of type `automaton_type`. """ state_distinguish = dict() states_dict = dict() @@ -210,7 +197,12 @@ def gen_hypothesis(self, no_cex_processing_used=False) -> Automaton: return automaton - def _get_row_representatives(self): + def _get_row_representatives(self) -> list: + """ + Selects a single representative prefix per distinct row value, preferring the shortest prefix. + + :return list: List of representative prefixes, one per distinct row value. + """ self.S.sort(key=len) representatives = defaultdict(list) for prefix in self.S: diff --git a/aalpy/learning_algs/deterministic/ObservationTree.py b/aalpy/learning_algs/deterministic/ObservationTree.py index 2c062d7b73d..61f1dab4573 100644 --- a/aalpy/learning_algs/deterministic/ObservationTree.py +++ b/aalpy/learning_algs/deterministic/ObservationTree.py @@ -1,14 +1,27 @@ +# Observation tree data structure and hypothesis construction/counterexample handling for the L# algorithm. +from typing import Any + from .ADS import Ads from .Apartness import Apartness from ... import Dfa, DfaState, MealyState, MealyMachine, MooreMachine, MooreState aut_type = ['dfa', 'mealy', 'moore'] + class MooreNode: + """ + Single node of the observation tree for DFA/Moore machines, storing its output and successors. + """ + _id_counter = 0 __slots__ = ['id', 'output', 'successors', 'parent', 'input_to_parent'] - def __init__(self, parent=None): + def __init__(self, parent: 'MooreNode | None' = None) -> None: + """ + Creates a Moore/DFA observation-tree node. + + :param MooreNode | None parent: Parent node, or None for the root. + """ MooreNode._id_counter += 1 self.id = MooreNode._id_counter self.output = None @@ -16,22 +29,39 @@ def __init__(self, parent=None): self.parent = parent self.input_to_parent = None - def __hash__(self): + def __hash__(self) -> int: return hash(self.id) - def add_successor(self, input_val, output_val, successor_node): - """ Adds a successor node to the current node based on input """ + def add_successor(self, input_val: Any, output_val: Any, successor_node: 'MooreNode') -> None: + """ + Adds a successor node to the current node based on input. + + :param Any input_val: Input leading to the successor. + :param Any output_val: Output observed at the successor. + :param MooreNode successor_node: The successor node. + """ self.successors[input_val] = successor_node self.successors[input_val].output = output_val - def get_successor(self, input_val): - """ Returns the successor node for the given input """ + def get_successor(self, input_val: Any) -> 'MooreNode | None': + """ + Returns the successor node for the given input. + + :param Any input_val: Input to look up. + :return MooreNode | None: The successor node, or None if not present. + """ if input_val in self.successors: return self.successors[input_val] return None - def extend_and_get(self, inp, output): - """ Extend the node with a new successor and return the successor node """ + def extend_and_get(self, inp: Any, output: Any) -> 'MooreNode': + """ + Extend the node with a new successor and return the successor node. + + :param Any inp: Input leading to the (possibly new) successor. + :param Any output: Output observed at the successor. + :return MooreNode: The (possibly newly created) successor node. + """ if inp in self.successors: return self.successors[inp] successor_node = MooreNode(parent=self) @@ -40,42 +70,76 @@ def extend_and_get(self, inp, output): return successor_node @property - def id_counter(self): + def id_counter(self) -> int: + """ + :return int: Total number of Moore/DFA observation-tree nodes created so far. + """ return self._id_counter class MealyNode: + """ + Single node of the observation tree for Mealy machines, storing (output, successor) pairs per input. + """ + _id_counter = 0 __slots__ = ['id', 'successors', 'parent', 'input_to_parent'] - def __init__(self, parent=None): + def __init__(self, parent: 'MealyNode | None' = None) -> None: + """ + Creates a Mealy observation-tree node. + + :param MealyNode | None parent: Parent node, or None for the root. + """ MealyNode._id_counter += 1 self.id = MealyNode._id_counter self.successors = {} self.parent = parent self.input_to_parent = None - def __hash__(self): + def __hash__(self) -> int: return hash(self.id) - def add_successor(self, input_val, output_val, successor_node): - """ Adds a successor node to the current node based on input """ + def add_successor(self, input_val: Any, output_val: Any, successor_node: 'MealyNode') -> None: + """ + Adds a successor node to the current node based on input. + + :param Any input_val: Input leading to the successor. + :param Any output_val: Output observed for this input. + :param MealyNode successor_node: The successor node. + """ self.successors[input_val] = (output_val, successor_node) - def get_successor(self, input_val): - """ Returns the successor node for the given input """ + def get_successor(self, input_val: Any) -> 'MealyNode | None': + """ + Returns the successor node for the given input. + + :param Any input_val: Input to look up. + :return MealyNode | None: The successor node, or None if not present. + """ if input_val in self.successors: return self.successors[input_val][1] return None - def get_output(self, input_val): - """ Returns the output for the given input """ + def get_output(self, input_val: Any) -> Any: + """ + Returns the output for the given input. + + :param Any input_val: Input to look up. + :return Any: The observed output, or None if not present. + """ if input_val in self.successors: return self.successors[input_val][0] return None - def extend_and_get(self, inp, output): - """ Extend the node with a new successor and return the successor node """ + def extend_and_get(self, inp: Any, output: Any) -> 'MealyNode': + """ + Extend the node with a new successor and return the successor node. + + :param Any inp: Input leading to the (possibly new) successor. + :param Any output: Output observed for this input. + :return MealyNode: The (possibly newly created) successor node. + """ if inp in self.successors: out = self.successors[inp][0] if out != output: @@ -88,14 +152,29 @@ def extend_and_get(self, inp, output): return successor_node @property - def id_counter(self): + def id_counter(self) -> int: + """ + :return int: Total number of Mealy observation-tree nodes created so far. + """ return self._id_counter class ObservationTree: - def __init__(self, alphabet, sul, automaton_type, extension_rule, separation_rule): + """ + Observation tree used by the L# algorithm. Stores all input/output observations made so far, tracks basis + (identified) and frontier (candidate) states, and constructs/updates hypotheses using apartness. + """ + + def __init__(self, alphabet: list, sul, automaton_type: str, extension_rule: str | None, + separation_rule: str) -> None: """ - Initialize the tree with a root node and the alphabet + Initialize the tree with a root node and the alphabet. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param str automaton_type: Automaton type, one of ['dfa', 'mealy', 'moore']. + :param str | None extension_rule: Extension rule, one of [None, 'SepSeq', 'ADS']. + :param str separation_rule: Separation rule, one of ['SepSeq', 'ADS']. """ assert automaton_type in aut_type assert alphabet is not None and sul is not None @@ -124,8 +203,13 @@ def __init__(self, alphabet, sul, automaton_type, extension_rule, separation_rul # Reverse map used during counterexample processing. self.state_to_tree_node = dict() - def insert_observation(self, inputs, outputs): - # Insert an observation into the tree using sequences of inputs and outputs + def insert_observation(self, inputs: list, outputs: list) -> None: + """ + Insert an observation into the tree using sequences of inputs and outputs. + + :param list inputs: Input sequence. + :param list outputs: Output sequence observed for the inputs. + """ if len(inputs) != len(outputs): raise ValueError("Inputs and outputs must have the same length.") @@ -133,8 +217,13 @@ def insert_observation(self, inputs, outputs): for input_val, output_val in zip(inputs, outputs): current_node = current_node.extend_and_get(input_val, output_val) - def get_observation(self, inputs): - # Retrieve the list of outputs based on a given input sequence + def get_observation(self, inputs: list) -> list | None: + """ + Retrieve the list of outputs based on a given input sequence. + + :param list inputs: Input sequence to look up from the root. + :return list | None: List of outputs, or None if the sequence is not in the tree. + """ current_node = self.root observation = [] for input_val in inputs: @@ -149,8 +238,14 @@ def get_observation(self, inputs): observation.append(output) return observation - def get_outputs(self, basis_state, inputs): - # Retrieve the list of outputs based on a basis state and a given input sequence + def get_outputs(self, basis_state, inputs: list) -> list | None: + """ + Retrieve the list of outputs based on a basis state and a given input sequence. + + :param basis_state: Basis observation-tree node to start from. + :param list inputs: Input sequence to look up. + :return list | None: List of outputs, or None if the sequence is not in the tree. + """ prefix = self.get_transfer_sequence(self.root, basis_state) current_node = self.get_successor(prefix) observation = [] @@ -166,8 +261,13 @@ def get_outputs(self, basis_state, inputs): return observation - def get_successor(self, inputs): - # Retrieve the node (subtree) corresponding to the given input sequence + def get_successor(self, inputs: list): + """ + Retrieve the node (subtree) corresponding to the given input sequence. + + :param list inputs: Input sequence from the root. + :return: The observation-tree node reached, or None if the sequence is not in the tree. + """ current_node = self.root for input_val in inputs: successor_node = current_node.get_successor(input_val) @@ -177,8 +277,14 @@ def get_successor(self, inputs): return current_node - def get_transfer_sequence(self, from_node, to_node): - # Get the transfer sequence (inputs) that moves from one node to another + def get_transfer_sequence(self, from_node, to_node) -> list | None: + """ + Get the transfer sequence (inputs) that moves from one node to another. + + :param from_node: Observation-tree node to start from. + :param to_node: Observation-tree node to reach. + :return list | None: The transfer input sequence, or None if `to_node` is not a descendant of `from_node`. + """ transfer_sequence = [] current_node = to_node @@ -191,8 +297,13 @@ def get_transfer_sequence(self, from_node, to_node): transfer_sequence.reverse() return transfer_sequence - def get_access_sequence(self, to_node): - # Get the transfer sequence (inputs) that moves from one node to another + def get_access_sequence(self, to_node) -> tuple | None: + """ + Get the transfer sequence (inputs) that moves from one node to another. + + :param to_node: Observation-tree node to reach from the root. + :return tuple | None: The access sequence, or None if `to_node` is not reachable from the root. + """ transfer_sequence = [] current_node = to_node @@ -205,21 +316,28 @@ def get_access_sequence(self, to_node): transfer_sequence.reverse() return tuple(transfer_sequence) - def get_size(self): + def get_size(self) -> int: + """ + :return int: Total number of nodes created in this observation tree. + """ return self.root.id_counter # Functions related to finding new basis and frontier states - def update_frontier_and_basis(self): - # Updates the frontier to basis map, promotes a frontier state and checks for consistency + def update_frontier_and_basis(self) -> None: + """ + Updates the frontier to basis map, promotes a frontier state and checks for consistency. + """ self.update_frontier_to_basis_dict() self.promote_frontier_state() self.check_frontier_consistency() self.update_frontier_to_basis_dict() - def update_basis_candidates(self, frontier_state): + def update_basis_candidates(self, frontier_state) -> None: """ Updates the basis candidates for the specified frontier state. Removes basis states that are deemed apart from the frontier state. + + :param frontier_state: Frontier observation-tree node to update candidates for. """ if frontier_state not in self.frontier_to_basis_dict: print( @@ -230,7 +348,7 @@ def update_basis_candidates(self, frontier_state): self.frontier_to_basis_dict[frontier_state] = [basis_state for basis_state in basis_list if not Apartness.states_are_apart(frontier_state, basis_state, self)] - def update_frontier_to_basis_dict(self): + def update_frontier_to_basis_dict(self) -> None: """ Checks for basis candidates (basis states with the same behavior) for each frontier state. If a frontier state and a basis state are "apart", the basis state is removed from the basis list. @@ -241,9 +359,10 @@ def update_frontier_to_basis_dict(self): basis_state for basis_state in basis_list if not Apartness.states_are_apart(frontier_state, basis_state, self)] - def promote_frontier_state(self): + def promote_frontier_state(self) -> None: """ - Searches for an isolated frontier state and adds it to the basis states if it is not associated with another basis state + Searches for an isolated frontier state and adds it to the basis states if it is not associated with + another basis state. """ for iso_frontier_state, basis_list in self.frontier_to_basis_dict.items(): if not basis_list: @@ -256,9 +375,9 @@ def promote_frontier_state(self): new_basis_list.append(new_basis) break - def check_frontier_consistency(self): + def check_frontier_consistency(self) -> None: """ - Checks if all the states are correctly defined and creates new frontier states when possible + Checks if all the states are correctly defined and creates new frontier states when possible. """ for basis_state in self.basis: for i in self.alphabet: @@ -271,9 +390,13 @@ def check_frontier_consistency(self): if not Apartness.states_are_apart(new_basis_state, maybe_frontier, self) ] - def is_observation_tree_adequate(self): - # Check if the frontier state have only 1 basis candidate, and if all basis - # states have some output for every input. + def is_observation_tree_adequate(self) -> bool: + """ + Check if the frontier state have only 1 basis candidate, and if all basis states have some output for + every input. + + :return bool: True if the observation tree is adequate to build a hypothesis, False otherwise. + """ self.check_frontier_consistency() for _, basis_list in self.frontier_to_basis_dict.items(): if len(basis_list) != 1: @@ -290,8 +413,10 @@ def is_observation_tree_adequate(self): return True - def make_basis_complete(self): - # Explore new frontier states and adding them to the frontier to basis map + def make_basis_complete(self) -> None: + """ + Explore new frontier states and add them to the frontier to basis map. + """ for basis_state in self.basis: for inp in self.alphabet: if basis_state.get_successor(inp) is None: @@ -300,14 +425,25 @@ def make_basis_complete(self): basis_candidates = self.find_basis_candidates(new_frontier) self.frontier_to_basis_dict[new_frontier] = basis_candidates - def find_basis_candidates(self, new_frontier): + def find_basis_candidates(self, new_frontier) -> set: + """ + Finds basis states that are not apart from the given frontier state. + + :param new_frontier: Frontier observation-tree node to find candidates for. + :return set: Set of basis states that could correspond to `new_frontier`. + """ return { new_basis_state for new_basis_state in self.basis if not Apartness.states_are_apart(new_basis_state, new_frontier, self) } - def explore_frontier(self, basis_state, inp): - # Explores a specific frontier state (basis state + input) by passing a query to the SUL + def explore_frontier(self, basis_state, inp: Any) -> None: + """ + Explores a specific frontier state (basis state + input) by passing a query to the SUL. + + :param basis_state: Basis observation-tree node to extend. + :param Any inp: Input to explore from `basis_state`. + """ if self.extension_rule is None or (self.extension_rule == "SepSeq" and len(self.basis) == 1): inputs = self.get_transfer_sequence(self.root, basis_state) inputs.append(inp) @@ -330,13 +466,26 @@ def explore_frontier(self, basis_state, inp): outputs = self.sul.query(inputs) self.insert_observation(inputs, outputs) - def adaptive_output_query(self, prefix, infix, ads): - # Adds input to the prefix and calls the base function + def adaptive_output_query(self, prefix: list, infix: Any, ads: Ads) -> tuple[list, list]: + """ + Adds input to the prefix and calls the base function. + + :param list prefix: Prefix input sequence (transfer sequence to the basis state). + :param Any infix: Input to append to the prefix before the adaptive query. + :param Ads ads: Adaptive distinguishing sequence to use for the query. + :return tuple[list, list]: The (possibly extended) inputs and the outputs received. + """ prefix.append(infix) return self.adaptive_output_query_base(prefix, ads) - def adaptive_output_query_base(self, prefix, ads): - # Query the tree for a result, if unsuccessful query the SUL and update the tree + def adaptive_output_query_base(self, prefix: list, ads: Ads) -> tuple[list, list]: + """ + Query the tree for a result, if unsuccessful query the SUL and update the tree. + + :param list prefix: Input sequence from the root to the node the ADS should be run from. + :param Ads ads: Adaptive distinguishing sequence to use for the query. + :return tuple[list, list]: The inputs sent and the outputs received. + """ from_node = self.get_successor(prefix) if from_node: tree_in, tree_out = self._answer_ads_from_tree(ads, from_node) @@ -358,8 +507,15 @@ def adaptive_output_query_base(self, prefix, ads): return sul_in, outputs - def _answer_ads_from_tree(self, ads, from_node): - # searches the tree based on the inputs returning the inputs/outputs when all ads inputs are used + def _answer_ads_from_tree(self, ads: Ads, from_node) -> tuple[list | None, list | None]: + """ + Searches the tree based on the inputs, returning the inputs/outputs when all ADS inputs are used. + + :param Ads ads: Adaptive distinguishing sequence being answered. + :param from_node: Observation-tree node to start answering the ADS from. + :return tuple[list | None, list | None]: Inputs sent and outputs received, or (None, None) if the tree + cannot answer the full ADS. + """ prev_output = None inputs_sent = [] outputs_received = [] @@ -389,10 +545,14 @@ def _answer_ads_from_tree(self, ads, from_node): ads.reset_to_root() return inputs_sent, outputs_received - def get_or_compute_witness(self, state_one, state_two): + def get_or_compute_witness(self, state_one, state_two) -> list | None: """ Get witness by checking cache and computing it otherwise. Only add pairs (a,b) with a < b. + + :param state_one: First observation-tree node (basis state). + :param state_two: Second observation-tree node (basis state). + :return list | None: Distinguishing input sequence between the two states, or None. """ if state_one.id < state_two.id: pair = (state_one.id, state_two.id) @@ -406,13 +566,19 @@ def get_or_compute_witness(self, state_one, state_two): self.witness_cache[pair] = witness return witness - def make_frontiers_identified(self): - # Loop over all frontier states to identify them + def make_frontiers_identified(self) -> None: + """ + Loop over all frontier states to identify them. + """ for frontier_state in self.frontier_to_basis_dict: self.identify_frontier(frontier_state) - def identify_frontier(self, frontier_state): - # Identify a specific frontier state + def identify_frontier(self, frontier_state) -> None: + """ + Identify a specific frontier state. + + :param frontier_state: Frontier observation-tree node to identify. + """ if frontier_state not in self.frontier_to_basis_dict: raise Exception( f"Warning: {frontier_state} not found in frontier_to_basis_dict.") @@ -433,8 +599,13 @@ def identify_frontier(self, frontier_state): if len(self.frontier_to_basis_dict.get(frontier_state)) == old_candidate_size: raise RuntimeError("Identification did not increase the norm") - def _identify_frontier_sepseq(self, frontier_state): - # Specifically identify frontier states using separating sequences + def _identify_frontier_sepseq(self, frontier_state) -> tuple[list, list]: + """ + Specifically identify frontier states using separating sequences. + + :param frontier_state: Frontier observation-tree node to identify. + :return tuple[list, list]: Inputs sent and outputs received. + """ basis_candidates = self.frontier_to_basis_dict.get(frontier_state) basis_one = basis_candidates[0] basis_two = basis_candidates[1] @@ -447,15 +618,22 @@ def _identify_frontier_sepseq(self, frontier_state): return inputs, outputs - def _identify_frontier_ads(self, frontier_state): - # Specifically identify frontier states using ADS + def _identify_frontier_ads(self, frontier_state) -> tuple[list, list]: + """ + Specifically identify frontier states using ADS. + + :param frontier_state: Frontier observation-tree node to identify. + :return tuple[list, list]: Inputs sent and outputs received. + """ basis_candidates = self.frontier_to_basis_dict.get(frontier_state) ads = Ads(self, basis_candidates) ads.reset_to_root() return self.adaptive_output_query_base(self.get_transfer_sequence(self.root, frontier_state), ads) - def construct_hypothesis_states(self): - # Construct the hypothesis states from the basis + def construct_hypothesis_states(self) -> None: + """ + Construct the hypothesis states from the basis. + """ self.states_dict = dict() self.state_to_tree_node = dict() state_counter = 0 @@ -473,8 +651,10 @@ def construct_hypothesis_states(self): self.state_to_tree_node[self.states_dict[basis_state]] = basis_state state_counter += 1 - def construct_hypothesis_transitions(self): - # Construct the hypothesis transitions from the basis, frontier and basis to frontier mapping + def construct_hypothesis_transitions(self) -> None: + """ + Construct the hypothesis transitions from the basis, frontier and basis to frontier mapping. + """ for basis_state in self.basis: for input_val in self.alphabet: # set transition @@ -497,7 +677,11 @@ def construct_hypothesis_transitions(self): input_val) def construct_hypothesis(self): - # Construct a hypothesis (Mealy Machine) based on the observation tree + """ + Construct a hypothesis automaton based on the observation tree. + + :return: The constructed hypothesis automaton (Dfa, MealyMachine or MooreMachine). + """ self.construct_hypothesis_states() self.construct_hypothesis_transitions() @@ -510,7 +694,11 @@ def construct_hypothesis(self): return hypothesis def build_hypothesis(self): - # Builds the hypothesis which will be sent to the SUL and checks consistency + """ + Builds the hypothesis which will be sent to the SUL and checks consistency. + + :return: The constructed, consistent hypothesis automaton. + """ while True: self.make_observation_tree_adequate() hypothesis = self.construct_hypothesis() @@ -522,8 +710,10 @@ def build_hypothesis(self): cex_outputs = self.get_observation(counter_example) self.process_counter_example(hypothesis, counter_example, cex_outputs) - def make_observation_tree_adequate(self): - # Updates the frontier and basis based on extension and separation rule + def make_observation_tree_adequate(self) -> None: + """ + Updates the frontier and basis based on extension and separation rule until the tree is adequate. + """ self.update_frontier_and_basis() while not self.is_observation_tree_adequate(): self.make_basis_complete() @@ -532,10 +722,14 @@ def make_observation_tree_adequate(self): # Counterexample Processing - def process_counter_example(self, hypothesis, cex_inputs, cex_outputs): + def process_counter_example(self, hypothesis, cex_inputs: list, cex_outputs: list) -> None: """ Inserts the counter example into the observation tree and searches for the - input-output sequence which is different + input-output sequence which is different. + + :param hypothesis: Hypothesis on which the counterexample was found. + :param list cex_inputs: Counterexample input sequence. + :param list cex_outputs: Outputs observed on the SUL for `cex_inputs`. """ self.insert_observation(cex_inputs, cex_outputs) hyp_outputs = hypothesis.compute_output_seq( @@ -545,16 +739,26 @@ def process_counter_example(self, hypothesis, cex_inputs, cex_outputs): self._process_binary_search( hypothesis, cex_inputs[:prefix_index], cex_outputs[:prefix_index]) - def _get_counter_example_prefix_index(self, cex_outputs, hyp_outputs): - """ Checks at which index the output functions differ """ + def _get_counter_example_prefix_index(self, cex_outputs: list, hyp_outputs: list) -> int: + """ + Checks at which index the output functions differ. + + :param list cex_outputs: Outputs observed on the SUL. + :param list hyp_outputs: Outputs computed on the hypothesis. + :return int: Index of the first differing output. + """ for index in range(len(cex_outputs)): if cex_outputs[index] != hyp_outputs[index]: return index raise RuntimeError("counterexample and hypothesis outputs are equal") - def _process_binary_search(self, hypothesis, cex_inputs, cex_outputs): + def _process_binary_search(self, hypothesis, cex_inputs: list, cex_outputs: list) -> None: """ - use binary search on the counter example to compute a witness between the real system and the hypothesis + Use binary search on the counter example to compute a witness between the real system and the hypothesis. + + :param hypothesis: Hypothesis on which the counterexample was found. + :param list cex_inputs: Counterexample input sequence (prefix up to the divergence point). + :param list cex_outputs: Outputs observed on the SUL for `cex_inputs`. """ tree_node = self.get_successor(cex_inputs) self.update_frontier_and_basis() @@ -603,7 +807,15 @@ def _process_binary_search(self, hypothesis, cex_inputs, cex_outputs): self._process_binary_search( hypothesis, new_inputs, query_outputs[:len(new_inputs)]) - def _get_automaton_successor(self, automaton, from_state, inputs): + def _get_automaton_successor(self, automaton, from_state, inputs: list): + """ + Executes an input sequence on the given automaton starting from a state and returns the reached state. + + :param automaton: Automaton to execute the sequence on. + :param from_state: State to start execution from. + :param list inputs: Input sequence to execute. + :return: The state reached after executing `inputs`. + """ automaton.current_state = from_state for inp in inputs: automaton.current_state = automaton.current_state.transitions[inp] @@ -611,6 +823,12 @@ def _get_automaton_successor(self, automaton, from_state, inputs): return automaton.current_state def _get_tree_node_for_hyp_state(self, hyp_state): + """ + Looks up the observation-tree node corresponding to a hypothesis state. + + :param hyp_state: Hypothesis state to look up. + :return: The corresponding observation-tree node. + """ try: return self.state_to_tree_node[hyp_state] except KeyError as exc: diff --git a/aalpy/learning_algs/deterministic_passive/ClassicRPNI.py b/aalpy/learning_algs/deterministic_passive/ClassicRPNI.py index 24f1bf59bc4..8cd1ecd5e54 100644 --- a/aalpy/learning_algs/deterministic_passive/ClassicRPNI.py +++ b/aalpy/learning_algs/deterministic_passive/ClassicRPNI.py @@ -1,11 +1,25 @@ +# Classic (non-generalized) RPNI passive learning algorithm implementation. import time from bisect import insort +from typing import Any + from aalpy.learning_algs.deterministic_passive.rpni_helper_functions import to_automaton, createPTA, \ - check_sequence, extract_unique_sequences + check_sequence, extract_unique_sequences, RpniNode class ClassicRPNI: - def __init__(self, data, automaton_type, print_info=True): + """ + Classic RPNI implementation that performs state merging directly on a copy of the PTA for every merge attempt. + """ + + def __init__(self, data: list, automaton_type: str, print_info: bool = True) -> None: + """ + Creates a ClassicRPNI instance and constructs the prefix tree acceptor (PTA) from the data. + + :param list data: Sequence of (input sequence, label) pairs. + :param str automaton_type: Either 'dfa', 'mealy', or 'moore'. + :param bool print_info: Whether to print learning progress and runtime information. + """ self.data = data self.automaton_type = automaton_type self.print_info = print_info @@ -17,7 +31,12 @@ def __init__(self, data, automaton_type, print_info=True): if self.print_info: print(f'PTA Construction Time: {round(time.time() - pta_construction_start, 2)}') - def run_rpni(self): + def run_rpni(self) -> Any: + """ + Runs the classic RPNI state-merging procedure and constructs the resulting automaton. + + :return Any: The learned Dfa, MooreMachine, or MealyMachine. + """ start_time = time.time() red = [self.root_node] @@ -53,18 +72,27 @@ def run_rpni(self): assert sorted(red, key=lambda x: len(x.prefix)) == red return to_automaton(red, self.automaton_type) - def _compatible(self, root_node): + def _compatible(self, root_node: RpniNode) -> bool: """ Check if current model is compatible with the data. + + :param RpniNode root_node: Root node of the model to check. + :return bool: True if the model is compatible with all test data, False otherwise. """ for sequence in self.test_data: if not check_sequence(root_node, sequence, automaton_type=self.automaton_type): return False return True - def _merge(self, red_node, lex_min_blue, copy_nodes=False): + def _merge(self, red_node: RpniNode, lex_min_blue: RpniNode, copy_nodes: bool = False) -> RpniNode: """ Merge two states and return the root node of resulting model. + + :param RpniNode red_node: Red state to merge into. + :param RpniNode lex_min_blue: Blue state to merge. + :param bool copy_nodes: Whether to perform the merge on a copy of the PTA (used for compatibility checks) + rather than in place. + :return RpniNode: Root node of the (possibly copied) model after merging. """ root_node = self.root_node.copy() if copy_nodes else self.root_node lex_min_blue = lex_min_blue.copy() if copy_nodes else lex_min_blue @@ -86,7 +114,13 @@ def _merge(self, red_node, lex_min_blue, copy_nodes=False): return root_node - def _fold(self, red_node, blue_node): + def _fold(self, red_node: RpniNode, blue_node: RpniNode) -> None: + """ + Recursively folds a blue node's subtree into a red node's subtree for non-mealy automata. + + :param RpniNode red_node: Red node to fold into. + :param RpniNode blue_node: Blue node to fold. + """ # Change the output of red only to concrete output, ignore None red_node.output = blue_node.output if blue_node.output is not None else red_node.output @@ -96,7 +130,13 @@ def _fold(self, red_node, blue_node): else: red_node.children[i] = blue_node.children[i] - def _fold_mealy(self, red_node, blue_node): + def _fold_mealy(self, red_node: RpniNode, blue_node: RpniNode) -> None: + """ + Recursively folds a blue node's subtree into a red node's subtree for mealy automata. + + :param RpniNode red_node: Red node to fold into. + :param RpniNode blue_node: Blue node to fold. + """ for i, o in blue_node.output.items(): red_node.output[i] = o @@ -105,4 +145,3 @@ def _fold_mealy(self, red_node, blue_node): self._fold_mealy(red_node.children[i], blue_node.children[i]) else: red_node.children[i] = blue_node.children[i] - diff --git a/aalpy/learning_algs/deterministic_passive/GsmRPNI.py b/aalpy/learning_algs/deterministic_passive/GsmRPNI.py index b1dc55e0890..e89db83b4bf 100644 --- a/aalpy/learning_algs/deterministic_passive/GsmRPNI.py +++ b/aalpy/learning_algs/deterministic_passive/GsmRPNI.py @@ -1,11 +1,25 @@ +# Generalized state merging (GSM) variant of RPNI, using partition-based compatibility checks for faster merging. import time from collections import deque +from typing import Any from aalpy.learning_algs.deterministic_passive.rpni_helper_functions import to_automaton, RpniNode, createPTA class GsmRPNI: - def __init__(self, data, automaton_type, print_info=True): + """ + RPNI implementation based on generalized state merging (GSM), which computes merge compatibility via + partitions instead of copying and folding the whole PTA for every merge attempt. + """ + + def __init__(self, data: list, automaton_type: str, print_info: bool = True) -> None: + """ + Creates a GsmRPNI instance and constructs the prefix tree acceptor (PTA) from the data. + + :param list data: Sequence of (input sequence, label) pairs. + :param str automaton_type: Either 'dfa', 'mealy', or 'moore'. + :param bool print_info: Whether to print learning progress and runtime information. + """ self.data = data self.final_automaton_type = automaton_type self.automaton_type = automaton_type if automaton_type != 'dfa' else 'moore' @@ -18,7 +32,12 @@ def __init__(self, data, automaton_type, print_info=True): if self.print_info: print(f'PTA Construction Time: {round(time.time() - pta_construction_start, 2)}') - def run_rpni(self): + def run_rpni(self) -> Any: + """ + Runs the GSM state-merging procedure and constructs the resulting automaton. + + :return Any: The learned Dfa, MooreMachine, or MealyMachine. + """ start_time = time.time() # sorted list of states already considered @@ -66,9 +85,14 @@ def run_rpni(self): return to_automaton(red_states, self.final_automaton_type) - def _partition_from_merge(self, red: RpniNode, blue: RpniNode): + def _partition_from_merge(self, red: RpniNode, blue: RpniNode) -> dict[RpniNode, RpniNode] | None: """ - Compatibility check based on partitions + Compatibility check based on partitions. + + :param RpniNode red: Red state to attempt merging with. + :param RpniNode blue: Blue state to attempt merging. + :return dict[RpniNode, RpniNode] | None: Map from original node to its merged partition block, or None if + the merge is not compatible with the data. """ partitions = dict() @@ -78,7 +102,13 @@ def _partition_from_merge(self, red: RpniNode, blue: RpniNode): while len(q) != 0: red, blue = q.popleft() - def get_partition(node: RpniNode): + def get_partition(node: RpniNode) -> RpniNode: + """ + Retrieves the existing partition block for a node, creating one via a shallow copy if absent. + + :param RpniNode node: Node whose partition block shall be retrieved. + :return RpniNode: The partition block associated with the node. + """ if node not in partitions: p = node.shallow_copy() partitions[node] = p diff --git a/aalpy/learning_algs/deterministic_passive/PAPNI.py b/aalpy/learning_algs/deterministic_passive/PAPNI.py index d72eac72f76..4a764070a1a 100644 --- a/aalpy/learning_algs/deterministic_passive/PAPNI.py +++ b/aalpy/learning_algs/deterministic_passive/PAPNI.py @@ -1,22 +1,20 @@ +# Passive automata learning for pushdown automata (PAPNI), built on top of RPNI/EDSM over stack-annotated data. from aalpy.utils import is_balanced -from aalpy.automata.Vpa import vpa_from_dfa_representation +from aalpy.automata.Vpa import vpa_from_dfa_representation, Vpa, VpaAlphabet -def run_PAPNI(data, vpa_alphabet, algorithm='edsm', print_info=True): + +def run_PAPNI(data: list, vpa_alphabet: VpaAlphabet, algorithm: str = 'edsm', print_info: bool = True) -> Vpa | None: """ Run PAPNI, a deterministic passive model learning algorithm of deterministic pushdown automata. Resulting model conforms to the provided data. - Args: - - data: sequence of input sequences and corresponding label. Eg. [[(i1,i2,i3, ...), label], ...] - vpa_alphabet: grouping of alphabet elements to call symbols, return symbols, and internal symbols. Call symbols - push to stack, return symbols pop from stack, and internal symbols do not affect the stack. - algorithm: either 'gsm' for classic RPNI or 'edsm' for evidence driven state merging variant of RPNI - print_info: print learning progress and runtime information - - Returns: - - VPA conforming to the data, or None if data is non-deterministic. + :param list data: sequence of input sequences and corresponding label. Eg. [[(i1,i2,i3, ...), label], ...] + :param VpaAlphabet vpa_alphabet: grouping of alphabet elements to call symbols, return symbols, and internal + symbols. Call symbols push to stack, return symbols pop from stack, and internal symbols do not affect the + stack. + :param str algorithm: either 'gsm' for classic RPNI or 'edsm' for evidence driven state merging variant of RPNI + :param bool print_info: print learning progress and runtime information + :return Vpa | None: VPA conforming to the data, or None if data is non-deterministic. """ from aalpy.learning_algs import run_EDSM, run_RPNI assert algorithm in {'gsm', 'classic', 'edsm'} diff --git a/aalpy/learning_algs/deterministic_passive/RPNI.py b/aalpy/learning_algs/deterministic_passive/RPNI.py index 6826d8c0d84..59472bba684 100644 --- a/aalpy/learning_algs/deterministic_passive/RPNI.py +++ b/aalpy/learning_algs/deterministic_passive/RPNI.py @@ -1,31 +1,28 @@ -from typing import Union - +# Entry point for running RPNI, dispatching to either the classic or GSM implementation. from aalpy.base import DeterministicAutomaton from aalpy.learning_algs.deterministic_passive.ClassicRPNI import ClassicRPNI from aalpy.learning_algs.deterministic_passive.GsmRPNI import GsmRPNI -def run_RPNI(data, automaton_type, algorithm='gsm', - input_completeness=None, print_info=True) -> Union[DeterministicAutomaton, None]: +def run_RPNI(data: list, automaton_type: str, algorithm: str = 'gsm', + input_completeness: str | None = None, print_info: bool = True) -> DeterministicAutomaton | None: """ Run RPNI, a deterministic passive model learning algorithm. Resulting model conforms to the provided data. For more information on RPNI, check out AALpy' Wiki: https://github.com/DES-Lab/AALpy/wiki/RPNI---Passive-Deterministic-Automata-Learning - Args: - - data: sequence of input sequences and corresponding label. Eg. [[(i1,i2,i3, ...), label], ...] - automaton_type: either 'dfa', 'mealy', 'moore'. Note that for 'mealy' machine learning, data has to be prefix-closed. - algorithm: either 'gsm' (generalized state merging) or 'classic' for base RPNI implementation. GSM is much faster and less resource intensive. - input_completeness: either None, 'sink_state', or 'self_loop'. If None, learned model could be input incomplete, - sink_state will lead all undefined inputs form some state to the sink state, whereas self_loop will simply create - a self loop. In case of Mealy learning output of the added transition will be 'epsilon'. - print_info: print learning progress and runtime information - - Returns: - - Model conforming to the data, or None if data is non-deterministic. + :param list data: sequence of input sequences and corresponding label. Eg. [[(i1,i2,i3, ...), label], ...] + :param str automaton_type: either 'dfa', 'mealy', 'moore'. Note that for 'mealy' machine learning, data has to + be prefix-closed. + :param str algorithm: either 'gsm' (generalized state merging) or 'classic' for base RPNI implementation. GSM is + much faster and less resource intensive. + :param str | None input_completeness: either None, 'sink_state', or 'self_loop'. If None, learned model could be + input incomplete, sink_state will lead all undefined inputs form some state to the sink state, whereas + self_loop will simply create a self loop. In case of Mealy learning output of the added transition will be + 'epsilon'. + :param bool print_info: print learning progress and runtime information + :return DeterministicAutomaton | None: Model conforming to the data, or None if data is non-deterministic. """ assert algorithm in {'gsm', 'classic'} assert automaton_type in {'dfa', 'mealy', 'moore'} @@ -54,5 +51,3 @@ def run_RPNI(data, automaton_type, algorithm='gsm', learned_model.make_input_complete(input_completeness) return learned_model - - diff --git a/aalpy/learning_algs/deterministic_passive/active_RPNI.py b/aalpy/learning_algs/deterministic_passive/active_RPNI.py index e3e95dd6751..d0495155580 100644 --- a/aalpy/learning_algs/deterministic_passive/active_RPNI.py +++ b/aalpy/learning_algs/deterministic_passive/active_RPNI.py @@ -1,6 +1,8 @@ +# Active sampling wrapper around RPNI that iteratively augments the passive learning data set with new samples. from abc import ABC, abstractmethod from random import randint, choice +from aalpy.base import SUL, DeterministicAutomaton from aalpy.learning_algs import run_RPNI from aalpy.utils import convert_i_o_traces_for_RPNI @@ -11,30 +13,42 @@ class RpniActiveSampler(ABC): """ @abstractmethod - def sample(self, sul, model): + def sample(self, sul: SUL, model: DeterministicAutomaton) -> 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 RPNI learning in its data-format. - + :param SUL sul: system under learning + :param DeterministicAutomaton model: current learned model + :return list: Data to be added to the data set for the passive RPNI learning in its data-format. """ pass class RandomWordSampler(RpniActiveSampler): - def __init__(self, num_walks, min_walk_len, max_walk_len): + """ + Sampling strategy that queries the SUL with randomly generated words of random length. + """ + + 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 perform per sampling call. + :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: DeterministicAutomaton) -> list: + """ + Samples the SUL with random walks over the input alphabet inferred from the current model. + + :param SUL sul: System under learning to query. + :param DeterministicAutomaton model: Current learned model, used to determine the input alphabet. + :return list: Data to be added to the data set for the passive RPNI learning in its data-format. + """ input_al = list({el for s in model.states for el in s.transitions.keys()}) samples = [] @@ -49,7 +63,20 @@ def sample(self, sul, model): return samples -def run_active_RPNI(data, sul, sampler, n_iter, automaton_type, print_info=True): +def run_active_RPNI(data: list, sul: SUL, sampler: RpniActiveSampler, n_iter: int, automaton_type: str, + print_info: bool = True) -> DeterministicAutomaton | None: + """ + Runs RPNI iteratively, extending the data set after each iteration with new samples obtained from the SUL. + + :param list data: Initial sequence of input sequences and corresponding labels. + :param SUL sul: System under learning queried by the sampler to obtain new samples. + :param RpniActiveSampler sampler: Sampling strategy used to generate new data between iterations. + :param int n_iter: Number of iterations to perform. + :param str automaton_type: Either 'dfa', 'mealy', or 'moore'. + :param bool print_info: Whether to print learning progress and runtime information. + :return DeterministicAutomaton | None: The model learned in the final iteration, or None if the data is + non-deterministic. + """ model = None for i in range(n_iter): if print_info: diff --git a/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py b/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py index df729a32aa9..fa75c86850f 100644 --- a/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py +++ b/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py @@ -1,12 +1,25 @@ +# Helper data structures and functions shared by the RPNI-family passive learning algorithms. import pickle from functools import total_ordering +from typing import Any @total_ordering class RpniNode: + """ + Single node of the prefix tree acceptor (PTA) used during RPNI-style state merging. + """ + __slots__ = ['output', 'children', 'prefix', "type"] - def __init__(self, output=None, children=None, automaton_type='moore'): + def __init__(self, output: Any = None, children: dict | None = None, automaton_type: str = 'moore') -> None: + """ + Creates a PTA node. + + :param Any output: Output of the node. For 'mealy' automata this is a dict mapping input to output. + :param dict | None children: Map from input symbol to child RpniNode. + :param str automaton_type: Either 'dfa', 'moore', or 'mealy'. + """ if output is None and automaton_type == 'mealy': output = dict() if children is None: @@ -16,26 +29,54 @@ def __init__(self, output=None, children=None, automaton_type='moore'): self.prefix = () self.type = automaton_type - def shallow_copy(self): + def shallow_copy(self) -> 'RpniNode': + """ + Creates a shallow copy of this node, copying the children map (and output dict for 'mealy'). + + :return RpniNode: The shallow copy. + """ output = self.output if self.type != 'mealy' else dict(self.output) return RpniNode(output, dict(self.children), self.type) - def copy(self): + def copy(self) -> 'RpniNode': + """ + Creates a deep copy of this node and its whole subtree. + + :return RpniNode: The deep copy. + """ return pickle.loads(pickle.dumps(self, -1)) - def __lt__(self, other): + def __lt__(self, other: 'RpniNode') -> bool: + """ + Compares nodes by prefix length, used to keep the blue/red state lists sorted. + + :param RpniNode other: Node to compare against. + :return bool: True if this node's prefix is shorter than other's. + """ return len(self.prefix) < len(other.prefix) # return (len(self.prefix), self.prefix) < (len(other.prefix), other.prefix) - def __eq__(self, other): + def __eq__(self, other: 'RpniNode') -> bool: + """ + Compares nodes by prefix. + + :param RpniNode other: Node to compare against. + :return bool: True if both nodes have the same prefix. + """ return self.prefix == other.prefix - def __hash__(self): + def __hash__(self) -> int: + """ + :return int: Identity-based hash of the node. + """ return id(self) # TODO This is a hack - def compatible_outputs(self, other): + def compatible_outputs(self, other: 'RpniNode') -> bool: """ Only allow merging of states that have same output(s). + + :param RpniNode other: Node to check compatibility against. + :return bool: True if the outputs of both nodes are compatible. """ # None is compatible with everything if self.type != 'mealy': @@ -48,16 +89,28 @@ def compatible_outputs(self, other): return False return True - def get_child_by_prefix(self, prefix): + def get_child_by_prefix(self, prefix: tuple) -> 'RpniNode': + """ + Follows a sequence of input symbols from this node and returns the reached node. + + :param tuple prefix: Sequence of input symbols to follow. + :return RpniNode: The node reached after following the prefix. + """ node = self for symbol in prefix: node = node.children[symbol] return node -def check_sequence(root_node, seq, automaton_type): +def check_sequence(root_node: RpniNode, seq: list, automaton_type: str) -> bool: """ Checks whether each sequence in the dataset is valid in the current automaton. + + :param RpniNode root_node: Root node of the (partial) automaton represented as a PTA. + :param list seq: Sequence of (input, output) pairs (optionally preceded by an initial output for non-mealy + automata) to validate. + :param str automaton_type: Either 'dfa', 'moore', or 'mealy'. + :return bool: True if the sequence is consistent with the automaton, False otherwise. """ curr_node = root_node # Check initial output for Moore machines and the like @@ -78,7 +131,14 @@ def check_sequence(root_node, seq, automaton_type): return True -def createPTA(data, automaton_type): +def createPTA(data: list, automaton_type: str) -> RpniNode | None: + """ + Constructs a prefix tree acceptor (PTA) from the provided data. + + :param list data: Sequence of (input sequence, label) pairs. + :param str automaton_type: Either 'dfa', 'moore', or 'mealy'. + :return RpniNode | None: The root node of the constructed PTA, or None if the data is non-deterministic. + """ data.sort(key=lambda x: len(x[0])) root_node = RpniNode(automaton_type=automaton_type) @@ -105,11 +165,18 @@ def createPTA(data, automaton_type): return root_node -def extract_unique_sequences(root_node, automaton_type): - def get_leaf_nodes(root): +def extract_unique_sequences(root_node: RpniNode, automaton_type: str) -> list[list]: + """ + Extracts, for every leaf of the PTA, the unique sequence of (input, output) pairs leading to it. + + :param RpniNode root_node: Root node of the PTA. + :param str automaton_type: Either 'dfa', 'moore', or 'mealy'. + :return list[list]: List of sequences, one per leaf node of the PTA. + """ + def get_leaf_nodes(root: RpniNode) -> list[RpniNode]: leaves = [] - def _get_leaf_nodes(node): + def _get_leaf_nodes(node: RpniNode) -> None: if node is not None: if len(node.children.keys()) == 0: leaves.append(node) @@ -135,7 +202,15 @@ def _get_leaf_nodes(node): return paths -def to_automaton(red, automaton_type): +def to_automaton(red: list[RpniNode], automaton_type: str) -> Any: + """ + Converts a list of merged (red) PTA nodes into the corresponding automaton. + + :param list[RpniNode] red: List of red (final) states of the PTA, in prefix-length order (the first entry is + the initial state). + :param str automaton_type: Either 'dfa', 'moore', or 'mealy'. + :return Any: The constructed Dfa, MooreMachine, or MealyMachine. + """ from aalpy.automata import DfaState, Dfa, MooreMachine, MooreState, MealyMachine, MealyState if automaton_type == 'dfa': @@ -169,7 +244,13 @@ def to_automaton(red, automaton_type): return automaton(initial_state, list(prefix_state_map.values())) -def visualize_pta(root_node, path='pta.pdf'): +def visualize_pta(root_node: RpniNode, path: str = 'pta.pdf') -> None: + """ + Visualizes the PTA and writes the resulting graph to a PDF file. + + :param RpniNode root_node: Root node of the PTA to visualize. + :param str path: Output file path for the generated PDF. + """ from pydot import Dot, Node, Edge graph = Dot('fpta', graph_type='digraph') diff --git a/aalpy/learning_algs/general_passive/GeneralizedStateMerging.py b/aalpy/learning_algs/general_passive/GeneralizedStateMerging.py index e1a1b1a01c2..a597fcf4139 100644 --- a/aalpy/learning_algs/general_passive/GeneralizedStateMerging.py +++ b/aalpy/learning_algs/general_passive/GeneralizedStateMerging.py @@ -1,9 +1,14 @@ +# Core implementation of the Generalized State Merging (GSM) algorithm: a red-blue +# state-merging framework used to passively learn deterministic, nondeterministic and +# stochastic automata from data. import functools from collections import deque -from typing import Dict, Tuple, Callable, List, Optional +from collections.abc import Callable +from typing import Any +from aalpy.base import Automaton from aalpy.learning_algs.general_passive.GsmNode import GsmNode, OutputBehavior, TransitionBehavior, TransitionInfo, \ - OutputBehaviorRange, TransitionBehaviorRange, intersection_iterator, unknown_output, detect_data_format + OutputBehaviorRange, TransitionBehaviorRange, DataFormat, intersection_iterator, unknown_output, detect_data_format from aalpy.learning_algs.general_passive.ScoreFunctionsGSM import ScoreCalculation, hoeffding_compatibility @@ -11,35 +16,75 @@ # Easiest done by adding a new method / field to ScoreCalculation class Partitioning: - def __init__(self, red: GsmNode, blue: GsmNode): + """Represents the tentative result of merging a blue node into a red node, plus the resulting node mapping.""" + + def __init__(self, red: GsmNode, blue: GsmNode) -> None: + """ + Create a (not yet scored) partitioning for the merge of blue into red. + + :param GsmNode red: Red (already accepted) node the merge targets. + :param GsmNode blue: Blue (candidate) node being merged. + """ self.red: GsmNode = red self.blue: GsmNode = blue self.score = False - self.red_mapping: Dict[GsmNode, GsmNode] = dict() - self.full_mapping: Dict[GsmNode, GsmNode] = dict() + self.red_mapping: dict[GsmNode, GsmNode] = dict() + self.full_mapping: dict[GsmNode, GsmNode] = dict() class Instrumentation: - def __init__(self): + """Base class for hooks that observe/report on the progress of GeneralizedStateMerging.run.""" + + def __init__(self) -> None: + """ + Create an instrumentation instance. No state by default. + """ pass - def reset(self, gsm: 'GeneralizedStateMerging'): + def reset(self, gsm: 'GeneralizedStateMerging') -> None: + """ + Called once at the start of a learning run. + + :param GeneralizedStateMerging gsm: The GSM instance being run. + """ pass - def pta_construction_done(self, root: GsmNode): + def pta_construction_done(self, root: GsmNode) -> None: + """ + Called after the initial PTA has been constructed. + + :param GsmNode root: Root node of the constructed PTA. + """ pass - def log_promote(self, node: GsmNode): + def log_promote(self, node: GsmNode) -> None: + """ + Called whenever a blue node is promoted to red. + + :param GsmNode node: The promoted node. + """ pass - def log_merge(self, part: Partitioning): + def log_merge(self, part: Partitioning) -> None: + """ + Called whenever a merge is performed. + + :param Partitioning part: The partitioning describing the performed merge. + """ pass - def learning_done(self, root: GsmNode): + def learning_done(self, root: GsmNode) -> None: + """ + Called once learning has finished. + + :param GsmNode root: Root node of the learned model. + """ pass class GeneralizedStateMerging: + """Implements the red-blue state-merging framework used to passively learn automata from data.""" + def __init__(self, *, output_behavior: OutputBehavior = "moore", transition_behavior: TransitionBehavior = "deterministic", @@ -49,8 +94,22 @@ def __init__(self, *, compatibility_on_pta: bool = False, compatibility_on_futures: bool = False, node_order: Callable[[GsmNode, GsmNode], bool] = None, - consider_only_min_blue=False, - depth_first=False): + consider_only_min_blue: bool = False, + depth_first: bool = False) -> None: + """ + Configure a GeneralizedStateMerging instance. + + :param OutputBehavior output_behavior: Either "moore" or "mealy". + :param TransitionBehavior transition_behavior: Either "deterministic", "nondeterministic" or "stochastic". + :param ScoreCalculation score_calc: Local compatibility / global score calculation to use. + :param Callable[[GsmNode], GsmNode] pta_preprocessing: Pre-processing function applied to the constructed PTA. + :param Callable[[GsmNode], GsmNode] postprocessing: Post-processing function applied to the learned model. + :param bool compatibility_on_pta: Whether compatibility is evaluated on the PTA instead of the current hypothesis. + :param bool compatibility_on_futures: Whether compatibility is evaluated on futures instead of full partitions. + :param Callable[[GsmNode, GsmNode], bool] node_order: Order in which merge candidates are considered. + :param bool consider_only_min_blue: Whether to only consider the minimal blue node in each round. + :param bool depth_first: Whether compatibility is checked depth-first instead of breadth-first. + """ if output_behavior not in OutputBehaviorRange: raise ValueError(f"invalid output behavior {output_behavior}. should be in {OutputBehaviorRange}") @@ -82,7 +141,14 @@ def __init__(self, *, self.consider_only_min_blue = consider_only_min_blue self.depth_first = depth_first - def compute_local_compatibility(self, a: GsmNode, b: GsmNode): + def compute_local_compatibility(self, a: GsmNode, b: GsmNode) -> bool: + """ + Check whether two nodes are locally compatible, considering output/transition behavior and the score calculation. + + :param GsmNode a: First node. + :param GsmNode b: Second node. + :return bool: True if the nodes are locally compatible. + """ if self.output_behavior == "moore" and not GsmNode.moore_compatible(a, b): return False if self.transition_behavior == "deterministic" and not GsmNode.deterministic_compatible(a, b): @@ -91,7 +157,17 @@ def compute_local_compatibility(self, a: GsmNode, b: GsmNode): # TODO: make more generic by adding the option to use a different algorithm than red blue # for selecting potential merge candidates. Maybe using inheritance with abstract `run`. - def run(self, data, convert=True, instrumentation: Instrumentation=None, data_format=None): + def run(self, data: Any, convert: bool = True, instrumentation: Instrumentation | None = None, + data_format: DataFormat | None = None) -> Automaton | GsmNode: + """ + Run the state-merging algorithm on the provided data. + + :param Any data: Learning data, in one of the supported data formats (or already a GsmNode tree). + :param bool convert: Whether to convert the resulting GsmNode tree into a concrete AALpy automaton. + :param Instrumentation | None instrumentation: Instrumentation object used to report progress, defaults to a no-op instance. + :param DataFormat | None data_format: Explicit data format of `data`, or None to auto-detect. + :return Automaton | GsmNode: The learned automaton (if convert is True) or the raw GsmNode tree. + """ if instrumentation is None: instrumentation = Instrumentation() instrumentation.reset(self) @@ -115,7 +191,7 @@ def run(self, data, convert=True, instrumentation: Instrumentation=None, data_fo # sorted list of states already considered red_states = [root] - partition_candidates: Dict[Tuple[GsmNode, GsmNode], Partitioning] = dict() + partition_candidates: dict[tuple[GsmNode, GsmNode], Partitioning] = dict() while True: # sort states. states are always sorted using default order on original prefix if self.node_order is not GsmNode.default_order: @@ -147,7 +223,7 @@ def run(self, data, convert=True, instrumentation: Instrumentation=None, data_fo # FUTURE: Save partitions? # calculate partitions resulting from merges with red states if necessary - current_candidates: Dict[GsmNode, Partitioning] = dict() + current_candidates: dict[GsmNode, Partitioning] = dict() perfect_partitioning = None red_state = None for red_state in red_states: @@ -200,7 +276,14 @@ def run(self, data, convert=True, instrumentation: Instrumentation=None, data_fo return root def _check_futures(self, red: GsmNode, blue: GsmNode) -> bool: - q: deque[Tuple[GsmNode, GsmNode]] = deque([(red, blue)]) + """ + Check compatibility of the futures of two nodes, without constructing a full partition. + + :param GsmNode red: Red (already accepted) node. + :param GsmNode blue: Blue (candidate) node. + :return bool: True if all reachable node pairs are locally compatible. + """ + q: deque[tuple[GsmNode, GsmNode]] = deque([(red, blue)]) pop = q.pop if self.depth_first else q.popleft while len(q) != 0: @@ -221,9 +304,15 @@ def _check_futures(self, red: GsmNode, blue: GsmNode) -> bool: return True def _partition_from_merge(self, red: GsmNode, blue: GsmNode) -> Partitioning: - # Compatibility check based on partitions. - # assumes that blue is a tree and red is not reachable from blue + """ + Compute the partitioning resulting from merging blue into red, including its score. + Assumes that blue is a tree and red is not reachable from blue. + + :param GsmNode red: Red (already accepted) node the merge targets. + :param GsmNode blue: Blue (candidate) node being merged. + :return Partitioning: The resulting partitioning (with score False if incompatible). + """ partitioning = Partitioning(red, blue) self.score_calc.reset() @@ -234,10 +323,10 @@ def _partition_from_merge(self, red: GsmNode, blue: GsmNode) -> Partitioning: # when compatibility is determined only by future and scores are disabled, we need not create partitions. if self.compatibility_on_futures and not self.score_calc.has_score_function(): - def update_partition(red_node: GsmNode, blue_node: Optional[GsmNode]) -> GsmNode: + def update_partition(red_node: GsmNode, blue_node: GsmNode | None) -> GsmNode: return red_node else: - def update_partition(red_node: GsmNode, blue_node: Optional[GsmNode]) -> GsmNode: + def update_partition(red_node: GsmNode, blue_node: GsmNode | None) -> GsmNode: p = partitioning.full_mapping.get(red_node) # could check smaller .red_mapping? if p is None: p = red_node.shallow_copy() @@ -257,7 +346,7 @@ def update_partition(red_node: GsmNode, blue_node: Optional[GsmNode]) -> GsmNode partition.resolve_unknown_prefix_output(blue_out_sym) # loop over implied merges - q: deque[Tuple[GsmNode, GsmNode]] = deque([(red, blue)]) + q: deque[tuple[GsmNode, GsmNode]] = deque([(red, blue)]) pop = q.pop if self.depth_first else q.popleft while len(q) != 0: red, blue = pop() @@ -310,45 +399,30 @@ def run_GSM(data: list, *, compatibility_on_pta: bool = False, compatibility_on_futures: bool = False, node_order: Callable[[GsmNode, GsmNode], bool] = None, - consider_only_min_blue=False, - depth_first=False, - instrumentation=None, - convert=True, - data_format=None, - ): + consider_only_min_blue: bool = False, + depth_first: bool = False, + instrumentation: Instrumentation | None = None, + convert: bool = True, + data_format: DataFormat | None = None, + ) -> Automaton | GsmNode: """ Performs a state merging algorithm in the red-blue framework on provided data. - Args: - data: Data used for learning. Recorded behavior of the system. - - output_behavior: Specifies whether outputs are emitted by states ("moore") or transitions ("mealy"). - - transition_behavior: Either "deterministic", "nondeterministic" or "stochastic". - - score_calc: A ScoreCalculation object which determines how compatibility and merge scores are calculated. - - pta_preprocessing: A pre-processing function applied to the PTA. - - postprocessing: A postprocessing function applied to the learned automaton. - - compatibility_on_pta: Whether compatibility is evaluated on the PTA or the current hypothesis. - - compatibility_on_futures: Whether compatibility is evaluated using the futures of both states or all partition information. - - node_order: Order in which merge candidates are considered. Defaults to short-lex. - - consider_only_min_blue: Whether to consider merge candidates from all blue nodes or just a single. - - depth_first: Whether compatibility is checked depth- or breadth-first. - - instrumentation: Instrumentation object for reporting progress or debugging. - - convert: Whether to return a normal AALpy automaton type or a `GsmNode` object (internal representation). - - data_format: Whether the input is given in the form of input-output traces or labeled input traces. - - Returns: The learned automaton. + :param list data: Data used for learning. Recorded behavior of the system. + :param OutputBehavior output_behavior: Specifies whether outputs are emitted by states ("moore") or transitions ("mealy"). + :param TransitionBehavior transition_behavior: Either "deterministic", "nondeterministic" or "stochastic". + :param ScoreCalculation score_calc: A ScoreCalculation object which determines how compatibility and merge scores are calculated. + :param Callable[[GsmNode], GsmNode] pta_preprocessing: A pre-processing function applied to the PTA. + :param Callable[[GsmNode], GsmNode] postprocessing: A postprocessing function applied to the learned automaton. + :param bool compatibility_on_pta: Whether compatibility is evaluated on the PTA or the current hypothesis. + :param bool compatibility_on_futures: Whether compatibility is evaluated using the futures of both states or all partition information. + :param Callable[[GsmNode, GsmNode], bool] node_order: Order in which merge candidates are considered. Defaults to short-lex. + :param bool consider_only_min_blue: Whether to consider merge candidates from all blue nodes or just a single. + :param bool depth_first: Whether compatibility is checked depth- or breadth-first. + :param Instrumentation | None instrumentation: Instrumentation object for reporting progress or debugging. + :param bool convert: Whether to return a normal AALpy automaton type or a `GsmNode` object (internal representation). + :param DataFormat | None data_format: Whether the input is given in the form of input-output traces or labeled input traces. + :return Automaton | GsmNode: The learned automaton. """ # instantiate gsm gsm = GeneralizedStateMerging( diff --git a/aalpy/learning_algs/general_passive/GsmAlgorithms.py b/aalpy/learning_algs/general_passive/GsmAlgorithms.py index 6ae869d17da..d00e08b0bb5 100644 --- a/aalpy/learning_algs/general_passive/GsmAlgorithms.py +++ b/aalpy/learning_algs/general_passive/GsmAlgorithms.py @@ -1,6 +1,9 @@ -from typing import Dict, Union, defaultdict +# Convenience wrappers around run_GSM implementing well-known passive learning +# algorithms: EDSM, k-tails, and Alergia/IoAlergia (with EDSM-style scoring). +from collections import defaultdict from aalpy import DeterministicAutomaton, Onfsm, NDMooreMachine +from aalpy.base import Automaton from aalpy.learning_algs.general_passive.GeneralizedStateMerging import run_GSM from aalpy.learning_algs.general_passive.Instrumentation import ProgressReport from aalpy.learning_algs.general_passive.GsmNode import GsmNode @@ -9,29 +12,25 @@ from aalpy.utils.HelperFunctions import dfa_from_moore -def run_EDSM(data, automaton_type, input_completeness=None, print_info=True) -> Union[DeterministicAutomaton, None]: +def run_EDSM(data: list, automaton_type: str, input_completeness: str | None = None, + print_info: bool = True) -> DeterministicAutomaton | None: """ Run Evidence Driven State Merging. - Args: - data: sequence of input sequences and corresponding label. Eg. [[(i1,i2,i3, ...), label], ...] - automaton_type: either 'dfa', 'mealy', 'moore'. Note that for 'mealy' machine learning, data has to be prefix-closed. - input_completeness: either None, 'sink_state', or 'self_loop'. If None, learned model could be input incomplete, + :param list data: sequence of input sequences and corresponding label. Eg. [[(i1,i2,i3, ...), label], ...] + :param str automaton_type: either 'dfa', 'mealy', 'moore'. Note that for 'mealy' machine learning, data has to be prefix-closed. + :param str | None input_completeness: either None, 'sink_state', or 'self_loop'. If None, learned model could be input incomplete, sink_state will lead all undefined inputs form some state to the sink state, whereas self_loop will simply create a self loop. In case of Mealy learning output of the added transition will be 'epsilon'. - print_info: print learning progress and runtime information - - Returns: - - Model conforming to the data, or None if data is non-deterministic. - + :param bool print_info: print learning progress and runtime information + :return DeterministicAutomaton | None: Model conforming to the data, or None if data is non-deterministic. """ assert automaton_type in {'dfa', 'mealy', 'moore'} assert input_completeness in {None, 'self_loop', 'sink_state'} print_level = ProgressReport(1) if print_info else None - def EDSM_score(part: Dict[GsmNode, GsmNode]): + def EDSM_score(part: dict[GsmNode, GsmNode]) -> int: reverse_partition = defaultdict(list) for original_node, resulting_node in part.items(): reverse_partition[resulting_node].append(original_node) @@ -69,32 +68,21 @@ def EDSM_score(part: Dict[GsmNode, GsmNode]): return learned_model -def run_k_tails(data, automaton_type, k, input_completeness=None, print_info=True) -> Union[ - Onfsm, NDMooreMachine, None]: +def run_k_tails(data: list, automaton_type: str, k: int, input_completeness: str | None = None, + print_info: bool = True) -> Onfsm | NDMooreMachine | None: """ Runs k-tails. - Args: - - data: sequence of input-output traces - - automaton_type: either 'mealy' or 'moore'. Note that the data has to be prefix-closed, and the resulting model - could be non-deterministic. - - k: depth until which to check node compatibility - - input_completeness: either None, 'sink_state', or 'self_loop'. If None, learned model could be input incomplete, - + :param list data: sequence of input-output traces + :param str automaton_type: either 'mealy' or 'moore'. Note that the data has to be prefix-closed, and the resulting model + could be non-deterministic. + :param int k: depth until which to check node compatibility + :param str | None input_completeness: either None, 'sink_state', or 'self_loop'. If None, learned model could be input incomplete, sink_state will lead all undefined inputs form some state to the sink state, whereas self_loop will simply create - a self loop. In case of Mealy learning output of the added transition will be 'epsilon'. - - print_info: print learning progress and runtime information - - Returns: - - Model conforming to the data such that future compatibility is checked only until the depth of k. - + :param bool print_info: print learning progress and runtime information + :return Onfsm | NDMooreMachine | None: Model conforming to the data such that future compatibility is checked only + until the depth of k. """ assert automaton_type in {'mealy', 'moore'} assert input_completeness in {None, 'self_loop', 'sink_state'} @@ -122,26 +110,18 @@ def run_k_tails(data, automaton_type, k, input_completeness=None, print_info=Tru return learned_model -def run_Alergia_EDSM(data, automaton_type, eps=0.05, print_info=False): +def run_Alergia_EDSM(data: list, automaton_type: str, eps: float = 0.05, print_info: bool = False) -> Automaton: """ Run IoAlergia with EDSM on provided data. - Args: - - data: [[O,(I,O),(I,O)...], [O,(I,O), (I, O)_,...],..,] if learning MDPs, + :param list data: [[O,(I,O),(I,O)...], [O,(I,O), (I, O)_,...],..,] if learning MDPs, or [[I,O,I,O...], [I,O_,...],..,] if learning SMMs (I represent input, O output), or [[O, O, O], ...] if learning Markov chains. Note that when learning MDPs and MCs the first symbol of each entry should be the same (Initial output). - - eps: epsilon value if you are using default HoeffdingCompatibility. - - automaton_type: either 'mdp' if you wish to learn an MDP, or 'smm' if you want to learn stochastic Mealy machine - - print_info: default False - - Returns: - - A Mc, Mdp or SMM + :param float eps: epsilon value if you are using default HoeffdingCompatibility. + :param str automaton_type: either 'mdp' if you wish to learn an MDP, or 'smm' if you want to learn stochastic Mealy machine + :param bool print_info: default False + :return Automaton: A Mc, Mdp or SMM """ from aalpy.utils.HelperFunctions import mc_format_to_mdp, mc_from_mdp @@ -150,19 +130,42 @@ def run_Alergia_EDSM(data, automaton_type, eps=0.05, print_info=False): print_level = ProgressReport(1) if print_info else None class IOAlergiaWithEDSM(ScoreCalculation): - def __init__(self, epsilon): + """ScoreCalculation combining IoAlergia's Hoeffding compatibility with an EDSM-style evidence score.""" + + def __init__(self, epsilon: float) -> None: + """ + Create an IoAlergia+EDSM score calculation. + + :param float epsilon: Confidence parameter for the Hoeffding compatibility check. + """ super().__init__() self.ioa_compatibility = hoeffding_compatibility(epsilon) self.evidence = 0 - def reset(self): + def reset(self) -> None: + """ + Reset the accumulated evidence counter. + """ self.evidence = 0 - def local_compatibility(self, a: GsmNode, b: GsmNode): + def local_compatibility(self, a: GsmNode, b: GsmNode) -> bool: + """ + Check local compatibility of two nodes, accumulating evidence for the score function. + + :param GsmNode a: First node. + :param GsmNode b: Second node. + :return bool: True if the nodes are compatible according to the Hoeffding bound. + """ self.evidence += 1 return self.ioa_compatibility(a, b) - def score_function(self, part: dict[GsmNode, GsmNode]): + def score_function(self, part: dict[GsmNode, GsmNode]) -> int: + """ + Compute the score of a merge partition as the accumulated evidence. + + :param dict[GsmNode, GsmNode] part: Mapping of original nodes to their merged partition representative. + :return int: The accumulated evidence count. + """ return self.evidence output_behaviour = 'moore' if automaton_type != 'smm' else 'mealy' diff --git a/aalpy/learning_algs/general_passive/GsmNode.py b/aalpy/learning_algs/general_passive/GsmNode.py index d3d13f9718e..e2b5a9e372c 100644 --- a/aalpy/learning_algs/general_passive/GsmNode.py +++ b/aalpy/learning_algs/general_passive/GsmNode.py @@ -1,9 +1,12 @@ +# Generic prefix-tree / observation-tree node structure used by the general passive +# (state-merging) learning algorithms, plus conversion to concrete AALpy automaton types. import functools import math import pathlib from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator, Sequence from functools import total_ordering -from typing import Dict, Any, List, Tuple, Iterable, Callable, Union, TypeVar, Iterator, Optional, Sequence +from typing import Any, TypeVar import pydot from copy import copy @@ -23,9 +26,9 @@ DataFormat = str DataFormatRange = ["io_traces", "labeled_sequences", "traces", "tree"] -IOPair = Tuple[Any, Any] +IOPair = tuple[Any, Any] IOTrace = Sequence[IOPair] -IOExample = Tuple[Sequence[Any], Any] +IOExample = tuple[Sequence[Any], Any] StateFunction = Callable[['GsmNode'], str] TransitionFunction = Callable[['GsmNode', Any, Any], str] @@ -33,7 +36,14 @@ unknown_output = None # can be set to a special value if required -def intersection_iterator(a: Dict[Key, Val], b: Dict[Key, Val]) -> Iterator[Tuple[Key, Val, Val]]: +def intersection_iterator(a: dict[Key, Val], b: dict[Key, Val]) -> Iterator[tuple[Key, Val, Val]]: + """ + Iterate over the key/value pairs that are present in both dictionaries. + + :param dict[Key, Val] a: First dictionary. + :param dict[Key, Val] b: Second dictionary. + :return Iterator[tuple[Key, Val, Val]]: Iterator of (key, value in a, value in b) for keys common to both dicts. + """ missing = object() for key, a_val in a.items(): b_val = b.get(key, missing) @@ -42,7 +52,15 @@ def intersection_iterator(a: Dict[Key, Val], b: Dict[Key, Val]) -> Iterator[Tupl yield key, a_val, b_val -def union_iterator(a: Dict[Key, Val], b: Dict[Key, Val], default: Val = None) -> Iterator[Tuple[Key, Val, Val]]: +def union_iterator(a: dict[Key, Val], b: dict[Key, Val], default: Val = None) -> Iterator[tuple[Key, Val, Val]]: + """ + Iterate over the key/value pairs present in either dictionary, substituting a default for missing values. + + :param dict[Key, Val] a: First dictionary. + :param dict[Key, Val] b: Second dictionary. + :param Val default: Value used in place of a missing entry. + :return Iterator[tuple[Key, Val, Val]]: Iterator of (key, value in a, value in b) for keys in either dict. + """ for key, a_val in a.items(): b_val = b.get(key, default) yield key, a_val, b_val @@ -54,7 +72,15 @@ def union_iterator(a: Dict[Key, Val], b: Dict[Key, Val], default: Val = None) -> # TODO reuse in RPNI -def detect_data_format(data, check_consistency=False, guess=False): +def detect_data_format(data: Any, check_consistency: bool = False, guess: bool = False) -> DataFormat: + """ + Guess the data format of the provided learning data. + + :param Any data: Input data: a GsmNode (tree), or a sequence of traces/examples. + :param bool check_consistency: Whether to check all data points instead of returning as soon as a unique format is found. + :param bool guess: Whether to allow guessing a single format when multiple formats remain ambiguous. + :return DataFormat: The detected data format string (see DataFormatRange). + """ # The different data formats are # - "tree": a tree-shaped automaton provided as a GsmNode # - "io_traces": either @@ -66,7 +92,7 @@ def detect_data_format(data, check_consistency=False, guess=False): if isinstance(data, GsmNode): return "tree" - accepted_types = (Tuple, List) + accepted_types = (tuple, list) # mapping data formats to compatibility criteria check_dict = dict( @@ -99,9 +125,20 @@ def detect_data_format(data, check_consistency=False, guess=False): # TODO maybe split this for maintainability (and perfomance?) class TransitionInfo: + """Stores the current and original (PTA) target node and count for a single transition.""" + __slots__ = ["target", "count", "original_target", "original_count"] - def __init__(self, target, count, original_target, original_count): + def __init__(self, target: 'GsmNode', count: int, original_target: 'GsmNode | None', + original_count: int | None) -> None: + """ + Create a transition info record. + + :param GsmNode target: Current target node of the transition. + :param int count: Current transition count. + :param GsmNode | None original_target: Target node in the original PTA, if any. + :param int | None original_count: Transition count in the original PTA, if any. + """ self.target: 'GsmNode' = target self.count: int = count self.original_target: 'GsmNode' = original_target @@ -122,13 +159,26 @@ class GsmNode: """ __slots__ = ['transitions', 'predecessor', 'prefix_access_pair'] - def __init__(self, prefix_access_pair, predecessor: 'GsmNode' = None): + def __init__(self, prefix_access_pair: IOPair, predecessor: 'GsmNode | None' = None) -> None: + """ + Create a node with the given prefix-access pair and predecessor. + + :param IOPair prefix_access_pair: (input, output) pair leading from the predecessor to this node. + :param GsmNode | None predecessor: Predecessor node, or None for the root node. + """ # TODO try single dict - self.transitions: defaultdict[Any, Dict[Any, TransitionInfo]] = defaultdict(dict) + self.transitions: defaultdict[Any, dict[Any, TransitionInfo]] = defaultdict(dict) self.predecessor: GsmNode = predecessor self.prefix_access_pair = prefix_access_pair - def __lt__(self, other, compare_length_only=False): + def __lt__(self, other: 'GsmNode', compare_length_only: bool = False) -> bool: + """ + Compare nodes in short-lex order: first by prefix length, then lexicographically by prefix. + + :param GsmNode other: Node to compare against. + :param bool compare_length_only: Whether to only compare based on prefix length. + :return bool: True if self is ordered before other. + """ own_l, other_l = self.get_prefix_length(), other.get_prefix_length() if own_l != other_l: return own_l < other_l @@ -143,7 +193,12 @@ def __lt__(self, other, compare_length_only=False): # TODO implicit prefixes as currently implemented require O(length) time for prefix calculations (e.g. to determine the minimal blue node) # other options would be to have more efficient explicit prefixes such as shared list representations - def get_prefix_length(self): + def get_prefix_length(self) -> int: + """ + Compute the length of this node's prefix (distance from the root). + + :return int: Number of transitions from the root to this node. + """ node = self length = 0 while node.predecessor: @@ -151,20 +206,38 @@ def get_prefix_length(self): length += 1 return length - def get_prefix_output(self): + def get_prefix_output(self) -> Any: + """ + Get the output of the prefix-access pair leading to this node. + + :return Any: The output symbol of the prefix-access pair. + """ return self.prefix_access_pair[1] - def get_prefix_input(self): + def get_prefix_input(self) -> Any: + """ + Get the input of the prefix-access pair leading to this node. + + :return Any: The input symbol of the prefix-access pair. + """ return self.prefix_access_pair[0] - def resolve_unknown_prefix_output(self, value): - current_prefix_output = self.get_prefix_output() - if current_prefix_output is unknown_output: + def resolve_unknown_prefix_output(self, value: Any) -> None: + """ + Set the prefix output to the given value if it is currently unknown. + + :param Any value: Output value to assign if the current prefix output is unknown. + """ + if self.get_prefix_output() is unknown_output: self.prefix_access_pair = (self.get_prefix_input(), value) - return True - return current_prefix_output == value - def get_prefix(self, include_output=True): + def get_prefix(self, include_output: bool = True) -> list[Any]: + """ + Compute the sequence of prefix-access pairs (or just inputs) leading from the root to this node. + + :param bool include_output: Whether to include the output alongside each input in the prefix. + :return list[Any]: List of IO pairs (or inputs only) leading to this node. + """ node = self prefix = [] while node.predecessor: @@ -176,25 +249,46 @@ def get_prefix(self, include_output=True): prefix.reverse() return prefix - def get_root(self): + def get_root(self) -> 'GsmNode': + """ + Find the root node of the tree this node belongs to. + + :return GsmNode: The root node. + """ current = self while current.predecessor: current = current.predecessor return current - def get_or_create_transitions(self, in_sym) -> Dict[Any, TransitionInfo]: + def get_or_create_transitions(self, in_sym: Any) -> dict[Any, TransitionInfo]: + """ + Get the transition dictionary for the given input symbol, creating it if necessary. + + :param Any in_sym: Input symbol. + :return dict[Any, TransitionInfo]: Mapping of output symbol to transition info for this input. + """ t = self.transitions.get(in_sym) if t is None: t = dict() self.transitions[in_sym] = t return t - def transition_iterator(self) -> Iterable[Tuple[Any, Any, TransitionInfo]]: + def transition_iterator(self) -> Iterable[tuple[Any, Any, TransitionInfo]]: + """ + Iterate over all outgoing transitions of this node. + + :return Iterable[tuple[Any, Any, TransitionInfo]]: Iterable of (input, output, transition info) triples. + """ for in_sym, transitions in self.transitions.items(): for out_sym, node in transitions.items(): yield in_sym, out_sym, node def shallow_copy(self) -> 'GsmNode': + """ + Create a shallow copy of this node, duplicating its transition dict but keeping the same targets. + + :return GsmNode: The copied node. + """ node = GsmNode(self.prefix_access_pair, self.predecessor) for in_sym, t in self.transitions.items(): d = dict() # appears to be faster than dict comprehension @@ -203,7 +297,13 @@ def shallow_copy(self) -> 'GsmNode': node.transitions[in_sym] = d return node - def get_by_prefix(self, seq: IOTrace) -> Optional['GsmNode']: + def get_by_prefix(self, seq: IOTrace) -> 'GsmNode | None': + """ + Follow the given sequence of IO pairs from this node and return the node it leads to. + + :param IOTrace seq: Sequence of (input, output) pairs to follow. + :return GsmNode | None: The reached node, or None if the sequence is not defined. + """ node: GsmNode = self for in_sym, out_sym in seq: if in_sym is None: # ignore initial transition of Node.get_prefix() @@ -217,7 +317,12 @@ def get_by_prefix(self, seq: IOTrace) -> Optional['GsmNode']: node = t_info.target return node - def get_all_nodes(self) -> List['GsmNode']: + def get_all_nodes(self) -> list['GsmNode']: + """ + Collect all nodes reachable from this node (including itself). + + :return list[GsmNode]: List of all reachable nodes. + """ result = [self] backing_set = {self} for state in result: @@ -228,8 +333,13 @@ def get_all_nodes(self) -> List['GsmNode']: result.append(child) return result - def is_tree(self): - q: List['GsmNode'] = [self] + def is_tree(self) -> bool: + """ + Check whether the structure reachable from this node is a tree (no shared/repeated nodes). + + :return bool: True if the structure is a tree. + """ + q: list['GsmNode'] = [self] backing_set = {self} while len(q) != 0: current = q.pop(0) @@ -242,7 +352,16 @@ def is_tree(self): return True def to_automaton(self, output_behavior: OutputBehavior, transition_behavior: TransitionBehavior, - check_behavior=True, set_prefix=False) -> Automaton: + check_behavior: bool = True, set_prefix: bool = False) -> Automaton: + """ + Convert the tree/graph reachable from this node into a concrete AALpy automaton. + + :param OutputBehavior output_behavior: Either "moore" or "mealy". + :param TransitionBehavior transition_behavior: Either "deterministic", "nondeterministic" or "stochastic". + :param bool check_behavior: Whether to validate that the structure actually matches the requested behaviors. + :param bool set_prefix: Whether to record the access prefix on each created state. + :return Automaton: The resulting automaton instance. + """ nodes = self.get_all_nodes() if check_behavior: @@ -305,13 +424,28 @@ def to_automaton(self, output_behavior: OutputBehavior, transition_behavior: Tra return AutomatonClass(initial_state, list(state_map.values())) - def visualize(self, path: Union[str, pathlib.Path], output_behavior: OutputBehavior = "mealy", format: str = "dot", - engine="dot", *, - state_label: StateFunction = None, state_color: StateFunction = None, - trans_label: TransitionFunction = None, trans_color: TransitionFunction = None, - state_props: Dict[str, StateFunction] = None, - trans_props: Dict[str, TransitionFunction] = None, - node_naming: StateFunction = None): + def visualize(self, path: str | pathlib.Path, output_behavior: OutputBehavior = "mealy", format: str = "dot", + engine: str = "dot", *, + state_label: StateFunction | None = None, state_color: StateFunction | None = None, + trans_label: TransitionFunction | None = None, trans_color: TransitionFunction | None = None, + state_props: dict[str, StateFunction] | None = None, + trans_props: dict[str, TransitionFunction] | None = None, + node_naming: StateFunction | None = None) -> None: + """ + Render the tree/graph reachable from this node to a graphviz file. + + :param str | pathlib.Path path: Output path (without extension). + :param OutputBehavior output_behavior: Either "moore" or "mealy", controls default labeling. + :param str format: Output format passed to graphviz (e.g. "dot", "pdf", "png"). + :param str engine: Graphviz layout engine to use. + :param StateFunction | None state_label: Function computing a state's label. + :param StateFunction | None state_color: Function computing a state's color. + :param TransitionFunction | None trans_label: Function computing a transition's label. + :param TransitionFunction | None trans_color: Function computing a transition's color. + :param dict[str, StateFunction] | None state_props: Extra per-state graphviz properties, keyed by property name. + :param dict[str, TransitionFunction] | None trans_props: Extra per-transition graphviz properties, keyed by property name. + :param StateFunction | None node_naming: Function assigning a unique graphviz node name to each state. + """ # handle default parameters if output_behavior not in ["moore", "mealy", None]: @@ -322,26 +456,26 @@ def visualize(self, path: Union[str, pathlib.Path], output_behavior: OutputBehav trans_props = dict() if state_label is None: if output_behavior == "moore": - def state_label(node: GsmNode): + def state_label(node: GsmNode) -> str: return f'{node.get_prefix_output()} {node.count()}' else: - def state_label(node: GsmNode): + def state_label(node: GsmNode) -> str: return f'{sum(t.count for _, _, t in node.transition_iterator())}' if trans_label is None and "label" not in trans_props: if output_behavior == "moore": - def trans_label(node: GsmNode, in_sym, out_sym): + def trans_label(node: GsmNode, in_sym: Any, out_sym: Any) -> str: return f'{in_sym} [{node.transitions[in_sym][out_sym].count}]' else: - def trans_label(node: GsmNode, in_sym, out_sym): + def trans_label(node: GsmNode, in_sym: Any, out_sym: Any) -> str: return f'{in_sym} / {out_sym} [{node.transitions[in_sym][out_sym].count}]' if state_color is None: - def state_color(x): return "black" + def state_color(x: 'GsmNode') -> str: return "black" if trans_color is None: - def trans_color(x, y, z): return "black" + def trans_color(x: 'GsmNode', y: Any, z: Any) -> str: return "black" if node_naming is None: node_dict = dict() - def node_naming(node: GsmNode): + def node_naming(node: GsmNode) -> str: if node not in node_dict: node_dict[node] = f"s{len(node_dict)}" return node_dict[node] @@ -378,7 +512,12 @@ def node_naming(node: GsmNode): file_ext = 'dot' graph.write(path=str(path) + "." + file_ext, prog=engine, format=format) - def make_input_complete(self) -> List[Tuple['GsmNode', Any, Any]]: + def make_input_complete(self) -> list[tuple['GsmNode', Any, Any]]: + """ + Add self-looping transitions for any input undefined at some node, using the node's prefix output. + + :return list[tuple[GsmNode, Any, Any]]: List of (node, input, output) triples for the added transitions. + """ all_nodes = self.get_all_nodes() inputs = {in_sym for node in all_nodes for in_sym in node.transitions} missing_trans = [] @@ -392,7 +531,12 @@ def make_input_complete(self) -> List[Tuple['GsmNode', Any, Any]]: transitions[out_sym] = t_info return missing_trans - def add_trace(self, trace: IOTrace): + def add_trace(self, trace: IOTrace) -> None: + """ + Add an IO trace to the tree rooted at this node, extending it with new nodes as necessary. + + :param IOTrace trace: Sequence of (input, output) pairs to add. + """ curr_node: GsmNode = self for in_sym, out_sym in trace: transitions = curr_node.transitions[in_sym] @@ -406,7 +550,12 @@ def add_trace(self, trace: IOTrace): node = info.target curr_node = node - def add_labeled_sequence(self, example: IOExample): + def add_labeled_sequence(self, example: IOExample) -> None: + """ + Add a labeled input sequence (inputs with a single label attached at the end) to the tree. + + :param IOExample example: (inputs, output) pair, where output labels the state reached by inputs. + """ inputs, output = example curr_node: GsmNode = self in_sym = None @@ -426,21 +575,29 @@ def add_labeled_sequence(self, example: IOExample): node = t_info.target else: # This should never happen - raise ValueError("nondeterminism encountered for GSM with labeled_sequences. not supported") + raise ValueError("Nondeterminism encountered for GSM with labeled_sequences. not supported") curr_node = node # set last output - outputs_agree = curr_node.resolve_unknown_prefix_output(output) - if not outputs_agree: - raise ValueError(f"nondeterminism encountered for GSM with labeled_sequences. not supported. conflicting outputs for prefix {curr_node.get_prefix(False)}: {curr_node.get_prefix_output()} vs {output}") + curr_node.resolve_unknown_prefix_output(output) pred = curr_node.predecessor if pred: transitions = pred.transitions[in_sym] if unknown_output in transitions: transitions[output] = transitions.pop(unknown_output) + if output not in transitions: + raise ValueError("nondeterminism encountered for GSM with labeled_sequences. not supported") @staticmethod - def createPTA(data, output_behavior, data_format=None) -> 'GsmNode': + def createPTA(data: Any, output_behavior: OutputBehavior, data_format: DataFormat | None = None) -> 'GsmNode': + """ + Build a prefix tree acceptor (PTA) from the given data. + + :param Any data: Learning data, in one of the supported data formats (or already a GsmNode tree). + :param OutputBehavior output_behavior: Either "moore" or "mealy". + :param DataFormat | None data_format: Explicit data format, or None to auto-detect. + :return GsmNode: The root node of the constructed (or passed-through) PTA. + """ if data_format is None: data_format = detect_data_format(data) if data_format not in DataFormatRange: @@ -458,26 +615,36 @@ def createPTA(data, output_behavior, data_format=None) -> 'GsmNode': if output_behavior == "moore": initial_output = data[0][0] root_node.prefix_access_pair = (None, initial_output) - def trace_iterator(trace): - it = iter(trace) - first = next(it) - if first != initial_output: - raise ValueError(f"conflicting initial output {initial_output} vs {trace[0]}") - yield from it - data = (trace_iterator(trace) for trace in data) + data = (d[1:] for d in data) for trace in data: if data_format == "traces": trace = (("step", t) for t in trace) root_node.add_trace(trace) return root_node - def is_locally_deterministic(self): + def is_locally_deterministic(self) -> bool: + """ + Check whether this node has at most one outgoing transition per input symbol. + + :return bool: True if this node is locally deterministic. + """ return all(len(item) == 1 for item in self.transitions.values()) - def is_deterministic(self): + def is_deterministic(self) -> bool: + """ + Check whether all nodes reachable from this node are locally deterministic. + + :return bool: True if the whole structure is deterministic. + """ return all(node.is_locally_deterministic() for node in self.get_all_nodes()) - def deterministic_compatible(self, other: 'GsmNode'): + def deterministic_compatible(self, other: 'GsmNode') -> bool: + """ + Check whether this node and another node have compatible outgoing input symbols (ignoring unknown outputs). + + :param GsmNode other: Node to compare against. + :return bool: True if the two nodes are compatible with a deterministic merge. + """ for _, trans_self, trans_other in intersection_iterator(self.transitions, other.transitions): if unknown_output in trans_self or unknown_output in trans_other: continue @@ -485,7 +652,12 @@ def deterministic_compatible(self, other: 'GsmNode'): return False return True - def is_moore(self): + def is_moore(self) -> bool: + """ + Check whether the structure reachable from this node satisfies the Moore condition (output determined by state). + + :return bool: True if the structure is Moore-compatible. + """ for node in self.get_all_nodes(): for in_sym, out_sym, transition in node.transition_iterator(): child_output = transition.target.get_prefix_output() @@ -493,12 +665,23 @@ def is_moore(self): return False return True - def moore_compatible(self, other: 'GsmNode'): + def moore_compatible(self, other: 'GsmNode') -> bool: + """ + Check whether this node and another node have compatible (or unknown) prefix outputs. + + :param GsmNode other: Node to compare against. + :return bool: True if the prefix outputs are compatible. + """ so = self.get_prefix_output() oo = other.get_prefix_output() return so == oo or so is unknown_output or oo is unknown_output - def local_log_likelihood_contribution(self): + def local_log_likelihood_contribution(self) -> float: + """ + Compute this node's contribution to the log-likelihood of the data given the model. + + :return float: The local log-likelihood contribution. + """ llc = 0 for in_sym, trans in self.transitions.items(): total_count = 0 @@ -509,7 +692,12 @@ def local_log_likelihood_contribution(self): llc -= total_count * math.log(total_count) return llc - def count(self): + def count(self) -> int: + """ + Compute the total transition count over all outgoing transitions of this node. + + :return int: Sum of transition counts. + """ return sum(trans.count for _, _, trans in self.transition_iterator()) default_order = functools.cmp_to_key(lambda a, b: -1 if a < b else 1) diff --git a/aalpy/learning_algs/general_passive/Instrumentation.py b/aalpy/learning_algs/general_passive/Instrumentation.py index bd20bfb36bb..e854216975d 100644 --- a/aalpy/learning_algs/general_passive/Instrumentation.py +++ b/aalpy/learning_algs/general_passive/Instrumentation.py @@ -1,5 +1,6 @@ +# Instrumentation implementations for the general passive state-merging algorithm: +# progress reporting and a debugging helper that checks merges/promotions against ground truth. from time import perf_counter -from typing import Dict, Optional from aalpy.learning_algs.general_passive.GeneralizedStateMerging import Instrumentation, Partitioning, \ GeneralizedStateMerging @@ -7,12 +8,19 @@ class ProgressReport(Instrumentation): - def __init__(self, lvl): + """Instrumentation that prints progress information (timing, state/merge counts) during learning.""" + + def __init__(self, lvl: int) -> None: + """ + Create a progress reporter. + + :param int lvl: Verbosity level; 0 disables detailed tracking, higher values print more information. + """ super().__init__() self.lvl = lvl if lvl < 1: return - self.gsm: Optional[GeneralizedStateMerging] = None + self.gsm: GeneralizedStateMerging | None = None self.log = [] self.pta_size = None self.nr_merged_states_total = 0 @@ -23,7 +31,12 @@ def __init__(self, lvl): self.previous_time = None - def reset(self, gsm: GeneralizedStateMerging): + def reset(self, gsm: GeneralizedStateMerging) -> None: + """ + Reset all tracked statistics at the start of a learning run. + + :param GeneralizedStateMerging gsm: The GSM instance being run. + """ self.gsm = gsm self.log = [] self.pta_size = None @@ -34,7 +47,12 @@ def reset(self, gsm: GeneralizedStateMerging): self.stats = dict() self.previous_time = perf_counter() - def pta_construction_done(self, root): + def pta_construction_done(self, root: GsmNode) -> None: + """ + Record and print statistics about the constructed PTA. + + :param GsmNode root: Root node of the constructed PTA. + """ pta_const_time = perf_counter() - self.previous_time self.stats["pta creation time"] = pta_const_time print(f'PTA Construction Time: {round(pta_const_time, 2)} s') @@ -48,25 +66,43 @@ def pta_construction_done(self, root): print(f'min / avg / max depth : {min(depth)} / {sum(depth) / len(depth)} / {max(depth)}') self.previous_time = perf_counter() - def print_status(self): + def print_status(self) -> None: + """ + Print the current learning progress (automaton size, merges, remaining states) to stdout. + """ reset_char = "\33[2K\r" print_str = reset_char + f'Current automaton size: {self.nr_red_states}' if 0 < self.lvl and not self.gsm.compatibility_on_futures: print_str += f' Merged: {self.nr_merged_states_total} Remaining: {self.pta_size - self.nr_red_states - self.nr_merged_states_total}' print(print_str, end="") - def log_promote(self, node: GsmNode): + def log_promote(self, node: GsmNode) -> None: + """ + Record a promotion of a blue node to red. + + :param GsmNode node: The promoted node. + """ self.log.append(["promote", (node.get_prefix(),)]) self.nr_red_states += 1 self.print_status() - def log_merge(self, part: Partitioning): + def log_merge(self, part: Partitioning) -> None: + """ + Record a merge of a blue node into a red node. + + :param Partitioning part: The partitioning describing the performed merge. + """ self.log.append(["merge", (part.red.get_prefix(), part.blue.get_prefix())]) self.nr_merged_states_total += len(part.full_mapping) - len(part.red_mapping) self.nr_merged_states += 1 self.print_status() - def learning_done(self, root: GsmNode): + def learning_done(self, root: GsmNode) -> None: + """ + Record and print final statistics once learning has finished. + + :param GsmNode root: Root node of the learned model. + """ learning_time = perf_counter() - self.previous_time self.stats["learning time"] = learning_time self.stats["total time"] = learning_time + self.stats["pta creation time"] @@ -77,19 +113,36 @@ def learning_done(self, root: GsmNode): class MergeViolationDebugger(Instrumentation): - def __init__(self, ground_truth: GsmNode): + """Instrumentation that cross-checks merges and promotions against a known ground-truth model.""" + + def __init__(self, ground_truth: GsmNode) -> None: + """ + Create a debugger that compares merges/promotions to a ground-truth GsmNode tree. + + :param GsmNode ground_truth: Root node of the ground-truth model. + """ super().__init__() self.root = ground_truth - self.map: Dict[GsmNode, GsmNode] = dict() + self.map: dict[GsmNode, GsmNode] = dict() self.log = [] - self.gsm: Optional[GeneralizedStateMerging] = None + self.gsm: GeneralizedStateMerging | None = None - def reset(self, gsm: GeneralizedStateMerging): + def reset(self, gsm: GeneralizedStateMerging) -> None: + """ + Reset tracked state at the start of a learning run. + + :param GeneralizedStateMerging gsm: The GSM instance being run. + """ self.gsm = gsm self.map = dict() self.log = [] - def log_promote(self, new_red: GsmNode): + def log_promote(self, new_red: GsmNode) -> None: + """ + Check a promotion against the ground truth and record whether it was correct. + + :param GsmNode new_red: The promoted node. + """ new_red_prefix = new_red.get_prefix() node = self.root.get_by_prefix(new_red_prefix) old_red = self.map.get(node) @@ -103,7 +156,12 @@ def log_promote(self, new_red: GsmNode): print(f" Representative (new): {new_red_prefix}") self.log.append(("wrong promote", new_red_prefix)) - def log_merge(self, part: Partitioning): + def log_merge(self, part: Partitioning) -> None: + """ + Check a merge against the ground truth and record whether it was correct. + + :param Partitioning part: The partitioning describing the performed merge. + """ red_prefix = part.red.get_prefix() blue_prefix = part.blue.get_prefix() red_node = self.root.get_by_prefix(red_prefix) diff --git a/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py b/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py index 555763c6794..e385f9b0135 100644 --- a/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py +++ b/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py @@ -1,15 +1,27 @@ +# Score/compatibility function building blocks used to guide the general passive +# state-merging algorithm (local compatibility checks and global merge scores). +from collections.abc import Callable, Iterable from math import sqrt, log -from typing import Callable, Dict, List, Iterable, Any +from typing import Any from aalpy.learning_algs.general_passive.GsmNode import GsmNode, intersection_iterator, union_iterator, TransitionInfo LocalCompatibilityFunction = Callable[[GsmNode, GsmNode], bool] -ScoreFunction = Callable[[Dict[GsmNode, GsmNode]], Any] +ScoreFunction = Callable[[dict[GsmNode, GsmNode]], Any] AggregationFunction = Callable[[Iterable], Any] class ScoreCalculation: - def __init__(self, local_compatibility: LocalCompatibilityFunction = None, score_function: ScoreFunction = None): + """Bundles a local compatibility check and a global score function used during state merging.""" + + def __init__(self, local_compatibility: LocalCompatibilityFunction = None, + score_function: ScoreFunction = None) -> None: + """ + Create a score calculation, optionally overriding the default (accept-everything) behavior. + + :param LocalCompatibilityFunction local_compatibility: Function determining local compatibility of two nodes. + :param ScoreFunction score_function: Function computing the score of a full merge partition. + """ # This is a hack that gives a simple implementation where we can easily - determine whether the default is # overridden (for optimization) - override behavior in a functional way by providing the functions as # arguments (no extra class) - override behavior in a stateful way by implementing a new class that provides @@ -19,30 +31,63 @@ def __init__(self, local_compatibility: LocalCompatibilityFunction = None, score if not hasattr(self, "score_function"): self.score_function: ScoreFunction = score_function or self.default_score_function - def reset(self): + def reset(self) -> None: + """ + Reset any internal state before starting a new learning run. No-op by default. + """ pass @staticmethod - def default_local_compatibility(a: GsmNode, b: GsmNode): + def default_local_compatibility(a: GsmNode, b: GsmNode) -> bool: + """ + Default local compatibility check: always compatible. + + :param GsmNode a: First node. + :param GsmNode b: Second node. + :return bool: Always True. + """ return True @staticmethod - def default_score_function(part: Dict[GsmNode, GsmNode]): + def default_score_function(part: dict[GsmNode, GsmNode]) -> bool: + """ + Default score function: any partition is acceptable. + + :param dict[GsmNode, GsmNode] part: Mapping of original nodes to their merged partition representative. + :return bool: Always True. + """ return True - def has_score_function(self): + def has_score_function(self) -> bool: + """ + Check whether a non-default score function is configured. + + :return bool: True if score_function was overridden. + """ return self.score_function is not self.default_score_function - def has_local_compatibility(self): + def has_local_compatibility(self) -> bool: + """ + Check whether a non-default local compatibility function is configured. + + :return bool: True if local_compatibility was overridden. + """ return self.local_compatibility is not self.default_local_compatibility -def hoeffding_compatibility(eps, compare_original=True) -> LocalCompatibilityFunction: +def hoeffding_compatibility(eps: float, compare_original: bool = True) -> LocalCompatibilityFunction: + """ + Build a local compatibility function based on the Hoeffding bound over output distributions. + + :param float eps: Confidence parameter (smaller values are stricter). + :param bool compare_original: Whether to compare counts from the original PTA rather than the current counts. + :return LocalCompatibilityFunction: Function checking whether two nodes' output distributions are compatible. + """ eps_fact = sqrt(0.5 * log(2 / eps)) count_name = "original_count" if compare_original else "count" transition_dummy = TransitionInfo(None, 0, None, 0) - def similar(a: GsmNode, b: GsmNode): + def similar(a: GsmNode, b: GsmNode) -> bool: # iterate over inputs that are common to both states for in_sym, a_trans, b_trans in intersection_iterator(a.transitions, b.transitions): # could create appropriate dict here @@ -63,18 +108,34 @@ def similar(a: GsmNode, b: GsmNode): class ScoreWithKTail(ScoreCalculation): """Applies k-Tails to a compatibility function: Compatibility is only evaluated up to a certain depth k.""" - def __init__(self, other_score: ScoreCalculation, k: int): + def __init__(self, other_score: ScoreCalculation, k: int) -> None: + """ + Wrap another score calculation, limiting local compatibility checks to depth k. + + :param ScoreCalculation other_score: Score calculation to delegate to within depth k. + :param int k: Maximum depth (relative to the blue node's initial depth) at which compatibility is checked. + """ super().__init__(None, other_score.score_function) self.other_score = other_score self.k = k self.depth_offset = None - def reset(self): + def reset(self) -> None: + """ + Reset the wrapped score calculation and the depth offset. + """ self.other_score.reset() self.depth_offset = None - def local_compatibility(self, a: GsmNode, b: GsmNode): + def local_compatibility(self, a: GsmNode, b: GsmNode) -> bool: + """ + Check local compatibility, treating nodes beyond depth k as automatically compatible. + + :param GsmNode a: First (red) node. + :param GsmNode b: Second (blue) node, assumed to be tree-shaped. + :return bool: True if compatible (or beyond depth k), False otherwise. + """ # assuming b is tree shaped. if self.depth_offset is None: self.depth_offset = b.get_prefix_length() @@ -87,8 +148,16 @@ def local_compatibility(self, a: GsmNode, b: GsmNode): class ScoreWithSinks(ScoreCalculation): """This class allows rejecting merge candidates based on additional criteria for the initial merge""" - - def __init__(self, other_score: ScoreCalculation, sink_cond: Callable[[GsmNode], bool], allow_sink_merge=True): + + def __init__(self, other_score: ScoreCalculation, sink_cond: Callable[[GsmNode], bool], + allow_sink_merge: bool = True) -> None: + """ + Wrap another score calculation, additionally rejecting merges involving "sink" nodes. + + :param ScoreCalculation other_score: Score calculation to delegate to. + :param Callable[[GsmNode], bool] sink_cond: Predicate identifying sink nodes. + :param bool allow_sink_merge: Whether merges between two sink nodes are allowed. + """ super().__init__(None, other_score.score_function) self.other_score = other_score self.sink_cond = sink_cond @@ -96,11 +165,21 @@ def __init__(self, other_score: ScoreCalculation, sink_cond: Callable[[GsmNode], self.is_first = True - def reset(self): + def reset(self) -> None: + """ + Reset the wrapped score calculation and the first-call flag. + """ self.other_score.reset() self.is_first = True - def local_compatibility(self, a: GsmNode, b: GsmNode): + def local_compatibility(self, a: GsmNode, b: GsmNode) -> bool: + """ + Check local compatibility, additionally applying the sink condition on the first call. + + :param GsmNode a: First (red) node. + :param GsmNode b: Second (blue) node. + :return bool: True if compatible according to the sink condition and the wrapped score calculation. + """ if self.is_first: self.is_first = False a_sink, b_sink = self.sink_cond(a), self.sink_cond(b) @@ -117,33 +196,68 @@ class ScoreCombinator(ScoreCalculation): individual methods in a user defined manner. It uses generator expressions to allow for short circuit evaluation. """ - def __init__(self, scores: List[ScoreCalculation], aggregate_compatibility: AggregationFunction = None, - aggregate_score: AggregationFunction = None): + def __init__(self, scores: list[ScoreCalculation], aggregate_compatibility: AggregationFunction = None, + aggregate_score: AggregationFunction = None) -> None: + """ + Combine several score calculations into one. + + :param list[ScoreCalculation] scores: Score calculations to combine. + :param AggregationFunction aggregate_compatibility: Function aggregating the individual compatibility results. + :param AggregationFunction aggregate_score: Function aggregating the individual score results. + """ super().__init__() self.scores = scores self.aggregate_compatibility = aggregate_compatibility or self.default_aggregate_compatibility self.aggregate_score = aggregate_score or self.default_aggregate_score - def reset(self): + def reset(self) -> None: + """ + Reset all combined score calculations. + """ for score in self.scores: score.reset() - def local_compatibility(self, a: GsmNode, b: GsmNode): + def local_compatibility(self, a: GsmNode, b: GsmNode) -> Any: + """ + Compute the aggregated local compatibility of two nodes over all combined score calculations. + + :param GsmNode a: First node. + :param GsmNode b: Second node. + :return Any: Aggregated compatibility result. + """ return self.aggregate_compatibility(score.local_compatibility(a, b) for score in self.scores) - def score_function(self, part: Dict[GsmNode, GsmNode]): + def score_function(self, part: dict[GsmNode, GsmNode]) -> Any: + """ + Compute the aggregated score of a merge partition over all combined score calculations. + + :param dict[GsmNode, GsmNode] part: Mapping of original nodes to their merged partition representative. + :return Any: Aggregated score result. + """ return self.aggregate_score(score.score_function(part) for score in self.scores) @staticmethod - def default_aggregate_compatibility(compatibility_iterable): - """Checks whether any of the individual tests rejects and accept otherwise.""" + def default_aggregate_compatibility(compatibility_iterable: Iterable) -> Any: + """ + Commits to the first value that is not inconclusive (== None). Accepts if in doubt. + + :param Iterable compatibility_iterable: Iterable of compatibility results. + :return Any: The first non-None result, or True if all are None. + """ for compat in compatibility_iterable: - if compat is False: - return False + if compat is None: + continue + return compat return True @staticmethod - def default_aggregate_score(score_iterable): + def default_aggregate_score(score_iterable: Iterable) -> list: + """ + Default score aggregation: collect all scores into a list. + + :param Iterable score_iterable: Iterable of score results. + :return list: List of the individual scores. + """ return list(score_iterable) @@ -153,9 +267,12 @@ def local_to_global_compatibility(local_fun: LocalCompatibilityFunction) -> Scor the new partitions with all nodes that make up that partition. One use case for this is to evaluate a local score function after the partitions are complete. The order of arguments for the local compatibility function is partition, original. + + :param LocalCompatibilityFunction local_fun: Local compatibility function to lift to a global score function. + :return ScoreFunction: Global score function returning False if any local check fails, True otherwise. """ - def fun(part: Dict[GsmNode, GsmNode]): + def fun(part: dict[GsmNode, GsmNode]) -> bool: for old_node, new_node in part.items(): if local_fun(new_node, old_node) is False: # Follows local_fun(red, blue) return False @@ -164,7 +281,13 @@ def fun(part: Dict[GsmNode, GsmNode]): return fun -def differential_info(part: Dict[GsmNode, GsmNode]): +def differential_info(part: dict[GsmNode, GsmNode]) -> tuple[float, int]: + """ + Compute the change in log-likelihood and number of parameters caused by a merge partition. + + :param dict[GsmNode, GsmNode] part: Mapping of original nodes to their merged partition representative. + :return tuple[float, int]: (log-likelihood difference, parameter count difference) between old and new nodes. + """ relevant_nodes_old = list(part.keys()) relevant_nodes_new = set(part.values()) @@ -177,34 +300,65 @@ def differential_info(part: Dict[GsmNode, GsmNode]): return partial_llh_old - partial_llh_new, num_params_old - num_params_new -def transform_score(score, transform: Callable): +def transform_score(score: Any, transform: Callable) -> Any: + """ + Apply a transformation to a score, a score function, or a ScoreCalculation's score function. + + :param Any score: A plain value, a callable score function, or a ScoreCalculation instance. + :param Callable transform: Function to apply to the (eventual) score value. + :return Any: The transformed score, callable, or ScoreCalculation. + """ if isinstance(score, Callable): return lambda *args: transform(score(*args)) if isinstance(score, ScoreCalculation): - inner_score_function = score.score_function - score.score_function = lambda *args: transform(inner_score_function(*args)) + score.score_function = lambda *args: transform(score.score_function(*args)) return score return transform(score) -def make_greedy(score): +def make_greedy(score: Any) -> Any: + """ + Transform a score into a greedy (boolean) score: accept anything but a False/reject result. + + :param Any score: A plain value, callable score function, or ScoreCalculation instance. + :return Any: The transformed score, callable, or ScoreCalculation. + """ return transform_score(score, lambda x: x is not False) -def lower_threshold(score, thresh): +def lower_threshold(score: Any, thresh: Any) -> Any: + """ + Transform a score so that it is rejected (False) unless it exceeds a threshold. + + :param Any score: A plain value, callable score function, or ScoreCalculation instance. + :param Any thresh: Threshold the score must exceed to be accepted. + :return Any: The transformed score, callable, or ScoreCalculation. + """ return transform_score(score, lambda x: x if thresh < x else False) -def AIC_score(alpha=0) -> ScoreFunction: - def score(part: Dict[GsmNode, GsmNode]): +def AIC_score(alpha: float = 0) -> ScoreFunction: + """ + Build a score function based on the Akaike information criterion (AIC). + + :param float alpha: Threshold applied to the AIC-based score. + :return ScoreFunction: Score function computing the AIC-based score of a merge partition. + """ + def score(part: dict[GsmNode, GsmNode]) -> Any: llh_diff, param_diff = differential_info(part) return lower_threshold(param_diff - llh_diff, alpha) return score -def EDSM_frequency_score(min_evidence=-1) -> ScoreFunction: - def score(part: Dict[GsmNode, GsmNode]): +def EDSM_frequency_score(min_evidence: int = -1) -> ScoreFunction: + """ + Build a score function counting the total evidence (transition count) contradicted by a merge. + + :param int min_evidence: Minimum evidence required for the merge to be accepted. + :return ScoreFunction: Score function computing the total contradicting evidence of a merge partition. + """ + def score(part: dict[GsmNode, GsmNode]) -> Any: total_evidence = 0 for old_node, new_node in part.items(): for in_sym, trans_old, trans_new in intersection_iterator(old_node.transitions, new_node.transitions): @@ -216,8 +370,14 @@ def score(part: Dict[GsmNode, GsmNode]): return score -def EDSM_score(min_evidence=-1) -> ScoreFunction: - def score(part: Dict[GsmNode, GsmNode]): +def EDSM_score(min_evidence: int = -1) -> ScoreFunction: + """ + Build the classic Evidence Driven State Merging (EDSM) score function. + + :param int min_evidence: Minimum number of merged states required for the merge to be accepted. + :return ScoreFunction: Score function computing the number of merged states minus the number of partitions. + """ + def score(part: dict[GsmNode, GsmNode]) -> Any: nr_partitions = len(set(part.values())) nr_merged = len(part) return lower_threshold(nr_merged - nr_partitions, min_evidence) diff --git a/aalpy/learning_algs/general_passive/__init__.py b/aalpy/learning_algs/general_passive/__init__.py index e69de29bb2d..ae4f0afb696 100644 --- a/aalpy/learning_algs/general_passive/__init__.py +++ b/aalpy/learning_algs/general_passive/__init__.py @@ -0,0 +1 @@ +# Package for the Generalized State Merging (GSM) passive learning algorithms. diff --git a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmLstar.py b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmLstar.py index af54aa59a64..ac168304713 100644 --- a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmLstar.py +++ b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmLstar.py @@ -1,5 +1,7 @@ +# L*-based active learning algorithm for abstracted observable non-deterministic finite state machines (ONFSMs). import time +from aalpy.automata import Onfsm from aalpy.base import SUL, Oracle from aalpy.learning_algs.non_deterministic.AbstractedOnfsmObservationTable import AbstractedNonDetObservationTable from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper @@ -8,8 +10,9 @@ print_options = [0, 1, 2, 3] -def run_abstracted_ONFSM_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, abstraction_mapping: dict, n_sampling=100, - max_learning_rounds=None, return_data=False, print_level=2): +def run_abstracted_ONFSM_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, abstraction_mapping: dict, + n_sampling: int = 100, max_learning_rounds: int | None = None, + return_data: bool = False, print_level: int = 2) -> Onfsm | tuple[Onfsm, dict]: """ Based on ''Learning Abstracted Non-deterministic Finite State Machines'' from Pferscher and Aichernig. The algorithm learns an abstracted onfsm of a non-deterministic system. For the additional abstraction, @@ -19,30 +22,21 @@ def run_abstracted_ONFSM_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, abst Note that this is the inherent flaw of the all-weather assumption. (All outputs will be seen) AALpy v.2.0 will try to solve that problem with a novel approach. - Args: - - alphabet: input alphabet - - sul: system under learning - - eq_oracle: equivalence oracle - - abstraction_mapping: dictionary containing mappings from abstracted to concrete values (equivalence classes) - - n_sampling: number of times that membership/input queries will be asked for each cell in the observation - (Default value = 100) - - max_learning_rounds: if max_learning_rounds is reached, learning will stop (Default value = None) - - return_data: if True, map containing all information like number of queries... will be returned - (Default value = False) - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - Returns: - learned abstracted ONFSM - + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param Oracle eq_oracle: Equivalence oracle. + :param dict abstraction_mapping: Dictionary containing mappings from abstracted to concrete values + (equivalence classes). + :param int n_sampling: Number of times that membership/input queries will be asked for each cell in the + observation table (Default value = 100). + :param int | None max_learning_rounds: If max_learning_rounds is reached, learning will stop (Default value = + None). + :param bool return_data: If True, map containing all information like number of queries... will be returned + (Default value = False). + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - + educational/debug (Default value = 2). + :return Onfsm | tuple[Onfsm, dict]: Learned abstracted ONFSM, or a (learned abstracted ONFSM, learning info) + pair if return_data is True. """ start_time = time.time() eq_query_time = 0 diff --git a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py index e01ad1a1d55..f353fd5903a 100644 --- a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py +++ b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py @@ -1,4 +1,6 @@ +# Observation table used by the abstracted ONFSM L* learning algorithm. from collections import defaultdict +from typing import Any from aalpy.automata import Onfsm, OnfsmState from aalpy.learning_algs.non_deterministic.OnfsmObservationTable import NonDetObservationTable @@ -7,16 +9,20 @@ class AbstractedNonDetObservationTable: - def __init__(self, alphabet: list, sul: NonDeterministicSULWrapper, abstraction_mapping: dict, n_sampling=100): + """ + Observation table for learning abstracted observable non-deterministic finite state machines, where outputs + are grouped into equivalence classes via an abstraction mapping. + """ + + def __init__(self, alphabet: list, sul: NonDeterministicSULWrapper, abstraction_mapping: dict, + n_sampling: int = 100) -> None: """ Construction of the abstracted non-deterministic observation table. - Args: - - alphabet: input alphabet - sul: system under learning - abstraction_mapping: map that translates outputs to abstracted outputs - n_sampling: number of samples to be performed for each cell + :param list alphabet: Input alphabet. + :param NonDeterministicSULWrapper sul: System under learning. + :param dict abstraction_mapping: Map that translates outputs to abstracted outputs. + :param int n_sampling: Number of samples to be performed for each cell. """ assert alphabet is not None and sul is not None @@ -35,25 +41,23 @@ def __init__(self, alphabet: list, sul: NonDeterministicSULWrapper, abstraction_ empty_word = tuple() self.S.append((empty_word, empty_word)) - def update_obs_table(self, s_set=None, e_set: list = None): + def update_obs_table(self, s_set: list[tuple[tuple, tuple]] | None = None, + e_set: list[tuple] | None = None) -> None: """ - Perform the membership queries and abstraction on observation table - With the all-weather assumption, each output query is tried a number of times on the system, - and the driver reports the set of all possible outputs. - - Args: - - s_set: Prefixes of S set on which to preform membership queries (Default value = None) - e_set: Suffixes of E set on which to perform membership queries - + Perform the membership queries and abstraction on observation table. + With the all-weather assumption, each output query is tried a number of times on the system, + and the driver reports the set of all possible outputs. + :param list[tuple[tuple, tuple]] | None s_set: Prefixes of S set on which to perform membership queries + (Default value = None). + :param list[tuple] | None e_set: Suffixes of E set on which to perform membership queries. """ self.observation_table.query_missing_observations(s_set, e_set) self.abstract_obs_table() self.clean_obs_table() - def abstract_obs_table(self): + def abstract_obs_table(self) -> None: """ Creation of abstracted observation table. The provided abstraction mapping is used to replace outputs by abstracted outputs. @@ -77,17 +81,13 @@ def abstract_obs_table(self): abstracted_outputs.append(abstract_output) self.add_to_T(s, e, tuple(abstracted_outputs)) - def add_to_T(self, s, e, value): + def add_to_T(self, s: tuple[tuple, tuple], e: tuple, value: tuple) -> None: """ Add values to the cell at T[s][e]. - Args: - - s: prefix - e: element of S - value: value to be added to the cell - - + :param tuple[tuple, tuple] s: Prefix. + :param tuple e: Element of E. + :param tuple value: Value to be added to the cell. """ if e not in self.T[s]: self.T[s][e] = set() @@ -95,30 +95,35 @@ def add_to_T(self, s, e, value): # CHANGED # helper function - def get_all_outputs(self, s, e): + def get_all_outputs(self, s: tuple[tuple, tuple], e: tuple) -> set: + """ + Collects all observed output traces for the given row and suffix. + + :param tuple[tuple, tuple] s: Prefix. + :param tuple e: Element of E. + :return set: Set of observed output traces. + """ cell_outputs = set() cell_outputs.update(self.sul.cache.get_all_traces(s, e)) return cell_outputs - def update_extended_S(self, row_prefix=None): + def update_extended_S(self, row_prefix: tuple[tuple, tuple] | None = None) -> list[tuple[tuple, tuple]]: """ - Helper generator function that returns extended S, or S.A set. + Helper function that returns extended S, or S.A set. For all values in the cell, create a new row where inputs is parent input plus element of alphabet, and output is parent output plus value in cell. - Returns: - - New rows of extended S set. + :param tuple[tuple, tuple] | None row_prefix: If given, only extend this single row instead of all of S. + :return list[tuple[tuple, tuple]]: New rows of extended S set. """ return self.observation_table.get_extended_S(row_prefix=row_prefix) - def get_row_to_close(self): + def get_row_to_close(self) -> tuple[tuple, tuple] | None: """ - Get row for that needs to be closed. - - Returns: + Get row that needs to be closed. - row that will be moved to S set and closed + :return tuple[tuple, tuple] | None: Row that will be moved to S set and closed, or None if all rows are + already closed. """ s_rows = set() for s in self.S: @@ -134,13 +139,11 @@ def get_row_to_close(self): return None - def get_row_to_complete(self): + def get_row_to_complete(self) -> tuple[tuple, tuple] | None: """ - Get row for that needs to be completed. + Get row that needs to be completed. - Returns: - - row that will be added to S.A + :return tuple[tuple, tuple] | None: Row that will be added to S.A, or None if the table is complete. """ s_rows = set() @@ -169,9 +172,12 @@ def get_row_to_complete(self): return None - def get_row_to_make_consistent(self): + def get_row_to_make_consistent(self) -> tuple | None: """ Get row that violates consistency. + + :return tuple | None: Distinguishing input sequence that violates consistency, or None if the table is + consistent. """ unified_S = self.S + self.S_dot_A s_rows = set() @@ -206,20 +212,15 @@ def get_row_to_make_consistent(self): return None - def get_distinctive_input_sequence(self, first_row, second_row, inp): + def get_distinctive_input_sequence(self, first_row: tuple[tuple, tuple], second_row: tuple[tuple, tuple], + inp: tuple) -> tuple | None: """ - get input sequence that leads to a different output sequence for two given input/output sequences - - Args: - - first_row: row to be compared - second_row: row to be compared - inp: appended input to first_row and second_row that leads to different state - - Returns: - - input sequence that leads to different outputs + Get input sequence that leads to a different output sequence for two given input/output sequences. + :param tuple[tuple, tuple] first_row: Row to be compared. + :param tuple[tuple, tuple] second_row: Row to be compared. + :param tuple inp: Appended input to first_row and second_row that leads to different state. + :return tuple | None: Input sequence that leads to different outputs, or None if none is found. """ for e in self.E: if len(self.T[first_row][e].difference(self.T[second_row][e])) > 0: @@ -227,15 +228,19 @@ def get_distinctive_input_sequence(self, first_row, second_row, inp): return None - def update_E(self, seq): + def update_E(self, seq: tuple) -> None: + """ + Adds a suffix to the E set if not already present. + + :param tuple seq: Suffix to add. + """ if seq not in self.E: self.E.append(seq) - def clean_obs_table(self): + def clean_obs_table(self) -> None: """ Moves duplicates from S to S_dot_A. The entries in S_dot_A which are based on the moved row get deleted. The table will be smaller and more efficient. - """ # just for testing without cleaning # return False @@ -262,19 +267,13 @@ def clean_obs_table(self): else: hashed_rows_from_s.add(hashed_s_row) - def row_to_hashable(self, row_prefix): + def row_to_hashable(self, row_prefix: tuple[tuple, tuple]) -> tuple: """ - Creates the hashable representation of the row. Frozenset is used as the order of element in each cell does not - matter - - Args: - - row_prefix: prefix of the row in the observation table - - Returns: - - hashable representation of the row + Creates the hashable representation of the row. Frozenset is used as the order of element in each cell does + not matter. + :param tuple[tuple, tuple] row_prefix: Prefix of the row in the observation table. + :return tuple: Hashable representation of the row. """ row_repr = tuple() for e in self.E: @@ -286,10 +285,7 @@ def gen_hypothesis(self) -> Onfsm: """ Generate automaton based on the values found in the abstracted observation table. - Returns: - - Current abstracted hypothesis - + :return Onfsm: Current abstracted hypothesis. """ state_distinguish = dict() states_dict = dict() @@ -330,17 +326,12 @@ def gen_hypothesis(self) -> Onfsm: return automaton - def extend_S_dot_A(self, cex_prefixes: list): + def extend_S_dot_A(self, cex_prefixes: list[tuple[tuple, tuple]]) -> list[tuple[tuple, tuple]]: """ Extends S.A based on counterexample prefixes. - Args: - - cex_prefixes: input/output sequences that are added to S.A - - Returns: - - input/output sequences that have been added to the S.A + :param list[tuple[tuple, tuple]] cex_prefixes: Input/output sequences that are added to S.A. + :return list[tuple[tuple, tuple]]: Input/output sequences that have been added to the S.A. """ prefixes = self.S + self.S_dot_A prefixes_to_extend = [] @@ -350,31 +341,24 @@ def extend_S_dot_A(self, cex_prefixes: list): self.S_dot_A.append(cex_prefix) return prefixes_to_extend - def get_abstraction(self, out): + def get_abstraction(self, out: Any) -> Any: """ Get an abstraction for a concrete output. If such abstraction is not defined, return output. - Args: - - out: output to be abstracted if possible - - Returns: - - abstracted output or output itself + :param Any out: Output to be abstracted if possible. + :return Any: Abstracted output or output itself. """ return self.abstraction_mapping[out] if out in self.abstraction_mapping.keys() else out - def cex_processing(self, cex: tuple, hypothesis: Onfsm): + def cex_processing(self, cex: tuple[list, list], hypothesis: Onfsm) -> None: """ Add counterexample to the observation table. If the counterexample leads to a state where an output of the same equivalence class already exists, the prefixes of the counterexample are added to S.A. Otherwise, the postfixes of counterexample are added to E. - - Args: - - cex: counterexample that should be added to the observation table - hypothesis: onfsm that implements the counterexample + :param tuple[list, list] cex: (inputs, outputs) counterexample that should be added to the observation + table. + :param Onfsm hypothesis: ONFSM that implements the counterexample. """ cex_len = len(cex[0]) @@ -412,7 +396,11 @@ def cex_processing(self, cex: tuple, hypothesis: Onfsm): added_suffixes = extend_set(self.observation_table.E, cex_suffixes) self.update_obs_table(e_set=added_suffixes) - def clean_tables(self): + def clean_tables(self) -> None: + """ + Cleans both the underlying observation table and the abstracted table, moving duplicate rows from S to + S_dot_A while keeping both tables consistent with each other. + """ self.observation_table.clean_obs_table() self.abstract_obs_table() diff --git a/aalpy/learning_algs/non_deterministic/NonDeterministicSULWrapper.py b/aalpy/learning_algs/non_deterministic/NonDeterministicSULWrapper.py index 4d0e80c0d41..29526ab24f8 100644 --- a/aalpy/learning_algs/non_deterministic/NonDeterministicSULWrapper.py +++ b/aalpy/learning_algs/non_deterministic/NonDeterministicSULWrapper.py @@ -1,3 +1,6 @@ +# SUL wrapper that records every observed input/output trace into a TraceTree, used by ONFSM learning algorithms. +from typing import Any + from aalpy.base import SUL from aalpy.learning_algs.non_deterministic.TraceTree import TraceTree @@ -7,19 +10,36 @@ class NonDeterministicSULWrapper(SUL): Wrapper for non-deterministic SUL. After every step, input/output pair is added to the tree containing all traces. """ - def __init__(self, sul: SUL): + def __init__(self, sul: SUL) -> None: + """ + Creates a wrapper around a non-deterministic SUL that records all observed traces. + + :param SUL sul: The wrapped system under learning. + """ super().__init__() self.sul = sul self.cache = TraceTree() - def pre(self): + def pre(self) -> None: + """ + Resets the trace tree cursor and the wrapped system under learning. + """ self.cache.reset() self.sul.pre() - def post(self): + def post(self) -> None: + """ + Performs cleanup on the wrapped system under learning. + """ self.sul.post() - def step(self, letter): + def step(self, letter: Any) -> Any: + """ + Executes an action on the wrapped system under learning, records it in the trace tree and returns its result. + + :param Any letter: Single input that is executed on the SUL. + :return Any: Output received after executing the input. + """ out = self.sul.step(letter) self.cache.add_to_tree(letter, out) return out diff --git a/aalpy/learning_algs/non_deterministic/OnfsmLstar.py b/aalpy/learning_algs/non_deterministic/OnfsmLstar.py index 02e5135ac5c..b3128f3c42a 100644 --- a/aalpy/learning_algs/non_deterministic/OnfsmLstar.py +++ b/aalpy/learning_algs/non_deterministic/OnfsmLstar.py @@ -1,5 +1,7 @@ +# L*-based active learning algorithm for observable non-deterministic finite state machines (ONFSMs). import time +from aalpy.automata import Onfsm, StochasticMealyMachine from aalpy.base import SUL, Oracle from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper from aalpy.learning_algs.non_deterministic.OnfsmObservationTable import NonDetObservationTable @@ -11,39 +13,31 @@ available_oracles, available_oracles_error_msg = get_available_oracles_and_err_msg() -def run_non_det_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, n_sampling=5, samples=None, stochastic=False, - max_learning_rounds=None, return_data=False, print_level=2): +def run_non_det_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, n_sampling: int = 5, + samples: list[tuple[tuple, tuple]] | None = None, stochastic: bool = False, + max_learning_rounds: int | None = None, return_data: bool = False, + print_level: int = 2) -> Onfsm | StochasticMealyMachine | tuple[Onfsm | StochasticMealyMachine, dict]: """ A ONFSM learning algorithm that does not rely on all weather assumption (once an input is queried, all possible outputs are observed). - Args: - - alphabet: input alphabet - - sul: system under learning - - eq_oracle: equivalence oracle - - n_sampling: number of times that each cell has to be updated. If this number is to low, all-weather condition - will not hold and learning will not converge to the correct model. (Default value = 50) - - samples: input output sequences provided to learning algorithm. List of ((input sequence), (output sequence)). - - stochastic: if True, non deterministic learning will be performed but probabilities will be added to the - returned model, making it a stochastic Mealy machine - - max_learning_rounds: if max_learning_rounds is reached, learning will stop (Default value = None) - - return_data: if True, map containing all information like number of queries... will be returned - (Default value = False) - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - Returns: - learned ONFSM - + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param Oracle eq_oracle: Equivalence oracle. + :param int n_sampling: Number of times that each cell has to be updated. If this number is too low, all-weather + condition will not hold and learning will not converge to the correct model. (Default value = 5) + :param list[tuple[tuple, tuple]] | None samples: Input output sequences provided to learning algorithm. List of + ((input sequence), (output sequence)). + :param bool stochastic: If True, non deterministic learning will be performed but probabilities will be added to + the returned model, making it a stochastic Mealy machine. + :param int | None max_learning_rounds: If max_learning_rounds is reached, learning will stop (Default value = + None). + :param bool return_data: If True, map containing all information like number of queries... will be returned + (Default value = False). + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - + educational/debug (Default value = 2). + :return Onfsm | StochasticMealyMachine | tuple[Onfsm | StochasticMealyMachine, dict]: Learned ONFSM, or a + (learned ONFSM, learning info) pair if return_data is True. """ start_time = time.time() @@ -139,7 +133,14 @@ def run_non_det_Lstar(alphabet: list, sul: SUL, eq_oracle: Oracle, n_sampling=5, return hypothesis -def counterexample_not_valid(hypothesis, cex): +def counterexample_not_valid(hypothesis: Onfsm, cex: tuple[list, list] | None) -> bool: + """ + Checks whether a previously found counterexample is still valid (not yet covered) against the given hypothesis. + + :param Onfsm hypothesis: Current hypothesis. + :param tuple[list, list] | None cex: (inputs, outputs) counterexample to check, or None. + :return bool: True if there is no counterexample or the hypothesis already covers it, False otherwise. + """ if cex is None: return True hypothesis.reset_to_initial() diff --git a/aalpy/learning_algs/non_deterministic/OnfsmObservationTable.py b/aalpy/learning_algs/non_deterministic/OnfsmObservationTable.py index 68c9d143a8e..5a73f202789 100644 --- a/aalpy/learning_algs/non_deterministic/OnfsmObservationTable.py +++ b/aalpy/learning_algs/non_deterministic/OnfsmObservationTable.py @@ -1,3 +1,4 @@ +# Observation table used by the ONFSM L* learning algorithm. from collections import Counter from aalpy.automata import Onfsm, OnfsmState, StochasticMealyState, StochasticMealyMachine @@ -5,16 +6,17 @@ class NonDetObservationTable: + """ + Observation table for learning observable non-deterministic finite state machines (ONFSMs). + """ - def __init__(self, alphabet: list, sul: NonDeterministicSULWrapper, n_sampling): + def __init__(self, alphabet: list, sul: NonDeterministicSULWrapper, n_sampling: int) -> None: """ Construction of the non-deterministic observation table. - Args: - - alphabet: input alphabet - sul: system under learning - n_sampling: number of samples to be performed for each cell + :param list alphabet: Input alphabet. + :param NonDeterministicSULWrapper sul: System under learning. + :param int n_sampling: Number of samples to be performed for each cell. """ assert alphabet is not None and sul is not None @@ -40,13 +42,12 @@ def __init__(self, alphabet: list, sul: NonDeterministicSULWrapper, n_sampling): self.pruned_nodes = set() - def get_row_to_close(self): + def get_row_to_close(self) -> tuple[tuple, tuple] | None: """ - Get row for that need to be closed. - - Returns: + Get row that needs to be closed. - row that will be moved to S set and closed + :return tuple[tuple, tuple] | None: Row that will be moved to the S set and closed, or None if all rows + are already closed. """ s_rows = set() @@ -65,15 +66,14 @@ def get_row_to_close(self): self.closing_counter = 0 return None - def get_extended_S(self, row_prefix=None): + def get_extended_S(self, row_prefix: tuple[tuple, tuple] | None = None) -> list[tuple[tuple, tuple]]: """ - Helper generator function that returns extended S, or S.A set. + Helper function that returns extended S, or S.A set. For all values in the cell, create a new row where inputs is parent input plus element of alphabet, and output is parent output plus value in cell. - Returns: - - extended S set. + :param tuple[tuple, tuple] | None row_prefix: If given, only extend this single row instead of all of S. + :return list[tuple[tuple, tuple]]: Extended S set. """ rows = self.S if row_prefix is None else [row_prefix] @@ -89,7 +89,14 @@ def get_extended_S(self, row_prefix=None): S_dot_A.append(new_row) return S_dot_A - def query_missing_observations(self, s=None, e=None): + def query_missing_observations(self, s: list[tuple[tuple, tuple]] | None = None, + e: list[tuple] | None = None) -> None: + """ + Queries the SUL until every cell for the given rows/columns has been sampled at least `n_samples` times. + + :param list[tuple[tuple, tuple]] | None s: Rows to query, defaults to all of S plus the extended S set. + :param list[tuple] | None e: Columns (suffixes) to query, defaults to all of E. + """ s_set = s if s is not None else self.S + self.get_extended_S() e_set = e if e is not None else self.E @@ -98,19 +105,13 @@ def query_missing_observations(self, s=None, e=None): while self.sul.cache.get_s_e_sampling_frequency(s, e) < self.n_samples: self.sul.query(s[0] + e) - def row_to_hashable(self, row_prefix): + def row_to_hashable(self, row_prefix: tuple[tuple, tuple]) -> tuple: """ - Creates the hashable representation of the row. Frozenset is used as the order of element in each cell does not - matter - - Args: - - row_prefix: prefix of the row in the observation table - - Returns: - - hashable representation of the row + Creates the hashable representation of the row. Frozenset is used as the order of element in each cell does + not matter. + :param tuple[tuple, tuple] row_prefix: Prefix of the row in the observation table. + :return tuple: Hashable representation of the row. """ row_repr = tuple() @@ -124,11 +125,10 @@ def row_to_hashable(self, row_prefix): return row_repr - def clean_obs_table(self): + def clean_obs_table(self) -> None: """ Moves duplicates from S to S_dot_A. The entries in S_dot_A which are based on the moved row get deleted. The table will be smaller and more efficient. - """ tmp_S = self.S.copy() @@ -151,14 +151,13 @@ def clean_obs_table(self): else: hashed_rows_from_s.add(hashed_s_row) - def gen_hypothesis(self, stochastic=False): + def gen_hypothesis(self, stochastic: bool = False) -> Onfsm | StochasticMealyMachine: """ Generate automaton based on the values found in the observation table. If stochastic is set to True, returns a Stochastic Mealy Machine. - Returns: - - Current hypothesis + :param bool stochastic: If True, a Stochastic Mealy Machine is generated instead of an ONFSM. + :return Onfsm | StochasticMealyMachine: Current hypothesis. """ state_distinguish = dict() diff --git a/aalpy/learning_algs/non_deterministic/TraceTree.py b/aalpy/learning_algs/non_deterministic/TraceTree.py index 5974a7d6226..5c3369e60e5 100644 --- a/aalpy/learning_algs/non_deterministic/TraceTree.py +++ b/aalpy/learning_algs/non_deterministic/TraceTree.py @@ -1,29 +1,46 @@ +# Tree structure used by ONFSM learning algorithms to keep track of all observed input/output traces. from collections import defaultdict +from typing import Any + +from aalpy.automata import Onfsm class Node: + """ + Single node of a :class:`TraceTree`, representing one observed input/output pair. + """ + __slots__ = ['output', 'children', 'parent', 'frequency_counter'] - def __init__(self, output): + def __init__(self, output: Any) -> None: + """ + Creates a trace tree node. + + :param Any output: Output associated with this node. + """ self.output = output - self.children = defaultdict(list) - self.parent = None + self.children: dict[Any, list['Node']] = defaultdict(list) + self.parent: Node | None = None # frq counter self.frequency_counter = 0 - def get_child(self, inp, out): + def get_child(self, inp: Any, out: Any) -> 'Node | None': """ - Args: - inp: - out: - - Returns: + Looks up the child reached via the given input/output pair. + :param Any inp: Input. + :param Any out: Output. + :return Node | None: Matching child node, or None if not found. """ return next((child for child in self.children[inp] if child.output == out), None) - def get_prefix(self): + def get_prefix(self) -> tuple: + """ + Reconstructs the sequence of outputs leading from the root to this node. + + :return tuple: Sequence of outputs on the path from the root to this node. + """ prefix = () curr_node = self while curr_node.parent is not None: @@ -37,22 +54,25 @@ class TraceTree: Tree used for keeping track of seen observations. """ - def __init__(self): + def __init__(self) -> None: + """ + Creates an empty trace tree containing only the root node. + """ self.root_node = Node(None) - self.curr_node = None + self.curr_node: Node | None = None - def reset(self): + def reset(self) -> None: + """ + Resets the current node cursor back to the root node. + """ self.curr_node = self.root_node - def add_to_tree(self, inp, out): + def add_to_tree(self, inp: Any, out: Any) -> None: """ - Adds new element to tree and makes it the current node - - Args: - - inp: Input - out: Output + Adds new element to tree and makes it the current node. + :param Any inp: Input. + :param Any out: Output. """ if inp not in self.curr_node.children.keys() or \ out not in {child.output for child in self.curr_node.children[inp]}: @@ -63,22 +83,25 @@ def add_to_tree(self, inp, out): self.curr_node = self.curr_node.get_child(inp, out) self.curr_node.frequency_counter += 1 - def add_trace(self, inputs, outputs): + def add_trace(self, inputs: tuple, outputs: tuple) -> None: + """ + Adds a whole input/output trace to the tree, starting from the root. + + :param tuple inputs: Sequence of inputs. + :param tuple outputs: Sequence of outputs. + """ self.reset() for i, o in zip(inputs, outputs): self.add_to_tree(i, o) - def get_to_node(self, inputs, outputs): + def get_to_node(self, inputs: tuple, outputs: tuple) -> Node | None: """ - Follows the path described by inp and out and returns the node which is reached - - Args: - inputs: Inputs - outputs: Outputs + Follows the path described by inputs and outputs and returns the node which is reached. - Returns: - - Node that is reached when following the given input and output through the tree + :param tuple inputs: Sequence of inputs. + :param tuple outputs: Sequence of outputs. + :return Node | None: Node that is reached when following the given input and output through the tree, + or None if the path does not exist. """ curr_node = self.root_node for i, o in zip(inputs, outputs): @@ -89,17 +112,14 @@ def get_to_node(self, inputs, outputs): return curr_node - def get_all_traces(self, prefix, e=None): + def get_all_traces(self, prefix: tuple[tuple, tuple], e: tuple) -> list[tuple]: """ + Follows `prefix` (an (inputs, outputs) pair) through the tree, and for the reached node returns all + traces of outputs corresponding to the input sequence `e`. - Args: - - prefix: prefix - e: List of inputs - - Returns: - - Traces of outputs corresponding to the input-sequence given by e + :param tuple[tuple, tuple] prefix: (inputs, outputs) pair identifying the starting node. + :param tuple e: Sequence of inputs to be traced from the starting node. + :return list[tuple]: Traces of outputs corresponding to the input sequence given by e. """ if not prefix or not e: @@ -125,16 +145,13 @@ def get_all_traces(self, prefix, e=None): cell = [node.get_prefix()[-len(e):] for node in reached_nodes] return cell - def get_table(self, s, e): + def get_table(self, s: list, e: list) -> dict: """ - Generates a table from the tree + Generates a table from the tree. - Args: - s: rows from S, S_dot_A, or both which should be presented in the table. - e: E - - Returns: - a table in a format that can be used for printing. + :param list s: Rows from S, S_dot_A, or both which should be presented in the table. + :param list e: E set (suffixes). + :return dict: A table in a format that can be used for printing. """ result = {} for prefix in s: @@ -145,7 +162,13 @@ def get_table(self, s, e): return result - def find_cex_in_cache(self, hypothesis): + def find_cex_in_cache(self, hypothesis: Onfsm) -> tuple[list, list] | None: + """ + Searches the cached traces for a counterexample against the given hypothesis, without querying the SUL. + + :param Onfsm hypothesis: Current hypothesis. + :return tuple[list, list] | None: (inputs, outputs) counterexample, or None if none is found in the cache. + """ queue = [(self.root_node, tuple())] while queue: @@ -168,7 +191,14 @@ def find_cex_in_cache(self, hypothesis): return None - def get_s_e_sampling_frequency(self, prefix, suffix): + def get_s_e_sampling_frequency(self, prefix: tuple[tuple, tuple], suffix: tuple) -> int: + """ + Counts how many times the path described by `prefix` followed by `suffix` has been observed. + + :param tuple[tuple, tuple] prefix: (inputs, outputs) pair identifying the starting node. + :param tuple suffix: Sequence of inputs to be traced from the starting node. + :return int: Number of times the given path has been sampled. + """ sampling_frequency = 0 curr_node = self.root_node for i, o in zip(prefix[0], prefix[1]): @@ -189,7 +219,14 @@ def get_s_e_sampling_frequency(self, prefix, suffix): return sampling_frequency - def get_sampling_distributions(self, prefix, input_from_alphabet): + def get_sampling_distributions(self, prefix: tuple[tuple, tuple], input_from_alphabet: Any) -> dict: + """ + Computes the empirical output probability distribution observed after `prefix` on a given input. + + :param tuple[tuple, tuple] prefix: (inputs, outputs) pair identifying the starting node. + :param Any input_from_alphabet: Single input from the alphabet. + :return dict: Map from observed output to its empirical probability. + """ sampling_distribution = {} curr_node = self.root_node for i, o in zip(prefix[0], prefix[1]): diff --git a/aalpy/learning_algs/non_deterministic/__init__.py b/aalpy/learning_algs/non_deterministic/__init__.py index e69de29bb2d..b7f4ee8d3e2 100644 --- a/aalpy/learning_algs/non_deterministic/__init__.py +++ b/aalpy/learning_algs/non_deterministic/__init__.py @@ -0,0 +1 @@ +# Non-deterministic (ONFSM) learning algorithms package. diff --git a/aalpy/learning_algs/resetless/__init__.py b/aalpy/learning_algs/resetless/__init__.py index e69de29bb2d..aa55f0427a8 100644 --- a/aalpy/learning_algs/resetless/__init__.py +++ b/aalpy/learning_algs/resetless/__init__.py @@ -0,0 +1 @@ +# Package for the resetless hW active automata learning algorithm. diff --git a/aalpy/learning_algs/resetless/hW.py b/aalpy/learning_algs/resetless/hW.py index b85dfa2a944..0f0e6042ed9 100644 --- a/aalpy/learning_algs/resetless/hW.py +++ b/aalpy/learning_algs/resetless/hW.py @@ -1,10 +1,13 @@ +# Resetless hW active automata learning algorithm (Groz et al.), plus its top-level entry point run_hW. import time from collections import deque +from typing import Any from aalpy.automata import MealyState, MealyMachine, MooreState, MooreMachine +from aalpy.base import Automaton, SUL from aalpy.utils.HelperFunctions import all_suffixes, print_learning_info from .hW_datastructures import ModelState, HomingSequenceIndex -from .resetless_oracles import find_counterexample_in_trace +from .resetless_oracles import hWOracle, find_counterexample_in_trace class hW: @@ -24,12 +27,23 @@ class hW: all pairs that diverged in the same check """ - def __init__(self, input_al, sul, - automaton_type, - eq_oracle, - query_for_initial_state=False, - H=None, - W=None): + def __init__(self, input_al: list, sul: SUL, + automaton_type: str, + eq_oracle: hWOracle, + query_for_initial_state: bool = False, + H: tuple | None = None, + W: list | None = None) -> None: + """ + Creates the hW learner and performs the initial SUL reset/W setup. + + :param list input_al: input alphabet. + :param SUL sul: system under learning. + :param str automaton_type: type of automaton to be learned, either 'mealy' or 'moore'. + :param hWOracle eq_oracle: resetless equivalence oracle used during the learning rounds. + :param bool query_for_initial_state: if True, query the SUL to identify the true initial state. + :param tuple | None H: optional user-provided homing sequence. + :param list | None W: optional user-provided characterization set. + """ assert automaton_type in ('mealy', 'moore') self.is_moore = automaton_type == 'moore' @@ -71,16 +85,26 @@ def __init__(self, input_al, sul, self.add_h_to_W() @staticmethod - def _unwrap_output(output): - """Unpack 1-element output tuples returned by some SULs.""" + def _unwrap_output(output: Any) -> Any: + """ + Unpack 1-element output tuples returned by some SULs. + + :param Any output: output as returned by the SUL. + :return Any: unwrapped output. + """ if isinstance(output, tuple) and len(output) == 1: return output[0] return output - def add_to_W(self, sequence, preserve_h=True): + def add_to_W(self, sequence: tuple, preserve_h: bool = True) -> bool: """ Add a sequence to W and drop its proper prefixes (h and the Moore empty - suffix are always kept). Returns True if W changed. + suffix are always kept). + + :param tuple sequence: sequence to add to W. + :param bool preserve_h: if True, the current homing sequence is kept in W even if it is a proper prefix + of sequence. + :return bool: True if W changed. """ sequence = tuple(sequence) if sequence in self.W: @@ -96,8 +120,12 @@ def add_to_W(self, sequence, preserve_h=True): ] return True - def add_h_to_W(self): - """Ensure h (or an extension of it) is part of W.""" + def add_h_to_W(self) -> bool: + """ + Ensure h (or an extension of it) is part of W. + + :return bool: True if W changed. + """ h = self.homing_sequence if h in self.W: return False @@ -105,15 +133,24 @@ def add_h_to_W(self): return False return self.add_to_W(h, preserve_h=False) - def step_wrapper(self, letter): - """Single SUL step that is recorded in the global trace.""" + def step_wrapper(self, letter: Any) -> Any: + """ + Single SUL step that is recorded in the global trace. + + :param Any letter: input executed on the SUL. + :return Any: output observed after executing the input. + """ output = self._unwrap_output(self.sul.step(letter)) self.global_trace.append((letter, output)) self.sul.num_steps += 1 return output - def execute_homing_sequence(self): - """Execute h, run the non-determinism check, and return the observed response.""" + def execute_homing_sequence(self) -> tuple: + """ + Execute h, run the non-determinism check, and return the observed response. + + :return tuple: response observed for the homing sequence. + """ response = tuple(self.step_wrapper(i) for i in self.homing_sequence) # a deliberate homing execution must always enter the h-ND index, even if it # overlaps an incidental occurrence registered just before it @@ -121,35 +158,44 @@ def execute_homing_sequence(self): self.check_h_ND_consistency(forced_cont_start=forced) return response - def execute_sequence(self, seq_under_test: tuple): + def execute_sequence(self, seq_under_test: tuple) -> tuple: """ Execute a sequence and return its outputs. The empty sequence (Moore only) reads the current state output without moving the SUL. + + :param tuple seq_under_test: sequence to execute. + :return tuple: outputs observed for the sequence. """ if not seq_under_test and self.is_moore: return (self._unwrap_output(self.sul.step(None)),) return tuple(self.step_wrapper(i) for i in seq_under_test) - def _add_shortest_new_suffix_to_W(self, sequence): + def _add_shortest_new_suffix_to_W(self, sequence: tuple) -> bool: """ Extend W with the shortest suffix of sequence not yet in W (the - counterexample processing rule). Returns True if W changed. + counterexample processing rule). + + :param tuple sequence: sequence whose suffixes are candidates for extending W. + :return bool: True if W changed. """ for s in sorted((tuple(s) for s in all_suffixes(sequence)), key=len): if self.add_to_W(s): return True return False - def execute_conjecture_path(self, start_state, path): + def execute_conjecture_path(self, start_state: ModelState, path: tuple) -> tuple[ModelState, tuple] | None: """ Walk path on the SUL while verifying outputs against the conjecture. On a mismatch, extend W with the failing prefix (or drop the stale transition data if the prefix is already in W) and return None. - Returns (reached state, observed outputs) on success. Adding the full prefix (instead of a suffix as for counterexamples) is deliberate: it benchmarks measurably better here, as the prefix starts at the very state pair that the conjecture confused. + + :param ModelState start_state: conjecture state the path starts from. + :param tuple path: input sequence to walk. + :return tuple[ModelState, tuple] | None: (reached state, observed outputs) on success, None on a mismatch. """ state = start_state observed_outputs = [] @@ -172,8 +218,12 @@ def execute_conjecture_path(self, start_state, path): state = state.transitions[i] return state, tuple(observed_outputs) - def create_daisy_hypothesis(self): - """Single-state hypothesis with self-loops; its first counterexample seeds h.""" + def create_daisy_hypothesis(self) -> Automaton: + """ + Single-state hypothesis with self-loops; its first counterexample seeds h. + + :return Automaton: single-state Mealy or Moore machine. + """ if self.is_moore: state = MooreState('s0', output=self._unwrap_output(self.sul.step(None))) else: @@ -189,10 +239,12 @@ def create_daisy_hypothesis(self): mm.current_state = state return mm - def _reset_state_data(self, reset_h_index=False): + def _reset_state_data(self, reset_h_index: bool = False) -> None: """ Discard the identified states. The h-ND index depends only on h, not on W, so W-driven resets keep it (preserving comparison progress and minable data). + + :param bool reset_h_index: if True, also reset the incremental h-ND index. """ self.state_map.clear() self.h_response_map.clear() @@ -200,8 +252,12 @@ def _reset_state_data(self, reset_h_index=False): if reset_h_index: self.h_index.reset(len(self.global_trace)) - def _all_states(self): - """All known states, complete and partial, without duplicates.""" + def _all_states(self) -> list: + """ + All known states, complete and partial, without duplicates. + + :return list: list of ModelState instances. + """ seen = set() states = [] for state in list(self.state_map.values()) + list(self.h_response_map.values()): @@ -210,11 +266,15 @@ def _all_states(self): states.append(state) return states - def check_h_ND_consistency(self, forced_cont_start=None): + def check_h_ND_consistency(self, forced_cont_start: int | None = None) -> bool: """ Detect non-determinism of h: two same-response h occurrences whose continuations agree on inputs but differ in outputs. On detection h is extended with the diverging input sequence and all state data is reset. + + :param int | None forced_cont_start: continuation start that must be registered in the h-ND index + even if it would otherwise be skipped as self-overlapping. + :return bool: True if h is still consistent, False if it was extended. """ extension = self.h_index.scan(self.global_trace, self.homing_sequence, forced_cont_start) if extension is None: @@ -226,8 +286,12 @@ def check_h_ND_consistency(self, forced_cont_start=None): self.add_h_to_W() return False - def is_complete(self): - """True if every identified state has all transitions leading to identified states.""" + def is_complete(self) -> bool: + """ + True if every identified state has all transitions leading to identified states. + + :return bool: True if the conjecture is complete. + """ if not self.state_map: return False return all( @@ -236,8 +300,13 @@ def is_complete(self): for i in self.input_alphabet ) - def _first_missing_transition_query(self, state): - """First (input, w) pair whose response is not yet known for this state.""" + def _first_missing_transition_query(self, state: ModelState) -> tuple | None: + """ + First (input, w) pair whose response is not yet known for this state. + + :param ModelState state: state to check. + :return tuple | None: (input, w) pair, or None if all responses are known. + """ for i in self.input_alphabet: learned = state.learned_w_per_input[i] if len(learned) != len(self.W): @@ -246,8 +315,13 @@ def _first_missing_transition_query(self, state): return i, w return None - def _reachable_state_paths(self, start_state): - """(state, shortest input path) pairs for all states reachable in the conjecture.""" + def _reachable_state_paths(self, start_state: ModelState) -> list: + """ + (state, shortest input path) pairs for all states reachable in the conjecture. + + :param ModelState start_state: state to start the search from. + :return list: list of (ModelState, tuple) pairs. + """ queue = deque([(start_state, ())]) visited = set() reachable = [] @@ -264,10 +338,12 @@ def _reachable_state_paths(self, start_state): return reachable - def find_reachable_incomplete_transition(self, start_state): + def find_reachable_incomplete_transition(self, start_state: ModelState) -> tuple | None: """ - Closest reachable state with an unknown (input, w) response, as - (path, state, input, w), or None if everything reachable is complete. + Closest reachable state with an unknown (input, w) response. + + :param ModelState start_state: state to start the search from. + :return tuple | None: (path, state, input, w), or None if everything reachable is complete. """ queue = deque([(start_state, ())]) visited = set() @@ -287,9 +363,15 @@ def find_reachable_incomplete_transition(self, start_state): return None - def _simulate_from_state(self, state, sequence): - """Outputs and end state of running sequence through the conjecture, or - (None, ...) if the required data is not known yet.""" + def _simulate_from_state(self, state: ModelState, sequence: tuple) -> tuple: + """ + Outputs and end state of running sequence through the conjecture, or + (None, ...) if the required data is not known yet. + + :param ModelState state: state to start simulating from. + :param tuple sequence: sequence to simulate. + :return tuple: (outputs, end_state), or (None, ...) if not known. + """ if not sequence and self.is_moore: state_out = state.state_w_values.get(()) if state_out is None: @@ -303,19 +385,26 @@ def _simulate_from_state(self, state, sequence): state = state.transitions[i] return tuple(outputs), state - def _empty_suffix_response_after_h(self, h_response): + def _empty_suffix_response_after_h(self, h_response: tuple) -> tuple | None: """ (Moore) The response to the empty suffix is the last output of h, so it comes for free with every homing. + + :param tuple h_response: response observed for the homing sequence. + :return tuple | None: response to the empty suffix, or None if it cannot be derived for free. """ if not self.is_moore or self.homing_sequence == (): return None return (h_response[-1],) - def _mine_h_w_response(self, hs_response, w): + def _mine_h_w_response(self, hs_response: tuple, w: tuple) -> tuple | None: """ Recover the response to w after an h occurrence with hs_response from already-observed trace data, avoiding a fresh query. + + :param tuple hs_response: response observed for the homing sequence occurrence. + :param tuple w: suffix whose response is being recovered. + :return tuple | None: recovered response, or None if it could not be mined from the trace. """ if not w: return None @@ -331,11 +420,17 @@ def _mine_h_w_response(self, hs_response, w): return tuple(trace[cont + k][1] for k in range(w_len)) return None - def _apply_conjecture_probe(self, current_state, path, w, kind): + def _apply_conjecture_probe(self, current_state: ModelState, path: tuple, w: tuple, kind: str) -> bool: """ Execute path + h + w on the SUL to expose a suspected inconsistency. `kind` distinguishes the check that requested the probe, so each probe runs - at most once. Returns True if anything was executed. + at most once. + + :param ModelState current_state: conjecture state the probe starts from. + :param tuple path: prefix path executed before homing. + :param tuple w: suffix executed after homing. + :param str kind: identifier of the check that requested the probe. + :return bool: True if anything was executed. """ key = (kind, current_state.hs, path, w, self.homing_sequence) if key in self._conjecture_probe_seen: @@ -354,12 +449,15 @@ def _apply_conjecture_probe(self, current_state, path, w, kind): self.check_h_ND_consistency() return True - def check_conjecture_inconsistencies(self, current_state): + def check_conjecture_inconsistencies(self, current_state: ModelState) -> bool: """ Look for internal inconsistencies of the conjecture: a state whose predicted responses after h disagree with the state mapped to that h-response, or two states that h cannot separate but W can. A probe is - executed to expose the first inconsistency found; returns True if so. + executed to expose the first inconsistency found. + + :param ModelState current_state: state the SUL is currently located at. + :return bool: True if a probe was executed to expose an inconsistency. """ states_after_h = [] for state, path in self._reachable_state_paths(current_state): @@ -397,13 +495,22 @@ def check_conjecture_inconsistencies(self, current_state): return False - def _w_profile(self, w_values): + def _w_profile(self, w_values: dict) -> tuple: + """ + Canonical hashable profile of a state's W responses. + + :param dict w_values: mapping of W suffixes to observed responses. + :return tuple: sorted (suffix, response) pairs. + """ return tuple(sorted(w_values.items())) - def _state_for_w_values(self, w_values): + def _state_for_w_values(self, w_values: dict) -> ModelState: """ State matching these W responses; checked against identified states first, then against partially identified ones. Created and registered if not found. + + :param dict w_values: mapping of W suffixes to observed responses. + :return ModelState: matching (or newly created) state. """ profile = self._w_profile(w_values) matched = self.state_map.get(profile) @@ -420,10 +527,13 @@ def _state_for_w_values(self, w_values): self.state_map[profile] = state return state - def _merge_states(self, canonical, duplicate): + def _merge_states(self, canonical: ModelState, duplicate: ModelState) -> None: """ Fold everything learned about duplicate into canonical and redirect all references to it. + + :param ModelState canonical: state that survives the merge. + :param ModelState duplicate: state whose data is merged into canonical and discarded. """ for (i, w), out in duplicate.transition_w_values.items(): canonical.transition_w_values.setdefault((i, w), out) @@ -443,10 +553,14 @@ def _merge_states(self, canonical, duplicate): if state is duplicate: self.h_response_map[h_response] = canonical - def _complete_h_response_state(self, h_response, state): + def _complete_h_response_state(self, h_response: tuple, state: ModelState) -> ModelState: """ Re-key a state identified by its h-response to its full W-profile, merging it with an existing state if the profile is already known. + + :param tuple h_response: h-response the state was previously keyed by. + :param ModelState state: state being completed. + :return ModelState: the resulting (possibly merged) state. """ profile = self._w_profile(state.state_w_values) existing = self.state_map.get(profile) @@ -462,9 +576,12 @@ def _complete_h_response_state(self, h_response, state): self.h_response_map[h_response] = existing return existing - def update_model_transition(self, state, i): + def update_model_transition(self, state: ModelState, i: Any) -> None: """ Set state's i-transition once the responses to every w in W are known. + + :param ModelState state: state whose transition is updated. + :param Any i: input whose transition is updated. """ learned = state.learned_w_per_input[i] if len(learned) != len(self.W): @@ -472,10 +589,14 @@ def update_model_transition(self, state, i): w_for_input = {w: state.transition_w_values[(i, w)] for w in learned} state.transitions[i] = self._state_for_w_values(w_for_input) - def _partition_signature(self, state, block_of): + def _partition_signature(self, state: ModelState, block_of: dict) -> tuple: """ Refinement signature: state output (Moore) or transition outputs (Mealy), plus the current block of each successor. + + :param ModelState state: state to compute the signature for. + :param dict block_of: mapping of state hs to current block index. + :return tuple: refinement signature. """ signature = [state.state_w_values.get(())] if self.is_moore else [] for i in self.input_alphabet: @@ -487,10 +608,11 @@ def _partition_signature(self, state, block_of): signature.append((i, state.output_fun.get(i), block)) return tuple(signature) - def _state_partitions(self): + def _state_partitions(self) -> dict: """ Group equivalent identified states into blocks via partition refinement. - Returns hs -> block index. + + :return dict: mapping of state hs to block index. """ states = [s for s in self.state_map.values() if len(s.state_w_values) == len(self.W)] @@ -505,10 +627,13 @@ def _state_partitions(self): return block_of block_of = next_block_of - def create_model(self, current_hs): + def create_model(self, current_hs: tuple) -> Automaton: """ Build a Moore/Mealy machine from the state blocks, starting (and keeping only states reachable) from the state identified by current_hs. + + :param tuple current_hs: hs of the state the resulting model should start from. + :return Automaton: constructed Mealy or Moore machine. """ block_of = self._state_partitions() @@ -571,11 +696,16 @@ def create_model(self, current_hs): mm.current_state = start_state return mm - def _track_through_conjecture(self, target, x, w, w_response): + def _track_through_conjecture(self, target: Any, x: Any, w: tuple, w_response: tuple) -> Any | None: """ - Follow the conjecture from target through x and w. Returns the state the - SUL is in after the probe, or None if any transition along the way is unknown - or a recorded output disagrees with the observed w_response. + Follow the conjecture from target through x and w. + + :param Any target: conjecture state the probe started from. + :param Any x: input executed right after target. + :param tuple w: suffix executed after x. + :param tuple w_response: outputs observed for w. + :return Any | None: the state the SUL is in after the probe, or None if any transition along the way is + unknown or a recorded output disagrees with the observed w_response. """ state = target.transitions.get(x) if state is None: @@ -587,11 +717,14 @@ def _track_through_conjecture(self, target, x, w, w_response): state = next_state return state - def create_hypothesis(self): + def create_hypothesis(self) -> Automaton: """ Main learning loop: localize via h, identify the current state's W responses, then learn outgoing transitions of reachable states until the - conjecture is complete and consistent.""" + conjecture is complete and consistent. + + :return Automaton: the constructed hypothesis. + """ # When the SUL's current state is known (nothing was executed since the last # localization, or the conjecture fully predicts the probe just executed), @@ -697,10 +830,15 @@ def create_hypothesis(self): return self.create_model(current_state.hs) - def main_loop(self, print_level=2): - """Outer loop: bootstrap h, alternate hypothesis construction with equivalence + def main_loop(self, print_level: int = 2) -> tuple[Automaton, dict]: + """ + Outer loop: bootstrap h, alternate hypothesis construction with equivalence checks from the configured oracle, refine W from counterexamples, and assemble - the result.""" + the result. + + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug. + :return tuple[Automaton, dict]: the learned hypothesis and a dict with learning statistics. + """ start_time = time.time() eq_query_time = 0 @@ -710,7 +848,7 @@ def main_loop(self, print_level=2): initial_model = self.create_daisy_hypothesis() eq_start = time.time() - counter_example = self.oracle.find_counterexample(initial_model) + counter_example = self.oracle.find_cex(initial_model) eq_query_time += time.time() - eq_start last_cex_input = (counter_example[-1],) @@ -729,7 +867,7 @@ def main_loop(self, print_level=2): print(f'Hypothesis {learning_rounds}: {hypothesis.size} states.') eq_start = time.time() - counter_example = self.oracle.find_counterexample(hypothesis) + counter_example = self.oracle.find_cex(hypothesis) if counter_example is None: # backstop at zero SUL cost: the observed trace may refute a # hypothesis that the oracle failed to disprove @@ -804,12 +942,12 @@ def main_loop(self, print_level=2): return hypothesis, info -def run_hW(alphabet: list, sul, eq_oracle, automaton_type, - query_for_initial_state=True, - provided_homing_sequence=None, - provided_characterization_set=None, - return_data=False, - print_level=2): +def run_hW(alphabet: list, sul: SUL, eq_oracle: hWOracle, automaton_type: str, + query_for_initial_state: bool = True, + provided_homing_sequence: tuple | None = None, + provided_characterization_set: list | None = None, + return_data: bool = False, + print_level: int = 2) -> Automaton | tuple[Automaton, dict]: """ Executes the hW resetless learning algorithm. Algorithm description can be found in "hW-inference: A heuristic approach to retrieve models through @@ -818,39 +956,29 @@ def run_hW(alphabet: list, sul, eq_oracle, automaton_type, The implementation does not strictly follow all aspects of the described algorithm, but relies on in for the most part. - Args: - - alphabet: input alphabet - - sul: system under learning - - eq_oracle: resetless equivalence oracle (an hWOracle instance) used during the learning rounds, e.g. - RandomhWOracle(num_testing_steps, reset_testing_counter) or - RandomWphWOracle(random_walk_length, num_test_origin_states). All testing-budget configuration lives - on the oracle itself. - - automaton_type: type of automaton to be learned. Either 'mealy', 'moore', or 'dfa'. For 'moore' and 'dfa' - the algorithm treats outputs as state properties (Moore semantics). 'dfa' additionally casts the final - Moore machine to a Dfa, treating True/False outputs as accepting/rejecting states. - - query_for_initial_state: if True, query the SUL to identify the true initial state (Default value = True) - - provided_homing_sequence: optional user-provided homing sequence. If supplied, hW starts with this sequence instead of deriving an - initial one from the daisy hypothesis counterexample. (Default value = None) - - provided_characterization_set: optional user-provided characterization set. If supplied, hW starts with these suffixes instead of an empty - characterization set. For Moore machines and DFAs, the empty suffix is added automatically. - (Default value = None) - - return_data: if True, return a (hypothesis, info) tuple instead of just the hypothesis - (Default value = False) - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - Returns: - - learned automaton of type automaton_type (or (automaton, info dict) if return_data is True) + :param list alphabet: input alphabet + :param SUL sul: system under learning + :param hWOracle eq_oracle: resetless equivalence oracle (an hWOracle instance) used during the learning rounds, + e.g. RandomhWOracle(num_testing_steps, reset_testing_counter) or + RandomWphWOracle(random_walk_length, num_test_origin_states). All testing-budget configuration lives + on the oracle itself. + :param str automaton_type: type of automaton to be learned. Either 'mealy', 'moore', or 'dfa'. For 'moore' and + 'dfa' the algorithm treats outputs as state properties (Moore semantics). 'dfa' additionally casts the + final Moore machine to a Dfa, treating True/False outputs as accepting/rejecting states. + :param bool query_for_initial_state: if True, query the SUL to identify the true initial state + (Default value = True) + :param tuple | None provided_homing_sequence: optional user-provided homing sequence. If supplied, hW starts + with this sequence instead of deriving an initial one from the daisy hypothesis counterexample. + (Default value = None) + :param list | None provided_characterization_set: optional user-provided characterization set. If supplied, hW + starts with these suffixes instead of an empty characterization set. For Moore machines and DFAs, the + empty suffix is added automatically. (Default value = None) + :param bool return_data: if True, return a (hypothesis, info) tuple instead of just the hypothesis + (Default value = False) + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug + (Default value = 2) + :return Automaton | tuple[Automaton, dict]: learned automaton of type automaton_type (or (automaton, info dict) + if return_data is True) """ assert print_level in [0, 1, 2, 3] assert automaton_type in ('mealy', 'moore', 'dfa') diff --git a/aalpy/learning_algs/resetless/hW_datastructures.py b/aalpy/learning_algs/resetless/hW_datastructures.py index cdfe8435d59..db7a1893f57 100644 --- a/aalpy/learning_algs/resetless/hW_datastructures.py +++ b/aalpy/learning_algs/resetless/hW_datastructures.py @@ -1,4 +1,7 @@ +# Data structures backing the hW learner: the conjecture's states and the incremental +# homing-sequence non-determinism index over the global trace. from collections import defaultdict +from typing import Any class ModelState: @@ -6,7 +9,12 @@ class ModelState: A state of the conjecture under construction. """ - def __init__(self, hs): + def __init__(self, hs: Any) -> None: + """ + Creates a conjecture state identified by its h-response or W-profile. + + :param Any hs: h-response (partially identified state) or W-profile (fully identified state). + """ self.hs = hs self.state_w_values = {} @@ -31,18 +39,23 @@ class HomingSequenceIndex: non-determinism could go undetected. """ - def __init__(self): + def __init__(self) -> None: + """ + Creates an empty index over an (initially empty) global trace. + """ self._hs_cont_starts = defaultdict(list) # h-response -> [continuation start positions] self._hs_cont_set = set() # all registered continuation starts (O(1) membership) self._pair_progress = {} # (p1, p2) -> compared continuation length so far self._scan_pos = 0 # how far the trace has been scanned self._next_occ_min_start = 0 # earliest start of the next registered h occurrence - def reset(self, trace_len): + def reset(self, trace_len: int) -> None: """ Clear the index. The old trace was produced under the previous h and can contain many incidental occurrences of the extended h; rescanning it makes h grow and forces avoidable relearning. + + :param int trace_len: length of the global trace at the point of the reset. """ self._hs_cont_starts.clear() self._hs_cont_set.clear() @@ -50,16 +63,27 @@ def reset(self, trace_len): self._scan_pos = trace_len self._next_occ_min_start = trace_len - def continuation_starts(self, hs_response): - """Continuation start positions recorded for the given h-response.""" + def continuation_starts(self, hs_response: tuple) -> tuple: + """ + Continuation start positions recorded for the given h-response. + + :param tuple hs_response: observed response to a homing sequence occurrence. + :return tuple: continuation start positions for that response. + """ return self._hs_cont_starts.get(hs_response, ()) - def scan(self, trace, h, forced_cont_start=None): + def scan(self, trace: list, h: tuple, forced_cont_start: int | None = None) -> tuple | None: """ Register h occurrences in the newly added part of the trace, advance every active continuation pair, and report non-determinism of h. Returns the diverging input sequence to extend h with (the shortest witness among all pairs that diverged in this call), or None if h is still consistent. + + :param list trace: global trace of (input, output) pairs observed so far. + :param tuple h: current homing sequence. + :param int | None forced_cont_start: continuation start that must be registered even if it + would otherwise be skipped as a self-overlapping occurrence. + :return tuple | None: diverging input sequence to extend h with, or None if h is consistent. """ h_len = len(h) if h_len == 0: diff --git a/aalpy/learning_algs/resetless/resetless_oracles.py b/aalpy/learning_algs/resetless/resetless_oracles.py index 545bf773707..38ea38c47cf 100644 --- a/aalpy/learning_algs/resetless/resetless_oracles.py +++ b/aalpy/learning_algs/resetless/resetless_oracles.py @@ -1,5 +1,8 @@ +# Resetless equivalence oracles used with the hW learner, plus a free counterexample-in-trace backstop. from random import choice +from aalpy.base.Automaton import Automaton, InputType + class hWOracle: """ @@ -15,22 +18,33 @@ class hWOracle: so a single oracle instance must not be shared between concurrent runs. """ - def __init__(self): + def __init__(self) -> None: + """ + Creates an oracle with no bound learner and a zeroed step counter. + """ self.learner = None self.num_steps = 0 # total SUL steps executed by this oracle across all checks - def find_counterexample(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple[InputType, ...] | None: """ Return the executed inputs up to and including the first output mismatch with the hypothesis, or None if no mismatch was observed. + + :param Automaton hypothesis: current hypothesis. + :return tuple[InputType, ...] | None: counterexample inputs, or None if no counterexample is found. """ raise NotImplementedError - def _execute_and_compare(self, hypothesis, inputs, cex): + def _execute_and_compare(self, hypothesis: Automaton, inputs: tuple, cex: list) -> bool: """ Run inputs on the SUL and the hypothesis in lock-step, appending each to cex. Returns True on the first output mismatch (cex then ends at the diverging input). + + :param Automaton hypothesis: current hypothesis. + :param tuple inputs: inputs to execute in lock-step on the SUL and the hypothesis. + :param list cex: counterexample accumulator, extended in place. + :return bool: True on the first output mismatch, False otherwise. """ learner = self.learner for i in inputs: @@ -46,19 +60,32 @@ class RandomhWOracle(hWOracle): Random-walk equivalence check: take random inputs until the SUL and the hypothesis disagree or the per-round step budget is exhausted. - Args: - num_testing_steps: number of random steps used per equivalence check - reset_testing_counter: if True, the step budget is reset for every - equivalence check; otherwise num_testing_steps bounds the total number - of testing steps across the whole run + :param int num_testing_steps: number of random steps used per equivalence check + :param bool reset_testing_counter: if True, the step budget is reset for every + equivalence check; otherwise num_testing_steps bounds the total number + of testing steps across the whole run """ - def __init__(self, num_testing_steps=200, reset_testing_counter=True): + def __init__(self, num_testing_steps: int = 200, reset_testing_counter: bool = True) -> None: + """ + Creates a random-walk resetless equivalence oracle. + + :param int num_testing_steps: number of random steps used per equivalence check. + :param bool reset_testing_counter: if True, the step budget is reset for every equivalence check; + otherwise num_testing_steps bounds the total number of testing steps across the whole run. + """ super().__init__() self.num_testing_steps = num_testing_steps self.reset_testing_counter = reset_testing_counter - def find_counterexample(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple[InputType, ...] | None: + """ + Return the executed inputs up to and including the first output mismatch + with the hypothesis, or None if no mismatch was observed within the step budget. + + :param Automaton hypothesis: current hypothesis. + :return tuple[InputType, ...] | None: counterexample inputs, or None if no counterexample is found. + """ learner = self.learner if self.reset_testing_counter: current_test_steps = self.num_testing_steps @@ -69,7 +96,7 @@ def find_counterexample(self, hypothesis): for _ in range(current_test_steps): random_input = choice(learner.input_alphabet) if self._execute_and_compare(hypothesis, (random_input,), cex): - return cex + return tuple(cex) return None @@ -83,12 +110,25 @@ class RandomWphWOracle(hWOracle): The first such probe that diverges from the hypothesis is returned. """ - def __init__(self, random_walk_length=20, num_test_origin_states=10): + def __init__(self, random_walk_length: int = 20, num_test_origin_states: int = 10) -> None: + """ + Creates a resetless Wp-method style equivalence oracle. + + :param int random_walk_length: length of the random walk executed after each probe. + :param int num_test_origin_states: number of random origin states probed per equivalence check. + """ super().__init__() self.random_walk_length = random_walk_length self.num_test_origin_states = num_test_origin_states - def find_counterexample(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple[InputType, ...] | None: + """ + Return the executed inputs up to and including the first output mismatch + with the hypothesis, or None if no mismatch was observed within the testing budget. + + :param Automaton hypothesis: current hypothesis. + :return tuple[InputType, ...] | None: counterexample inputs, or None if no counterexample is found. + """ learner = self.learner for _ in range(self.num_test_origin_states): @@ -100,18 +140,18 @@ def find_counterexample(self, hypothesis): if path is None: path = () if self._execute_and_compare(hypothesis, path, cex): - return cex + return tuple(cex) # distinguish the reached state with a random element of W if learner.W: w = choice(learner.W) if self._execute_and_compare(hypothesis, w, cex): - return cex + return tuple(cex) # explore further with a random walk from the reached state walk = [choice(learner.input_alphabet) for _ in range(self.random_walk_length)] if self._execute_and_compare(hypothesis, walk, cex): - return cex + return tuple(cex) return None diff --git a/aalpy/learning_algs/stochastic/DifferenceChecker.py b/aalpy/learning_algs/stochastic/DifferenceChecker.py index dec3ec33622..28109ba23cd 100644 --- a/aalpy/learning_algs/stochastic/DifferenceChecker.py +++ b/aalpy/learning_algs/stochastic/DifferenceChecker.py @@ -1,3 +1,4 @@ +# Statistical checkers used to decide whether two output-frequency distributions differ. from abc import ABC, abstractmethod from math import sqrt, log @@ -25,24 +26,64 @@ class DifferenceChecker(ABC): + """ + Abstract class implemented by all checkers that decide whether two observed output-frequency + distributions (cells) are statistically different. + """ @abstractmethod def are_cells_different(self, c1: dict, c2: dict, **kwargs) -> bool: + """ + Determine whether two cells (output frequency dictionaries) are different. + + :param dict c1: Output frequencies of the first cell. + :param dict c2: Output frequencies of the second cell. + :param kwargs: Additional checker-specific arguments. + :return bool: True if the cells are considered different, False otherwise. + """ pass - def difference_value(self, c1: dict, c2: dict): + def difference_value(self, c1: dict, c2: dict) -> float | None: + """ + Compute a numeric difference value between two cells, if supported by the checker. + + :param dict c1: Output frequencies of the first cell. + :param dict c2: Output frequencies of the second cell. + :return float | None: Difference value, or None if not supported. + """ return None - def use_diff_value(self): + def use_diff_value(self) -> bool: + """ + Whether this checker supports computing a numeric difference value. + + :return bool: True if difference_value can be used, False otherwise. + """ return False class HoeffdingChecker(DifferenceChecker): + """ + Difference checker based on the Hoeffding bound. + """ + + def __init__(self, alpha: float = 0.05) -> None: + """ + Create a Hoeffding-bound based difference checker. - def __init__(self, alpha=0.05): + :param float alpha: Significance level used in the Hoeffding bound. + """ self.alpha = alpha def are_cells_different(self, c1: dict, c2: dict, **kwargs) -> bool: + """ + Determine whether two cells are different using the Hoeffding bound. + + :param dict c1: Output frequencies of the first cell. + :param dict c2: Output frequencies of the second cell. + :param kwargs: Unused, present for interface compatibility. + :return bool: True if the cells are considered different, False otherwise. + """ if c1.keys() != c2.keys(): return True @@ -57,17 +98,43 @@ def are_cells_different(self, c1: dict, c2: dict, **kwargs) -> bool: return False -def compute_epsilon(alpha1, n1): +def compute_epsilon(alpha1: float, n1: int) -> float: + """ + Compute the Hoeffding-bound epsilon value for a given significance level and sample size. + + :param float alpha1: Significance level. + :param int n1: Sample size. + :return float: Computed epsilon value. + """ epsilon1 = sqrt((1. / (2 * n1)) * log(2. / alpha1)) return epsilon1 class AdvancedHoeffdingChecker(DifferenceChecker): - def __init__(self, alpha=0.05, use_diff=False): + """ + Difference checker based on per-output Hoeffding bounds, optionally exposing a numeric + difference value. + """ + + def __init__(self, alpha: float = 0.05, use_diff: bool = False) -> None: + """ + Create an advanced Hoeffding-bound based difference checker. + + :param float alpha: Significance level used in the Hoeffding bound. + :param bool use_diff: Whether difference_value should be usable. + """ self.alpha = alpha self.use_diff = use_diff def are_cells_different(self, c1: dict, c2: dict, **kwargs) -> bool: + """ + Determine whether two cells are different using per-output Hoeffding bounds. + + :param dict c1: Output frequencies of the first cell. + :param dict c2: Output frequencies of the second cell. + :param kwargs: Unused, present for interface compatibility. + :return bool: True if the cells are considered different, False otherwise. + """ n1 = sum(c1.values()) n2 = sum(c2.values()) @@ -84,10 +151,23 @@ def are_cells_different(self, c1: dict, c2: dict, **kwargs) -> bool: return True return False - def use_diff_value(self): + def use_diff_value(self) -> bool: + """ + Whether this checker supports computing a numeric difference value. + + :return bool: True if use_diff was set on construction, False otherwise. + """ return self.use_diff - def difference_value(self, c1_out_freq: dict, c2_out_freq: dict): + def difference_value(self, c1_out_freq: dict, c2_out_freq: dict) -> float: + """ + Compute a numeric difference value between two cells. + + :param dict c1_out_freq: Output frequencies of the first cell. + :param dict c2_out_freq: Output frequencies of the second cell. + :return float: Sum of absolute output frequency differences, a combined epsilon bound if + only one cell has observations, or 0 if neither has observations. + """ n1 = 0 if not c1_out_freq else sum(c1_out_freq.values()) n2 = 0 if not c2_out_freq else sum(c2_out_freq.values()) @@ -109,8 +189,17 @@ def difference_value(self, c1_out_freq: dict, c2_out_freq: dict): class ChiSquareChecker(DifferenceChecker): + """ + Difference checker based on the chi-square test for homogeneity. + """ + + def __init__(self, alpha: float = 0.001, use_diff_value: bool = False) -> None: + """ + Create a chi-square test based difference checker. - def __init__(self, alpha=0.001, use_diff_value=False): + :param float alpha: Significance level, must have a precomputed chi2 table entry. + :param bool use_diff_value: Whether difference_value should be usable. + """ self.alpha = alpha self.chi2_cache = dict() if 1 - self.alpha not in chi2_table.keys(): @@ -119,7 +208,15 @@ def __init__(self, alpha=0.001, use_diff_value=False): self.use_diff = use_diff_value def are_cells_different(self, c1_out_freq: dict, c2_out_freq: dict, **kwargs) -> bool: - # chi square test for homogeneity (see, for instance: https://online.stat.psu.edu/stat415/lesson/17/17.1) + """ + Determine whether two cells are different using a chi-square test for homogeneity + (see, for instance: https://online.stat.psu.edu/stat415/lesson/17/17.1). + + :param dict c1_out_freq: Output frequencies of the first cell. + :param dict c2_out_freq: Output frequencies of the second cell. + :param kwargs: Unused, present for interface compatibility. + :return bool: True if the cells are considered different, False otherwise. + """ if not c1_out_freq or not c2_out_freq: return False keys = list(set(c1_out_freq.keys()).union(c2_out_freq.keys())) @@ -141,10 +238,23 @@ def are_cells_different(self, c1_out_freq: dict, c2_out_freq: dict, **kwargs) -> return Q >= chi2_val - def use_diff_value(self): + def use_diff_value(self) -> bool: + """ + Whether this checker supports computing a numeric difference value. + + :return bool: True if use_diff_value was set on construction, False otherwise. + """ return self.use_diff - def difference_value(self, c1_out_freq: dict, c2_out_freq: dict): + def difference_value(self, c1_out_freq: dict, c2_out_freq: dict) -> float: + """ + Compute a numeric difference value between two cells based on the chi-square statistic. + + :param dict c1_out_freq: Output frequencies of the first cell. + :param dict c2_out_freq: Output frequencies of the second cell. + :return float: Chi-square statistic Q, a threshold value if one cell has no observations, + or 0 if there is a single degree of freedom. + """ if not c1_out_freq or not c2_out_freq: # return a value on the threshold if we don't have information c1_outs = set(c1_out_freq.keys()) if c1_out_freq else set() @@ -159,7 +269,15 @@ def difference_value(self, c1_out_freq: dict, c2_out_freq: dict): Q = self.compute_Q(c1_out_freq, c2_out_freq, keys) return Q - def compute_Q(self, c1_out_freq, c2_out_freq, keys): + def compute_Q(self, c1_out_freq: dict, c2_out_freq: dict, keys: list) -> float: + """ + Compute the chi-square test statistic Q for two output-frequency distributions. + + :param dict c1_out_freq: Output frequencies of the first cell. + :param dict c2_out_freq: Output frequencies of the second cell. + :param list keys: Union of the output keys present in both cells. + :return float: Chi-square test statistic Q. + """ n_1 = sum(c1_out_freq.values()) n_2 = sum(c2_out_freq.values()) diff --git a/aalpy/learning_algs/stochastic/SamplingBasedObservationTable.py b/aalpy/learning_algs/stochastic/SamplingBasedObservationTable.py index 21e8a43aab1..7e1de0f0853 100644 --- a/aalpy/learning_algs/stochastic/SamplingBasedObservationTable.py +++ b/aalpy/learning_algs/stochastic/SamplingBasedObservationTable.py @@ -1,3 +1,4 @@ +# Observation table used by the sampling-based stochastic L* algorithm to learn MDPs and stochastic Mealy machines. from collections import defaultdict from aalpy.automata import Mdp, MdpState, StochasticMealyState, StochasticMealyMachine @@ -7,18 +8,26 @@ class SamplingBasedObservationTable: - def __init__(self, input_alphabet: list, automaton_type, teacher: StochasticTeacher, - compatibility_checker: DifferenceChecker, - alpha=0.05, strategy='normal', - cex_processing=None): - """Constructor of the observation table. Initial queries are asked in the constructor. - - Args: - - input_alphabet: input alphabet - teacher: stochastic teacher - alpha: constant used in Hoeffding bound + """ + Observation table for the sampling-based stochastic L* algorithm. Rows are prefixes (traces) and columns are + suffixes; cells store observed output frequencies. Because exact row equivalence cannot be established from + finitely many samples, rows are grouped into compatibility classes instead. + """ + def __init__(self, input_alphabet: list, automaton_type: str, teacher: StochasticTeacher, + compatibility_checker: DifferenceChecker, + alpha: float = 0.05, strategy: str = 'normal', + cex_processing: str | None = None) -> None: + """ + Constructor of the observation table. Initial queries are asked in the constructor. + + :param list input_alphabet: Input alphabet. + :param str automaton_type: Either 'mdp' or 'smm'. + :param StochasticTeacher teacher: Stochastic teacher used for frequency queries. + :param DifferenceChecker compatibility_checker: Checker used to decide whether cells/rows differ. + :param float alpha: Constant used in the Hoeffding bound. + :param str strategy: One of 'classic', 'normal' or 'chi2'. + :param str | None cex_processing: Counterexample processing strategy, or None. """ self.compatibility_checker = compatibility_checker assert input_alphabet is not None and teacher is not None @@ -51,19 +60,14 @@ def __init__(self, input_alphabet: list, automaton_type, teacher: StochasticTeac self.unambiguity_values = [] - def refine_not_completed_cells(self, n_resample, uniform=False): + def refine_not_completed_cells(self, n_resample: int, uniform: bool = False) -> bool: """ Firstly a prefix-tree acceptor is constructed for all non-completed cells and then that tree is used for online testing/sampling. - Args: - - uniform: if true, all cells will be uniformly sampled (Default value = False) - n_resample: Number of resamples - - Returns: - - False if no cells are to be refined, True if refining happened + :param int n_resample: Number of resamples. + :param bool uniform: If true, all cells will be uniformly sampled. + :return bool: False if no cells are to be refined, True if refining happened. """ if self.automaton_type == 'mdp': pta_root = Node(self.initial_output[0]) @@ -112,16 +116,12 @@ def refine_not_completed_cells(self, n_resample, uniform=False): self.teacher.tree_query(pta_root) return True - def update_obs_table_with_freq_obs(self, element_of_s=None): + def update_obs_table_with_freq_obs(self, element_of_s: list | None = None) -> None: """ Updates cells in the observation table with frequency data. If the row in S has no extension yet, it is generated and its cells populated. - Args: - element_of_s: if not None, selected row and its extensions will be updated (Default value = None) - - Returns: - + :param list | None element_of_s: If not None, selected row and its extensions will be updated. """ if element_of_s: s_set = element_of_s + list(self.get_extended_s(element_of_s=element_of_s)) @@ -135,14 +135,12 @@ def update_obs_table_with_freq_obs(self, element_of_s=None): self.freq_query_cache[s + e] = self.T[s][e] self._row_compatibility_cache.clear() - def get_extended_s(self, element_of_s=None): - """Generator returning all elements of the extended S set. - - Args: - element_of_s: (Default value = None) - - Returns: + def get_extended_s(self, element_of_s: list | None = None): + """ + Generator returning all elements of the extended S set. + :param list | None element_of_s: Rows to extend, defaults to self.S when None. + :return: Generator of new prefixes (rows) extending element_of_s (or self.S). """ s_set = element_of_s if element_of_s else self.S for s in s_set: @@ -156,7 +154,7 @@ def get_extended_s(self, element_of_s=None): if freq > 0 and new_pref not in self.S: yield new_pref - def make_closed_and_consistent(self): + def make_closed_and_consistent(self) -> None: """ Observation table is updated until it is closed and consistent. Note that due the updated notion of row equivalence no sampling is needed. @@ -185,13 +183,11 @@ def make_closed_and_consistent(self): if closed and consistent: break - def get_row_to_close(self): + def get_row_to_close(self) -> tuple | None: """ Returns a row that is not closed. - Returns: - - row that needs to be closed + :return tuple | None: Row that needs to be closed, or None if the table is closed. """ for lt in self.get_extended_s(): row_is_closed = False @@ -203,16 +199,13 @@ def get_row_to_close(self): return lt return None - def get_consistency_violation(self, ignore=None): - """Find and return cause of consistency violation. Only computed on the compatibility class representatives. - :return: element of input + element of output + element of e that lead to the inconsistency - - Args: - ignore: (Default value = None) - - Returns: + def get_consistency_violation(self, ignore: tuple | None = None) -> tuple | None: + """ + Find and return cause of consistency violation. Only computed on the compatibility class representatives. - i + o + e that violate consistency + :param tuple | None ignore: Element of E to ignore during the computation. + :return tuple | None: Element of input + element of output + element of e that lead to the inconsistency, + or None if consistent. """ if self.cex_processing is not None: return None @@ -234,15 +227,12 @@ def get_consistency_violation(self, ignore=None): return i + o + e return None - def get_representative(self, target): + def get_representative(self, target: tuple) -> tuple | None: """ + Returns the compatibility class representative for the given row. - Args: - target: row in the observation table - - Returns: - a representative compatible with the target - + :param tuple target: Row in the observation table. + :return tuple | None: A representative compatible with the target. """ if self.compatibility_checker.use_diff_value(): smallest_diff_value = 2 ** 32 @@ -277,8 +267,10 @@ def get_representative(self, target): return r assert False - def trim_columns(self): - """ """ + def trim_columns(self) -> None: + """ + Removes redundant columns (suffixes in E) that do not contribute distinguishing information. + """ reverse_sorted_E = list(self.E) reverse_sorted_E.sort(key=len, reverse=True) to_remove = [] @@ -306,13 +298,11 @@ def trim_columns(self): if e in self.T[s]: self.T[s].pop(e) - def trim(self, hypothesis): + def trim(self, hypothesis: 'Mdp | StochasticMealyMachine') -> None: """ Removes unnecessary rows from the observation table. - Args: - hypothesis: - + :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis, used to look up representative states. """ prefix_to_state_dict = {state.prefix: state for state in hypothesis.states} @@ -360,26 +350,22 @@ def trim(self, hypothesis): else: self.update_obs_table_with_freq_obs() - def stop(self, learning_round, chaos_cex_present, cex, stopping_range_dict, min_rounds=10, max_rounds=None, - target_unambiguity=0.99, print_unambiguity=False): + def stop(self, learning_round: int, chaos_cex_present: bool, cex: tuple | None, stopping_range_dict: dict, + min_rounds: int = 10, max_rounds: int | None = None, + target_unambiguity: float = 0.99, print_unambiguity: bool = False) -> bool: """ Decide if learning should terminate. - Args: - - learning_round: current learning round - chaos_cex_present: is chaos counterexample present in the hypothesis - cex: counterexample found by the eq oracle - stopping_range_dict: dictionary where keys are number of last unambiguity values and value is - maximum differance allowed between them - min_rounds: minimum number of learning rounds (Default value = 5) - max_rounds: maximum number of learning rounds (Default value = None) - target_unambiguity: percentage of rows with unambiguous representatives (Default value = 0.99) - print_unambiguity: if true, current unambiguity rate will be printed (Default value = False) - - Returns: - - True if stopping condition satisfied, false otherwise + :param int learning_round: Current learning round. + :param bool chaos_cex_present: Is chaos counterexample present in the hypothesis. + :param tuple | None cex: Counterexample found by the eq oracle. + :param dict stopping_range_dict: Dictionary where keys encode number of last unambiguity values and value + is maximum difference allowed between them. + :param int min_rounds: Minimum number of learning rounds. + :param int | None max_rounds: Maximum number of learning rounds. + :param float target_unambiguity: Percentage of rows with unambiguous representatives. + :param bool print_unambiguity: If true, current unambiguity rate will be printed. + :return bool: True if stopping condition satisfied, false otherwise. """ if max_rounds: assert min_rounds <= max_rounds @@ -419,7 +405,12 @@ def stop(self, learning_round, chaos_cex_present, cex, stopping_range_dict, min_ return False - def get_unamb_percentage(self): + def get_unamb_percentage(self) -> float: + """ + Computes the percentage of rows that have exactly one compatible representative. + + :return float: Unambiguous rows percentage, rounded to two decimals (0-100 scale). + """ extended_s = list(self.get_extended_s()) self.update_compatibility_classes() numerator = 0 @@ -433,20 +424,14 @@ def get_unamb_percentage(self): unambiguous_rows_percentage = numerator / len(self.S + extended_s) return round(unambiguous_rows_percentage * 100, 2) - def are_cells_incompatible(self, s1, s2, e): + def are_cells_incompatible(self, s1: tuple, s2: tuple, e: tuple) -> bool: """ Checks if 2 cells are considered different. - Args: - - s1: prefix of row s1 - s2: prefix of row s2 - e: element of E - - Returns: - - True if cells are different, false otherwise - + :param tuple s1: Prefix of row s1. + :param tuple s2: Prefix of row s2. + :param tuple e: Element of E. + :return bool: True if cells are different, false otherwise. """ if self.strategy == 'classic': if self.teacher.complete_query(s1, e) and self.teacher.complete_query(s2, e): @@ -459,20 +444,16 @@ def are_cells_incompatible(self, s1, s2, e): return self.compatibility_checker.are_cells_different(self.T[s1][e], self.T[s2][e], s1=s1, s2=s2, e=e) return False - def are_rows_compatible(self, s1, s2, e_ignore=None): + def are_rows_compatible(self, s1: tuple, s2: tuple, e_ignore: tuple | None = None) -> bool: """ Check if the rows are compatible. Rows are compatible if all cells are compatible(not different) and their prefixes end in the same output element. - Args: - s1: prefix of row s1 - s2: prefix of row s2 - e_ignore: e not considered for the computation of row compatibility (Default value = None) - - Returns: - True if rows are compatible, False otherwise - + :param tuple s1: Prefix of row s1. + :param tuple s2: Prefix of row s2. + :param tuple | None e_ignore: e not considered for the computation of row compatibility. + :return bool: True if rows are compatible, False otherwise. """ cache_key = (frozenset((s1, s2)), e_ignore, len(self.E)) if cache_key in self._row_compatibility_cache: @@ -491,8 +472,10 @@ def are_rows_compatible(self, s1, s2, e_ignore=None): self._row_compatibility_cache[cache_key] = True return True - def update_compatibility_classes(self): - """Updates the compatibility classes and stores their representatives.""" + def update_compatibility_classes(self) -> None: + """ + Updates the compatibility classes and stores their representatives. + """ self.compatibility_class.clear() self._row_compatibility_cache.clear() @@ -531,7 +514,7 @@ def update_compatibility_classes(self): self.compatibility_classes_representatives = representatives self._ensure_access_closed_representatives() - def _ensure_access_closed_representatives(self): + def _ensure_access_closed_representatives(self) -> None: """ Representatives are used as hypothesis access rows. If a long row becomes a representative while one of its access prefixes is still only a member of another compatibility class, the generated hypothesis can contain an @@ -553,20 +536,23 @@ def _ensure_access_closed_representatives(self): self.compatibility_classes_representatives.append(prefix) representative_set.add(prefix) - def _proper_access_prefixes(self, row): + def _proper_access_prefixes(self, row: tuple): + """ + Generator yielding the proper access prefixes (even-length steps) of a row. + + :param tuple row: Row to compute proper access prefixes for. + :return: Generator of prefixes of row. + """ first_prefix_len = 3 if self.automaton_type == 'mdp' else 2 for prefix_len in range(first_prefix_len, len(row), 2): yield row[:prefix_len] - def chaos_counterexample(self, hypothesis): - """ Check whether the chaos state is reachable. - - Args: - hypothesis: current hypothesis - - Returns: - True if chaos state is reachable, False otherwise + def chaos_counterexample(self, hypothesis: 'Mdp | StochasticMealyMachine') -> bool: + """ + Check whether the chaos state is reachable. + :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis. + :return bool: True if chaos state is reachable, False otherwise. """ for state in hypothesis.states: if self.automaton_type == "mdp" and state.output == "chaos" \ @@ -588,17 +574,14 @@ def chaos_counterexample(self, hypothesis): return False # return None - def add_to_PTA(self, pta_root, trace, uncertainty_value=None): - """Adds a trace to the PTA. PTA is later used for online sampling. The uncertainty value is added to inputs as + def add_to_PTA(self, pta_root: Node, trace: tuple, uncertainty_value: float | None = None) -> None: + """ + Adds a trace to the PTA. PTA is later used for online sampling. The uncertainty value is added to inputs as frequencies, which specify how often a particular input should be sampled. - Args: - pta_root: root of the prefix tree acceptor - trace: trace to add to the PTA - uncertainty_value: uncertainty value (Default value = None) - - Returns: - + :param Node pta_root: Root of the prefix tree acceptor. + :param tuple trace: Trace to add to the PTA. + :param float | None uncertainty_value: Uncertainty value. """ curr_node = pta_root start = 1 if self.automaton_type == 'mdp' else 0 @@ -617,14 +600,11 @@ def add_to_PTA(self, pta_root, trace, uncertainty_value=None): curr_node.children[inp][output] = new_node curr_node = new_node - def generate_hypothesis(self): - """Generates the hypothesis from the observation table. - :return: current hypothesis - - Args: - - Returns: + def generate_hypothesis(self) -> 'Mdp | StochasticMealyMachine': + """ + Generates the hypothesis from the observation table. + :return Mdp | StochasticMealyMachine: Current hypothesis. """ r_state_map = dict() state_counter = 0 diff --git a/aalpy/learning_algs/stochastic/StochasticCexProcessing.py b/aalpy/learning_algs/stochastic/StochasticCexProcessing.py index 7f1df693043..c72df97eca9 100644 --- a/aalpy/learning_algs/stochastic/StochasticCexProcessing.py +++ b/aalpy/learning_algs/stochastic/StochasticCexProcessing.py @@ -1,18 +1,15 @@ -from aalpy.automata import Mdp +# Counterexample processing strategies used by stochastic L* to extract new suffixes. +from aalpy.automata import Mdp, StochasticMealyMachine from aalpy.base import SUL -def stochastic_longest_prefix(cex, prefixes): +def stochastic_longest_prefix(cex: tuple, prefixes: list) -> tuple: """ Counterexample processing based on Shabaz-Groz cex processing. - Args: - - cex: counterexample - prefixes: all prefixes in the observation table - Returns: - - Single suffix. + :param tuple cex: Counterexample. + :param list prefixes: All prefixes in the observation table. + :return tuple: Single suffix. """ prefixes = list(prefixes) prefixes.sort(key=len, reverse=True) @@ -42,18 +39,14 @@ def stochastic_longest_prefix(cex, prefixes): return suffixes -def stochastic_rs(sul: SUL, cex: tuple, hypothesis): - """Rivest-Schapire counter example processing. - - Args: - - sul: system under learning - cex: found counterexample - hypothesis: hypothesis on which counterexample was found - Returns: - - suffixes to be added to the E set +def stochastic_rs(sul: SUL, cex: tuple, hypothesis: Mdp | StochasticMealyMachine) -> list[tuple]: + """ + Rivest-Schapire counterexample processing. + :param SUL sul: System under learning. + :param tuple cex: Found counterexample. + :param Mdp | StochasticMealyMachine hypothesis: Hypothesis on which counterexample was found. + :return list[tuple]: Suffixes to be added to the E set. """ # cex_out = self.sul.query(tuple(cex)) diff --git a/aalpy/learning_algs/stochastic/StochasticLStar.py b/aalpy/learning_algs/stochastic/StochasticLStar.py index 7d7623b8197..386be1eda6a 100644 --- a/aalpy/learning_algs/stochastic/StochasticLStar.py +++ b/aalpy/learning_algs/stochastic/StochasticLStar.py @@ -1,5 +1,7 @@ +# Sampling-based stochastic L* algorithm for learning MDPs and stochastic Mealy machines. import time +from aalpy.automata import Mdp, StochasticMealyMachine from aalpy.base import SUL, Oracle from aalpy.learning_algs.stochastic.DifferenceChecker import AdvancedHoeffdingChecker, HoeffdingChecker, \ ChiSquareChecker, DifferenceChecker @@ -21,63 +23,44 @@ available_oracles, available_oracles_error_msg = get_available_oracles_and_err_msg() -def run_stochastic_Lstar(input_alphabet, sul: SUL, eq_oracle: Oracle, target_unambiguity=0.99, - min_rounds=10, max_rounds=200, automaton_type='mdp', strategy='normal', - cex_processing=None, samples_cex_strategy=None, stopping_range_dict='strict', custom_oracle=False, - return_data=False, property_based_stopping=None, n_c=20, n_resample=100, print_level=2): +def run_stochastic_Lstar(input_alphabet: list, sul: SUL, eq_oracle: Oracle, target_unambiguity: float = 0.99, + min_rounds: int = 10, max_rounds: int | None = 200, automaton_type: str = 'mdp', + strategy: str | DifferenceChecker = 'normal', + cex_processing: str | None = None, samples_cex_strategy: str | None = None, + stopping_range_dict: dict | str = 'strict', custom_oracle: bool = False, + return_data: bool = False, property_based_stopping: tuple | None = None, + n_c: int = 20, n_resample: int = 100, print_level: int = 2) \ + -> 'Mdp | StochasticMealyMachine | tuple[Mdp | StochasticMealyMachine, dict]': """ Learning of Markov Decision Processes and Stochastic Mealy machines based on 'L*-Based Learning of Markov Decision Processes' and 'Active Model Learning of Stochastic Reactive Systems' by Tappler et al. - Args: - - input_alphabet: input alphabet - - sul: system under learning - - eq_oracle: equivalence oracle - - target_unambiguity: target unambiguity value (default 0.99) - - min_rounds: minimum number of learning rounds (Default value = 10) - - max_rounds: if learning_rounds >= max_rounds, learning will stop (Default value = 200) - - automaton_type: either 'mdp' or 'smm' (Default value = 'mdp') - - strategy: either one of ['classic', 'normal', 'chi2'] or a object implementing DifferenceChecker class, - default value is 'normal'. Classic strategy is the one presented - in the seed paper, 'normal' is the updated version and chi2 is based on chi squared. - - cex_processing: cex processing strategy, None , 'longest_prefix' or 'rs' (rs is experimental) - - samples_cex_strategy: strategy for finding counterexamples in the trace tree. None, 'bfs' or - "random:<#traces to check:int>:" eg. random:200:0.2 - - stopping_range_dict: Values in form of a dictionary, or 'strict', 'relaxed' to use predefined stopping - criteria. Custom values: Dictionary where keys encode the last n unambiguity values which need to be in range - of its value in order to perform early stopping. Eg. {5: 0.001, 10: 0.01} would stop if last 5 hypothesis had - unambiguity values when max(last_5_vals) - (last_5_vals) <= 0.001. - - property_based_stopping: A tuple containing (path to the properties file, correct values of each property, - allowed error for each property. Recommended one is 0.02 (2%)). - - custom_oracle: if True, warning about oracle type will be removed and custom oracle can be used - - return_data: if True, map containing all information like number of queries... will be returned - (Default value = False) - - n_c: cutoff for a cell to be considered complete (Default value = 20), only used with 'classic' strategy - - n_resample: resampling size (Default value = 100), only used with 'classic' strategy - - print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug - (Default value = 2) - - - Returns: - - learned MDP/SMM + :param list input_alphabet: Input alphabet. + :param SUL sul: System under learning. + :param Oracle eq_oracle: Equivalence oracle. + :param float target_unambiguity: Target unambiguity value. + :param int min_rounds: Minimum number of learning rounds. + :param int | None max_rounds: If learning_rounds >= max_rounds, learning will stop. + :param str automaton_type: Either 'mdp' or 'smm'. + :param str | DifferenceChecker strategy: Either one of ['classic', 'normal', 'chi2'] or an object implementing + DifferenceChecker class. Classic strategy is the one presented in the seed paper, 'normal' is the updated + version and chi2 is based on chi squared. + :param str | None cex_processing: Cex processing strategy, None, 'longest_prefix' or 'rs' (rs is experimental). + :param str | None samples_cex_strategy: Strategy for finding counterexamples in the trace tree. None, 'bfs' or + "random:<#traces to check:int>:" eg. random:200:0.2. + :param dict | str stopping_range_dict: Values in form of a dictionary, or 'strict', 'relaxed' to use predefined + stopping criteria. Custom values: Dictionary where keys encode the last n unambiguity values which need to + be in range of its value in order to perform early stopping. Eg. {5: 0.001, 10: 0.01} would stop if last 5 + hypothesis had unambiguity values when max(last_5_vals) - (last_5_vals) <= 0.001. + :param bool custom_oracle: If True, warning about oracle type will be removed and custom oracle can be used. + :param bool return_data: If True, map containing all information like number of queries... will be returned. + :param tuple | None property_based_stopping: A tuple containing (path to the properties file, correct values of + each property, allowed error for each property. Recommended one is 0.02 (2%)). + :param int n_c: Cutoff for a cell to be considered complete, only used with 'classic' strategy. + :param int n_resample: Resampling size, only used with 'classic' strategy. + :param int print_level: 0 - None, 1 - just results, 2 - current round and hypothesis size, 3 - educational/debug. + :return Mdp | StochasticMealyMachine | tuple[Mdp | StochasticMealyMachine, dict]: Learned MDP/SMM, or a + (hypothesis, info) pair if return_data is True. """ assert samples_cex_strategy in cex_sampling_options or samples_cex_strategy.startswith('random') diff --git a/aalpy/learning_algs/stochastic/StochasticTeacher.py b/aalpy/learning_algs/stochastic/StochasticTeacher.py index 3070dcf75cf..11d48398ef5 100644 --- a/aalpy/learning_algs/stochastic/StochasticTeacher.py +++ b/aalpy/learning_algs/stochastic/StochasticTeacher.py @@ -1,25 +1,52 @@ +# Sampling-based teacher that maintains a multiset of observed traces for stochastic L*. from collections import defaultdict from random import choice, random +from typing import Any -from aalpy.base import SUL +from aalpy.automata import Mdp, StochasticMealyMachine +from aalpy.base import SUL, Oracle from aalpy.learning_algs.stochastic.DifferenceChecker import DifferenceChecker class StochasticSUL(SUL): - def __init__(self, sul, teacher): + """ + SUL wrapper that forwards every performed step to the stochastic teacher's trace tree. + """ + + def __init__(self, sul: SUL, teacher: 'StochasticTeacher') -> None: + """ + Create a stochastic SUL wrapper. + + :param SUL sul: The wrapped system under learning. + :param StochasticTeacher teacher: Teacher whose trace tree is updated on every step. + """ super().__init__() self.sul = sul self.teacher = teacher - def pre(self): + def pre(self) -> None: + """ + Reset the current node of the teacher's trace tree to the root and reset the wrapped SUL. + + :return None: The return value of the wrapped SUL's pre() call. + """ self.num_queries += 1 self.teacher.back_to_root() return self.sul.pre() - def post(self): + def post(self) -> None: + """ + Perform cleanup on the wrapped system under learning. + """ self.sul.post() - def step(self, letter): + def step(self, letter: Any) -> Any: + """ + Execute an action on the wrapped SUL and record it in the teacher's trace tree. + + :param Any letter: Single input that is executed on the SUL. + :return Any: Output received after executing the input. + """ self.num_steps += 1 out = self.sul.step(letter) self.teacher.add(letter, out) @@ -31,47 +58,46 @@ class Node: Node of the cache/multiset of all traces. """ - def __init__(self, output): + def __init__(self, output: Any) -> None: + """ + Create a trace tree node. + + :param Any output: Output associated with this node. + """ self.output = output self.frequency = 0 self.children = defaultdict(dict) self.input_frequencies = defaultdict(int) - def get_child(self, inp, out): + def get_child(self, inp: Any, out: Any) -> 'Node | None': """ + Get the child reached by performing `inp` and observing `out`. - Args: - - inp: input - out: output - - Returns: - - Child with output that equals to `out` reached when performing `inp`. If such child does not exist, - return None. + :param Any inp: Input. + :param Any out: Output. + :return Node | None: Child with output that equals to `out` reached when performing `inp`. + If such child does not exist, return None. """ if inp not in self.children.keys() or out not in self.children[inp].keys(): return None return self.children[inp][out] - def get_frequency_sum(self, input_letter): + def get_frequency_sum(self, input_letter: Any) -> int: """ - Returns: + Get the number of times an input was observed in the current node. - number of times input was observed in current state + :param Any input_letter: Input. + :return int: Number of times input was observed in current state. """ return self.input_frequencies[input_letter] - def get_output_frequencies(self, input_letter): + def get_output_frequencies(self, input_letter: Any) -> dict: """ - Args: - - input_letter: input - - Returns: - - observed outputs and their frequencies for given `input_letter` in the current state + Get the observed output frequencies for a given input in the current node. + :param Any input_letter: Input. + :return dict: Observed outputs and their frequencies for given `input_letter` in the + current state. """ if input_letter not in self.children.keys(): return dict() @@ -84,8 +110,19 @@ class StochasticTeacher: Whenever new traces are sampled in the course of learning, they are added to S. """ - def __init__(self, sul: SUL, n_c, eq_oracle, automaton_type, compatibility_checker: DifferenceChecker, - samples_cex_strategy=None): + def __init__(self, sul: SUL, n_c: int, eq_oracle: Oracle, automaton_type: str, + compatibility_checker: DifferenceChecker, samples_cex_strategy: str | None = None) -> None: + """ + Create a stochastic teacher. + + :param SUL sul: System under learning. + :param int n_c: Number of samples required to consider a cell complete. + :param Oracle eq_oracle: Equivalence oracle used to find counterexamples. + :param str automaton_type: Type of automaton being learned, e.g. 'mdp' or 'smm'. + :param DifferenceChecker compatibility_checker: Checker used to compare output distributions. + :param str | None samples_cex_strategy: Strategy used to search for a counterexample in the + sample tree before querying the equivalence oracle ('bfs' or 'random::

'). + """ self.automaton_type = automaton_type if automaton_type == 'mdp': self.initial_value = sul.query(tuple()) @@ -108,19 +145,18 @@ def __init__(self, sul: SUL, n_c, eq_oracle, automaton_type, compatibility_check self.last_cex = None self.last_tree_cex = None - def back_to_root(self): + def back_to_root(self) -> None: + """ + Reset the current node to the root of the trace tree. + """ self.curr_node = self.root_node - def add(self, inp, out): + def add(self, inp: Any, out: Any) -> None: """ - Adds a input/output to the tree. - - Args: - - inp: input - out: output - + Add an input/output pair to the trace tree. + :param Any inp: Input. + :param Any out: Output. """ self.curr_node.input_frequencies[inp] += 1 if inp not in self.curr_node.children.keys() or out not in self.curr_node.children[inp].keys(): @@ -130,19 +166,13 @@ def add(self, inp, out): self.curr_node = self.curr_node.children[inp][out] self.curr_node.frequency += 1 - def frequency_query(self, s: tuple, e: tuple): - """Output frequencies observed after trace s + e. - - Args: - - s: sequence from S set - e: sequence from E set - - - Returns: - - sum of output frequencies + def frequency_query(self, s: tuple, e: tuple) -> dict: + """ + Get the output frequencies observed after trace s + e. + :param tuple s: Sequence from S set. + :param tuple e: Sequence from E set. + :return dict: Sum of output frequencies. """ if self.automaton_type == 'mdp': s = s[1:] @@ -163,20 +193,14 @@ def frequency_query(self, s: tuple, e: tuple): self.complete_query_cache.add(s + e) return output_freq - def complete_query(self, s: tuple, e: tuple): + def complete_query(self, s: tuple, e: tuple) -> bool: """ - Given a test sequences returns true if sufficient information is available to estimate an output distribution - from frequency queries; returns false otherwise. - - Args: - - s: sequence from S set - e: sequence from E set - - Returns: - - True if cell is completed, false otherwise + Determine whether sufficient information is available to estimate an output distribution + from frequency queries for a given test sequence. + :param tuple s: Sequence from S set. + :param tuple e: Sequence from E set. + :return bool: True if cell is completed, false otherwise. """ # extract inputs and outputs @@ -208,19 +232,13 @@ def complete_query(self, s: tuple, e: tuple): self.complete_query_cache.add(s + e) return sum_freq >= self.n_c - def tree_query(self, pta_root): + def tree_query(self, pta_root: Node) -> None: """ - Execute a refine query based on input/output trace. If at some point real outputs differ from expected - outputs, trace to that point is added to the tree, otherwise whole trace is executed. - - Args: - - pta_root: root of the PTA - - Returns: - - number of steps taken + Execute a refine query based on an input/output trace sampled from the trace tree. If at + some point real outputs differ from expected outputs, the trace up to that point is added + to the tree, otherwise the whole trace is executed. + :param Node pta_root: Root of the PTA. """ self.sul.pre() curr_node = pta_root @@ -264,7 +282,14 @@ def tree_query(self, pta_root): self.sul.post() return - def single_dfs_for_cex(self, stop_prob, hypothesis): + def single_dfs_for_cex(self, stop_prob: float, hypothesis: Mdp | StochasticMealyMachine) -> tuple | None: + """ + Perform a single randomized depth-first search over the trace tree for a counterexample. + + :param float stop_prob: Probability of stopping the search at each step. + :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis. + :return tuple | None: Counterexample trace, or None if none was found in this pass. + """ curr_node = self.root_node curr_state = hypothesis.initial_state if self.automaton_type == "mdp": @@ -303,14 +328,29 @@ def single_dfs_for_cex(self, stop_prob, hypothesis): curr_state = next_state trace = trace + (i,) + (o,) - def dfs_for_cex_in_tree(self, hypothesis, nr_traces, stop_prob): + def dfs_for_cex_in_tree(self, hypothesis: Mdp | StochasticMealyMachine, nr_traces: int, + stop_prob: float) -> tuple | None: + """ + Repeatedly search the trace tree for a counterexample using randomized depth-first search. + + :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis. + :param int nr_traces: Number of search attempts to perform. + :param float stop_prob: Probability of stopping each search early. + :return tuple | None: Counterexample trace, or None if none was found. + """ for i in range(nr_traces): cex = self.single_dfs_for_cex(stop_prob, hypothesis) if cex: return cex return None - def bfs_for_cex_in_tree(self, hypothesis): + def bfs_for_cex_in_tree(self, hypothesis: Mdp | StochasticMealyMachine) -> tuple | None: + """ + Search the trace tree for a counterexample using breadth-first search. + + :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis. + :return tuple | None: Counterexample trace, or None if none was found. + """ # BFS for cex if self.automaton_type == "mdp": to_check = [(self.root_node, hypothesis.initial_state, tuple(self.initial_value))] @@ -341,18 +381,13 @@ def bfs_for_cex_in_tree(self, hypothesis): to_check.append((c, next_state, new_trace)) return None - def equivalence_query(self, hypothesis): + def equivalence_query(self, hypothesis: Mdp | StochasticMealyMachine) -> tuple | None: """ - Finds and returns a counterexample - - Args: - - hypothesis: current hypothesis - - Returns: - - counterexample + Find and return a counterexample, preferring cached or tree-based candidates before + falling back to the wrapped equivalence oracle. + :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis. + :return tuple | None: Counterexample, or None if none was found. """ if self.last_cex and not self.is_cex_processed(hypothesis, self.last_cex): return self.last_cex @@ -380,7 +415,15 @@ def equivalence_query(self, hypothesis): self.last_cex = cex return cex - def is_cex_processed(self, hypothesis, cex): + def is_cex_processed(self, hypothesis: Mdp | StochasticMealyMachine, cex: tuple) -> bool: + """ + Check whether a previously found counterexample is still a valid counterexample on the + given hypothesis. + + :param Mdp | StochasticMealyMachine hypothesis: Current hypothesis. + :param tuple cex: Previously found counterexample. + :return bool: True if the counterexample is still valid (unprocessed), False otherwise. + """ if self.automaton_type == 'mdp': cex = cex[1:] last_inp = cex[-1] diff --git a/aalpy/learning_algs/stochastic/__init__.py b/aalpy/learning_algs/stochastic/__init__.py index e69de29bb2d..3b2b240a094 100644 --- a/aalpy/learning_algs/stochastic/__init__.py +++ b/aalpy/learning_algs/stochastic/__init__.py @@ -0,0 +1 @@ +# Sampling-based stochastic L* algorithm and supporting components for learning MDPs and stochastic Mealy machines. diff --git a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py index 5abdfcdc4c7..ee338a9e9db 100644 --- a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py +++ b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py @@ -1,7 +1,11 @@ +# Active Alergia: samples from the system under learning based on intermediate hypotheses to augment the +# learning data used by (passive) Alergia/IOAlergia. from abc import ABC, abstractmethod from random import randint, choice +from aalpy.base import SUL from aalpy.learning_algs import run_Alergia +from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import CompatibilityChecker class Sampler(ABC): @@ -10,30 +14,42 @@ class Sampler(ABC): """ @abstractmethod - def sample(self, sul, model): + def sample(self, sul: SUL, model: 'Mdp') -> list: """ Abstract method implementing sampling strategy. - Args: - - sul: system under learning - model: current learned model - - Returns: - - Data to be added to the data set for the passive learnign. - + :param SUL sul: System under learning. + :param Mdp model: Current learned model. + :return list: Data to be added to the data set for the passive learning. """ pass class RandomWordSampler(Sampler): - def __init__(self, num_walks, min_walk_len, max_walk_len): + """ + Sampler that generates random walks over the input alphabet of the current hypothesis. + """ + + def __init__(self, num_walks: int, min_walk_len: int, max_walk_len: int) -> None: + """ + Creates a random word sampler. + + :param int num_walks: Number of random walks to sample per iteration. + :param int min_walk_len: Minimum length of a random walk. + :param int max_walk_len: Maximum length of a random walk. + """ self.num_walks = num_walks self.min_walk_len = min_walk_len self.max_walk_len = max_walk_len - def sample(self, sul, model): + def sample(self, sul: SUL, model: 'Mdp') -> list: + """ + Samples num_walks random walks of random length over the current hypothesis' input alphabet. + + :param SUL sul: System under learning. + :param Mdp model: Current learned model, used to extract the input alphabet. + :return list: List of sampled traces in the form [output, (input, output), ...]. + """ input_al = list({el for s in model.states for el in s.transitions.keys()}) samples = [] @@ -52,28 +68,23 @@ def sample(self, sul, model): return samples -def run_active_Alergia(data, sul, sampler, n_iter, eps=0.05, compatibility_checker=None, automaton_type='mdp', - print_info=True): +def run_active_Alergia(data: list, sul: SUL, sampler: Sampler, n_iter: int, eps: float | str = 0.05, + compatibility_checker: CompatibilityChecker | None = None, automaton_type: str = 'mdp', + print_info: bool = True) -> 'Mdp': """ Active version of IOAlergia algorithm. Based on intermediate hypothesis sampling on the system is performed. Sampled data is added to the learning data and more accurate model is learned. - Proposed in "Aichernig and Tappler, Probabilistic Black-Box Reachability Checking" - - Args: - - data: initial learning data, in form [[O, (I,O), (I,O)...] ,...] where O is outputs and I input. - sul: system under learning which is basis for sampling - sampler: instance of Sampler class - n_iter: number of iterations of active learning - eps: epsilon value if the default checker is used. Look in run_Alergia for description - compatibility_checker: passed to run_Alergia, check there for description - automaton_type: either 'mdp' or 'smm' (Markov decision process or Stochastic Mealy Machine) - print_info: print current learning iteration - - Returns: - - learned MDP - + Proposed in "Aichernig and Tappler, Probabilistic Black-Box Reachability Checking". + + :param list data: Initial learning data, in form [[O, (I,O), (I,O)...] ,...] where O is outputs and I input. + :param SUL sul: System under learning which is basis for sampling. + :param Sampler sampler: Instance of Sampler class. + :param int n_iter: Number of iterations of active learning. + :param float | str eps: Epsilon value if the default checker is used. Look in run_Alergia for description. + :param CompatibilityChecker | None compatibility_checker: Passed to run_Alergia, check there for description. + :param str automaton_type: Either 'mdp' or 'smm' (Markov decision process or Stochastic Mealy Machine). + :param bool print_info: Print current learning iteration. + :return Mdp: Learned MDP. """ model = None for i in range(n_iter): @@ -85,4 +96,3 @@ def run_active_Alergia(data, sul, sampler, n_iter, eps=0.05, compatibility_check data.extend(new_samples) return model - diff --git a/aalpy/learning_algs/stochastic_passive/Alergia.py b/aalpy/learning_algs/stochastic_passive/Alergia.py index 653450432c2..c3d80648ca8 100644 --- a/aalpy/learning_algs/stochastic_passive/Alergia.py +++ b/aalpy/learning_algs/stochastic_passive/Alergia.py @@ -1,17 +1,36 @@ +# Implementation of the Alergia/IOAlergia passive learning algorithm for Markov chains, MDPs, and stochastic Mealy +# machines, plus helper entry points for running it on in-memory data or via the external JAlergia implementation. import time from bisect import insort from aalpy.automata import MarkovChain, MdpState, Mdp, McState, StochasticMealyState, \ StochasticMealyMachine -from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import HoeffdingCompatibility -from aalpy.learning_algs.stochastic_passive.FPTA import create_fpta +from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import CompatibilityChecker, HoeffdingCompatibility +from aalpy.learning_algs.stochastic_passive.FPTA import AlergiaPtaNode, create_fpta state_automaton_map = {'mc': (McState, MarkovChain), 'mdp': (MdpState, Mdp), 'smm': (StochasticMealyState, StochasticMealyMachine)} class Alergia: - def __init__(self, data, automaton_type, eps=0.05, compatibility_checker=None, print_info=False): + """ + Implementation of the Alergia/IOAlergia state-merging algorithm, building an FPTA from data and merging + statistically compatible states to obtain a Markov chain, MDP, or stochastic Mealy machine. + """ + + def __init__(self, data: list, automaton_type: str, eps: float | str = 0.05, + compatibility_checker: CompatibilityChecker | None = None, print_info: bool = False) -> None: + """ + Creates an Alergia instance and constructs the FPTA from the provided data. + + :param list data: Learning data, format depends on automaton_type (see run_Alergia for details). + :param str automaton_type: Either 'mdp', 'mc', or 'smm'. + :param float | str eps: Epsilon value for the default HoeffdingCompatibility, or 'auto' to compute it from + the data. + :param CompatibilityChecker | None compatibility_checker: Custom compatibility checker, HoeffdingCompatibility + with eps value by default. + :param bool print_info: If True, print timing/statistics information. + """ assert eps == 'auto' or 0 < eps <= 2 self.automaton_type = automaton_type @@ -30,7 +49,14 @@ def __init__(self, data, automaton_type, eps=0.05, compatibility_checker=None, p if self.print_info: print(f'PTA Construction Time: {pta_time}') - def compatibility_test(self, a, b): + def compatibility_test(self, a: AlergiaPtaNode, b: AlergiaPtaNode) -> bool: + """ + Recursively checks whether two FPTA nodes (and all their reachable descendants) are compatible for merging. + + :param AlergiaPtaNode a: First node. + :param AlergiaPtaNode b: Second node. + :return bool: True if the nodes are compatible, False otherwise. + """ # for MDPs and MC output of the state needs to be the same if self.automaton_type != 'smm' and a.output != b.output: @@ -51,7 +77,13 @@ def compatibility_test(self, a, b): return True - def merge(self, red_state, blue_state): + def merge(self, red_state: AlergiaPtaNode, blue_state: AlergiaPtaNode) -> None: + """ + Merges a blue node into a red (representative) node by rewiring its parent's transition and folding it in. + + :param AlergiaPtaNode red_state: Representative state that will absorb the blue state. + :param AlergiaPtaNode blue_state: State to be merged into the red state. + """ b_prefix = blue_state.prefix to_update = self.fpta for p in b_prefix[:-1]: @@ -61,7 +93,13 @@ def merge(self, red_state, blue_state): self.fold(red_state, blue_state) - def fold(self, red, blue): + def fold(self, red: AlergiaPtaNode, blue: AlergiaPtaNode) -> None: + """ + Recursively folds the subtree rooted at blue into the subtree rooted at red, merging input frequencies. + + :param AlergiaPtaNode red: Node into which blue is folded. + :param AlergiaPtaNode blue: Node being folded into red. + """ for i, blue_child in blue.children.items(): if i in red.children: red.input_frequency[i] += blue.input_frequency[i] @@ -70,7 +108,12 @@ def fold(self, red, blue): red.children[i] = blue.children[i] red.input_frequency[i] = blue.input_frequency[i] - def run(self): + def run(self) -> MarkovChain | Mdp | StochasticMealyMachine: + """ + Runs the Alergia state-merging loop on the FPTA and converts the resulting red states into an automaton. + + :return MarkovChain | Mdp | StochasticMealyMachine: The learned automaton. + """ start_time = time.time() # representative nodes that will be included in the final output model @@ -112,7 +155,12 @@ def run(self): return self.to_automaton(red) - def normalize(self, red): + def normalize(self, red: list) -> None: + """ + Normalizes input/output frequencies of all red states into probabilities. + + :param list red: List of representative (red) AlergiaPtaNode states. + """ red_sorted = sorted(list(red), key=lambda x: len(x.prefix)) for r in red_sorted: # Initializing in here saves many unnecessary initializations @@ -125,7 +173,13 @@ def normalize(self, red): for i, o in r.input_frequency.keys(): r.children_prob[(i, o)] = r.input_frequency[(i, o)] / r.get_input_frequency(i) - def to_automaton(self, red): + def to_automaton(self, red: list) -> MarkovChain | Mdp | StochasticMealyMachine: + """ + Converts the list of red FPTA nodes into an automaton of the configured type. + + :param list red: List of representative (red) AlergiaPtaNode states. + :return MarkovChain | Mdp | StochasticMealyMachine: The constructed automaton. + """ s_c = state_automaton_map[self.automaton_type][0] a_c = state_automaton_map[self.automaton_type][1] @@ -162,31 +216,24 @@ def to_automaton(self, red): return a_c(initial_state, states) -def run_Alergia(data, automaton_type, eps=0.05, compatibility_checker=None, print_info=False): +def run_Alergia(data: list, automaton_type: str, eps: float | str = 0.05, + compatibility_checker: CompatibilityChecker | None = None, + print_info: bool = False) -> MarkovChain | Mdp | StochasticMealyMachine: """ Run Alergia or IOAlergia on provided data. - Args: - - data: data either in a form [[I,I,I],[I,I,I],...] if learning Markov Chains or [[O,(I,O),(I,O)...], - [O,(I,O), (I, O)_,...],..,] if learning MDPs, or [[I,O,I,O...], [I,O_,...],..,] if learning SMMs - (I represents input, O output). - Note that in whole data first symbol of each entry should be the same (Initial output of the MDP/MC). - - eps: epsilon value if you are using default HoeffdingCompatibility. If it is set to 'auto' it will be computed - as 10/(all steps in the data) - - automaton_type: either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain, or 'smm' if - you want to learn stochastic Mealy machine - - compatibility_checker: impl. of class CompatibilityChecker, HoeffdingCompatibility with eps value by default - - (note: not interchangeable, depends on data) - print_info: - - Returns: - - mdp, smm, or markov chain + :param list data: Data either in a form [[I,I,I],[I,I,I],...] if learning Markov Chains or + [[O,(I,O),(I,O)...], [O,(I,O), (I, O)_,...],..,] if learning MDPs, or [[I,O,I,O...], [I,O_,...],..,] if + learning SMMs (I represents input, O output). Note that in whole data first symbol of each entry should be + the same (Initial output of the MDP/MC). + :param str automaton_type: Either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain, or + 'smm' if you want to learn stochastic Mealy machine. + :param float | str eps: Epsilon value if you are using default HoeffdingCompatibility. If it is set to 'auto' it + will be computed as 10/(all steps in the data). + :param CompatibilityChecker | None compatibility_checker: Impl. of class CompatibilityChecker, + HoeffdingCompatibility with eps value by default (note: not interchangeable, depends on data). + :param bool print_info: Print learning statistics. + :return MarkovChain | Mdp | StochasticMealyMachine: Learned MDP, SMM, or Markov chain. """ assert automaton_type in {'mdp', 'mc', 'smm'} alergia = Alergia(data, eps=eps, automaton_type=automaton_type, @@ -196,30 +243,21 @@ def run_Alergia(data, automaton_type, eps=0.05, compatibility_checker=None, prin return model -def run_JAlergia(path_to_data_file, automaton_type, path_to_jAlergia_jar, eps=0.05, heap_memory='-Xmx2048M'): +def run_JAlergia(path_to_data_file: str | list, automaton_type: str, path_to_jAlergia_jar: str, + eps: float = 0.05, heap_memory: str = '-Xmx2048M') -> MarkovChain | Mdp | StochasticMealyMachine | None: """ - Run Alergia or IOAlergia on provided data. - - Args: - - path_to_data_file: either a data in a list of lists or a path to file containing data. - Form [[I,I,I],[I,I,I],...] if learning Markov Chains or - [[O,I,O,I,O...], [O,I,O_,...],..,] if learning MDPs (I represents input, O output), or - [[I,O,I,O...], [I,O_,...],..,] if learning SMMs. - Note that in whole data first symbol of each entry should be the same (Initial output of the MDP/MC). - - eps: epsilon value - - heap_memory: java heap memory flag, increase if heap is full - - automaton_type: either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain, - or 'smm' if you - want to learn stochastic Mealy machine - - - Returns: - - learnedModel + Run Alergia or IOAlergia on provided data using the external JAlergia Java implementation. + + :param str | list path_to_data_file: Either a data in a list of lists or a path to file containing data. Form + [[I,I,I],[I,I,I],...] if learning Markov Chains or [[O,I,O,I,O...], [O,I,O_,...],..,] if learning MDPs + (I represents input, O output), or [[I,O,I,O...], [I,O_,...],..,] if learning SMMs. Note that in whole data + first symbol of each entry should be the same (Initial output of the MDP/MC). + :param str automaton_type: Either 'mdp' if you wish to learn an MDP, 'mc' if you want to learn Markov Chain, or + 'smm' if you want to learn stochastic Mealy machine. + :param str path_to_jAlergia_jar: Path to the JAlergia jar file. + :param float eps: Epsilon value. + :param str heap_memory: Java heap memory flag, increase if heap is full. + :return MarkovChain | Mdp | StochasticMealyMachine | None: Learned model, or None if an error occurred. """ assert automaton_type in {'mdp', 'smm', 'mc'} diff --git a/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py b/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py index 87c6972ebbc..cad65c117b3 100644 --- a/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py +++ b/aalpy/learning_algs/stochastic_passive/CompatibilityChecker.py @@ -1,3 +1,4 @@ +# Compatibility checkers used by Alergia/IOAlergia to decide whether two FPTA states may be merged. from abc import ABC, abstractmethod from math import sqrt, log @@ -5,18 +6,47 @@ class CompatibilityChecker(ABC): + """ + Abstract class implemented by all compatibility checkers used to decide whether two states of the FPTA are + statistically different (and thus should not be merged). + """ @abstractmethod def are_states_different(self, a: AlergiaPtaNode, b: AlergiaPtaNode, **kwargs) -> bool: + """ + Checks whether two FPTA nodes are statistically different. + + :param AlergiaPtaNode a: First node. + :param AlergiaPtaNode b: Second node. + :param kwargs: Additional implementation-specific arguments. + :return bool: True if the nodes are statistically different, False otherwise. + """ pass class HoeffdingCompatibility(CompatibilityChecker): - def __init__(self, eps): + """ + Compatibility checker based on the Hoeffding bound, comparing observed output frequency distributions of two + FPTA nodes. + """ + + def __init__(self, eps: float) -> None: + """ + Creates a Hoeffding-bound-based compatibility checker. + + :param float eps: Epsilon value controlling the strictness of the Hoeffding bound. + """ self.eps = eps self.log_term = sqrt(0.5 * log(2 / self.eps)) - def hoeffding_bound(self, a: dict, b: dict): + def hoeffding_bound(self, a: dict, b: dict) -> bool: + """ + Checks whether two output frequency distributions differ by more than the Hoeffding bound. + + :param dict a: Frequency distribution of the first node. + :param dict b: Frequency distribution of the second node. + :return bool: True if the distributions differ by more than the Hoeffding bound, False otherwise. + """ n1 = sum(a.values()) n2 = sum(b.values()) @@ -33,7 +63,16 @@ def hoeffding_bound(self, a: dict, b: dict): return True return False - def are_states_different(self, a: AlergiaPtaNode, b: AlergiaPtaNode, **kwargs): + def are_states_different(self, a: AlergiaPtaNode, b: AlergiaPtaNode, **kwargs) -> bool: + """ + Checks whether two FPTA nodes are statistically different based on the Hoeffding bound, conditioned on + inputs in the IOAlergia case. + + :param AlergiaPtaNode a: First node. + :param AlergiaPtaNode b: Second node. + :param kwargs: Unused, present for interface compatibility. + :return bool: True if the nodes are statistically different, False otherwise. + """ # no data available for any node if len(a.original_input_frequency) * len(b.original_children) == 0: diff --git a/aalpy/learning_algs/stochastic_passive/FPTA.py b/aalpy/learning_algs/stochastic_passive/FPTA.py index 08b0dac446b..f0381df1c7c 100644 --- a/aalpy/learning_algs/stochastic_passive/FPTA.py +++ b/aalpy/learning_algs/stochastic_passive/FPTA.py @@ -1,12 +1,26 @@ +# Frequency Prefix Tree Acceptor (FPTA) data structure used by Alergia/IOAlergia as the initial hypothesis. from functools import total_ordering +from typing import Any @total_ordering class AlergiaPtaNode: + """ + Single node of the frequency prefix tree acceptor (FPTA). Keeps both the current (mutable) and original + (immutable) children/input frequencies, the latter being used for statistical compatibility checks. + """ + __slots__ = ['prefix', 'output', 'input_frequency', 'children', 'original_input_frequency', 'original_children', 'state_id', 'children_prob'] - def __init__(self, output, prefix): + def __init__(self, output: Any, prefix: tuple) -> None: + """ + Creates a new FPTA node. + + :param Any output: Output associated with this node. + :param tuple prefix: Sequence of inputs (and/or outputs, depending on automaton type) that lead to this node + from the root. + """ self.prefix = prefix self.output = output # mutable values @@ -19,38 +33,84 @@ def __init__(self, output, prefix): self.state_id = None self.children_prob = None - def successors(self): + def successors(self) -> list['AlergiaPtaNode']: + """ + Returns the (mutable) children of this node. + + :return list[AlergiaPtaNode]: List of successor nodes. + """ return list(self.children.values()) - def get_inputs(self): + def get_inputs(self) -> set: + """ + Returns the set of inputs observed in the mutable input frequency map. + + :return set: Set of inputs. + """ return {i for i, _ in self.input_frequency.keys()} - def get_input_frequency(self, target_input): + def get_input_frequency(self, target_input: Any) -> int: + """ + Computes the total frequency of a given input, summed over all outputs. + + :param Any target_input: Input whose frequency should be computed. + :return int: Total observed frequency of the input. + """ return sum(freq for (i, _), freq in self.input_frequency.items() if i == target_input) - def get_output_frequencies(self, target_input): + def get_output_frequencies(self, target_input: Any) -> dict: + """ + Returns the frequency of each output observed for a given input. + + :param Any target_input: Input for which output frequencies should be computed. + :return dict: Mapping of output to observed frequency. + """ return {o: freq for (i, o), freq in self.input_frequency.items() if i == target_input} - def get_immutable_inputs(self): + def get_immutable_inputs(self) -> set: + """ + Returns the set of inputs observed in the original (immutable) children map. + + :return set: Set of inputs. + """ return {i for i, _ in self.original_children.keys()} - def get_immutable_input_frequency(self, target_input): + def get_immutable_input_frequency(self, target_input: Any) -> int: + """ + Computes the total original frequency of a given input, summed over all outputs. + + :param Any target_input: Input whose frequency should be computed. + :return int: Total original frequency of the input. + """ return sum(freq for (i, _), freq in self.original_input_frequency.items() if i == target_input) - def get_original_output_frequencies(self, target_input): + def get_original_output_frequencies(self, target_input: Any) -> dict: + """ + Returns the original frequency of each output observed for a given input. + + :param Any target_input: Input for which output frequencies should be computed. + :return dict: Mapping of output to original observed frequency. + """ return {o: freq for (i, o), freq in self.original_input_frequency.items() if i == target_input} - def __lt__(self, other): + def __lt__(self, other: 'AlergiaPtaNode') -> bool: return (len(self.prefix), self.prefix) < (len(other.prefix), other.prefix) - def __le__(self, other): + def __le__(self, other: 'AlergiaPtaNode') -> bool: return self < other or self == other - def __eq__(self, other): + def __eq__(self, other: 'AlergiaPtaNode') -> bool: return self.prefix == other.prefix -def create_fpta(data, automaton_type): +def create_fpta(data: list, automaton_type: str) -> AlergiaPtaNode: + """ + Builds the frequency prefix tree acceptor (FPTA) from a data set of observed traces. + + :param list data: Data set of traces, format depends on automaton_type (see run_Alergia for details). + :param str automaton_type: Either 'mc', 'mdp', or 'smm'. + :return AlergiaPtaNode: Root node of the constructed FPTA. + """ # in case of SMM, there is no initial input seq_iter_index = 0 if automaton_type == 'smm' else 1 diff --git a/aalpy/learning_algs/stochastic_passive/__init__.py b/aalpy/learning_algs/stochastic_passive/__init__.py index e69de29bb2d..0e9be0adb99 100644 --- a/aalpy/learning_algs/stochastic_passive/__init__.py +++ b/aalpy/learning_algs/stochastic_passive/__init__.py @@ -0,0 +1 @@ +# Package containing passive stochastic learning algorithms (Alergia/IOAlergia, active Alergia) and the FPTA data structure. diff --git a/aalpy/oracles/BreadthFirstExplorationEqOracle.py b/aalpy/oracles/BreadthFirstExplorationEqOracle.py index 0e3ca74b319..3cf01f1f679 100644 --- a/aalpy/oracles/BreadthFirstExplorationEqOracle.py +++ b/aalpy/oracles/BreadthFirstExplorationEqOracle.py @@ -1,5 +1,7 @@ +# Equivalence oracle that exhaustively explores all input combinations up to a fixed depth. from aalpy.base.Oracle import Oracle from aalpy.base.SUL import SUL +from aalpy.base.Automaton import Automaton from itertools import product from random import shuffle @@ -11,15 +13,13 @@ class BreadthFirstExplorationEqOracle(Oracle): Extremely inefficient equivalence oracle and should only be used for demonstrations. """ - def __init__(self, alphabet, sul: SUL, depth=5): + def __init__(self, alphabet: list, sul: SUL, depth: int = 5) -> None: """ - Args: + Constructs the oracle and pre-generates all test cases of the given depth. - alphabet: input alphabet - - sul: system under learning - - depth: depth of the tree + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int depth: Depth of the tree, i.e. length of each generated test case. """ super().__init__(alphabet, sul) @@ -32,8 +32,13 @@ def __init__(self, alphabet, sul: SUL, depth=5): shuffle(self.queue) - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | None: + """ + Executes queued test cases against the SUL and hypothesis until a counterexample is found. + :param Automaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ while self.queue: test_case = self.queue.pop() self.reset_hyp_and_sul(hypothesis) diff --git a/aalpy/oracles/CacheBasedEqOracle.py b/aalpy/oracles/CacheBasedEqOracle.py index 953999ceeaa..7d6edc5ca1e 100644 --- a/aalpy/oracles/CacheBasedEqOracle.py +++ b/aalpy/oracles/CacheBasedEqOracle.py @@ -1,5 +1,9 @@ +# Equivalence oracle that reuses the trace cache built during learning to guide test case selection. +from typing import Any + from aalpy.base import Oracle, SUL from aalpy.base.SUL import CacheSUL +from aalpy.base.Automaton import Automaton from random import choice @@ -11,21 +15,17 @@ class CacheBasedEqOracle(Oracle): of length (max_tree_depth + 'depth_increase') - len(prefix), where prefix is a path to the leaf. """ - def __init__(self, alphabet: list, sul: SUL, num_walks=100, depth_increase=5, reset_after_cex=True): + def __init__(self, alphabet: list, sul: SUL, num_walks: int = 100, depth_increase: int = 5, + reset_after_cex: bool = True) -> None: """ - - Args: - - alphabet: input alphabet - - sul: system under learning - - num_walks: number of random walks to perform - - depth_increase: length of random walk that exceeds the maximum depth of the tree - - reset_after_cex: if False, total number of queries will equal num_walks, if True, in each execution of - find_cex method at most num_walks will be executed + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. Must wrap or be a CacheSUL. + :param int num_walks: Number of random walks to perform. + :param int depth_increase: Length of random walk that exceeds the maximum depth of the tree. + :param bool reset_after_cex: If False, total number of queries will equal num_walks, if True, in each + execution of find_cex method at most num_walks will be executed. """ super().__init__(alphabet, sul) @@ -35,8 +35,13 @@ def __init__(self, alphabet: list, sul: SUL, num_walks=100, depth_increase=5, re self.reset_after_cex = reset_after_cex self.num_walks_done = 0 - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> list | None: + """ + Performs random walks starting from cached prefixes until a counterexample is found. + :param Automaton hypothesis: Current hypothesis. + :return list | None: Counterexample inputs, None if no counterexample is found. + """ assert isinstance(self.sul, CacheSUL) self.cache_tree = self.sul.cache @@ -75,16 +80,14 @@ def find_cex(self, hypothesis): return None - def get_paths(self, t, paths=None, current_path=None): + def get_paths(self, t: Any, paths: list | None = None, current_path: list | None = None) -> list: """ + Recursively collects the paths (sequences of inputs) from the root of a cache tree node to all its leaves. - Args: - t: - paths: (Default value = None) - current_path: (Default value = None) - - Returns: - + :param Any t: Cache tree node to collect paths from. + :param list | None paths: Accumulator of completed paths, created if None. + :param list | None current_path: Path accumulated so far to reach node t, created if None. + :return list: List of paths (each a list of inputs) from t to its leaves. """ if paths is None: paths = [] diff --git a/aalpy/oracles/KWayStateCoverageEqOracle.py b/aalpy/oracles/KWayStateCoverageEqOracle.py index 250f2a75355..910fa50ec89 100644 --- a/aalpy/oracles/KWayStateCoverageEqOracle.py +++ b/aalpy/oracles/KWayStateCoverageEqOracle.py @@ -1,6 +1,8 @@ +# Equivalence oracle that covers k-wise combinations/permutations of states with a trailing random walk. from random import choices, shuffle from aalpy.base import Oracle, SUL +from aalpy.base.Automaton import Automaton from itertools import combinations, permutations @@ -10,22 +12,22 @@ class KWayStateCoverageEqOracle(Oracle): random walk at the end. """ - def __init__(self, alphabet: list, sul: SUL, k=2, random_walk_len=20, - method='permutations', - num_test_lower_bound=None, - num_test_upper_bound=None): + def __init__(self, alphabet: list, sul: SUL, k: int = 2, random_walk_len: int = 20, + method: str = 'permutations', + num_test_lower_bound: int | None = None, + num_test_upper_bound: int | None = None) -> None: """ - - Args: - - alphabet: input alphabet - sul: system under learning - k: k value used for k-wise combinations/permutations of states - random_walk_len: length of random walk performed at the end of each combination/permutation - method: either 'combinations' or 'permutations' - num_test_lower_bound= either None or number a minimum number of test-cases to be performed in each testing round - num_test_upper_bound= either None or number a maximum number of test-cases to be performed in each testing round - + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int k: k value used for k-wise combinations/permutations of states. + :param int random_walk_len: Length of random walk performed at the end of each combination/permutation. + :param str method: Either 'combinations' or 'permutations'. + :param int | None num_test_lower_bound: Either None or a minimum number of test-cases to be performed in + each testing round. + :param int | None num_test_upper_bound: Either None or a maximum number of test-cases to be performed in + each testing round. """ super().__init__(alphabet, sul) assert k > 1 and method in ['combinations', 'permutations'] @@ -37,8 +39,14 @@ def __init__(self, alphabet: list, sul: SUL, k=2, random_walk_len=20, self.num_test_lower_bound = num_test_lower_bound self.num_test_upper_bound = num_test_upper_bound - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | None: + """ + Generates and executes test cases covering k-wise state combinations/permutations until a counterexample + is found. + :param Automaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ shuffle(hypothesis.states) test_cases = [] diff --git a/aalpy/oracles/KWayTransitionCoverageEqOracle.py b/aalpy/oracles/KWayTransitionCoverageEqOracle.py index 8a6814c9019..5d6004af396 100644 --- a/aalpy/oracles/KWayTransitionCoverageEqOracle.py +++ b/aalpy/oracles/KWayTransitionCoverageEqOracle.py @@ -1,4 +1,6 @@ +# Equivalence oracle that selects test cases based on k-way transition coverage. from collections import namedtuple +from collections.abc import Iterator from itertools import product from random import choices, randint, random @@ -20,25 +22,27 @@ def __init__(self, alphabet: list, sul: SUL, k: int = 2, method='random', max_path_len: int = 50, max_number_of_steps: int = 0, optimize: str = 'steps', - random_walk_len=10, - num_test_lower_bound=None, - num_test_upper_bound=None, - ): - """ - Args: - - alphabet: input alphabet - sul: system under learning - k: k value used for K-Way transitions, i.e the number of steps between the start and the end of a transition - method: defines how the queries are generated 'random' or 'prefix' - num_generate_paths: number of random queries used to find the optimal subset - max_path_len: the maximum step size of a generated path - max_number_of_steps: maximum number of steps that will be executed on the SUL (0 = no limit) - optimize: minimize either the number of 'steps' or 'queries' that are executed - random_walk_len: the number of steps that are added by 'prefix' generated paths - num_test_lower_bound= either None or number a minimum number of test-cases to be performed in each testing round - num_test_upper_bound= either None or number a maximum number of test-cases to be performed in each testing round - + random_walk_len: int = 10, + num_test_lower_bound: int | None = None, + num_test_upper_bound: int | None = None, + ) -> None: + """ + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int k: k value used for K-Way transitions, i.e the number of steps between the start and the end of + a transition. + :param str method: Defines how the queries are generated, 'random' or 'prefix'. + :param int num_generate_paths: Number of random queries used to find the optimal subset. + :param int max_path_len: The maximum step size of a generated path. + :param int max_number_of_steps: Maximum number of steps that will be executed on the SUL (0 = no limit). + :param str optimize: Minimize either the number of 'steps' or 'queries' that are executed. + :param int random_walk_len: The number of steps that are added by 'prefix' generated paths. + :param int | None num_test_lower_bound: Either None or a minimum number of test-cases to be performed in + each testing round. + :param int | None num_test_upper_bound: Either None or a maximum number of test-cases to be performed in + each testing round. """ super().__init__(alphabet, sul) assert k >= 2 @@ -61,7 +65,13 @@ def __init__(self, alphabet: list, sul: SUL, k: int = 2, method='random', self.cached_paths = list() - def find_cex(self, hypothesis: Automaton): + def find_cex(self, hypothesis: Automaton) -> tuple | list | None: + """ + Generates paths covering k-way transitions and executes them until a counterexample is found. + + :param Automaton hypothesis: Current hypothesis. + :return tuple | list | None: Counterexample inputs, None if no counterexample is found. + """ if self.method == 'random': paths = self.generate_random_paths(hypothesis) + self.cached_paths self.cached_paths = self.greedy_set_cover(hypothesis, paths) @@ -111,7 +121,14 @@ def find_cex(self, hypothesis: Automaton): return None - def greedy_set_cover(self, hypothesis: Automaton, paths: list): + def greedy_set_cover(self, hypothesis: Automaton, paths: list) -> list: + """ + Greedily selects a subset of paths that covers as many k-way transitions as possible. + + :param Automaton hypothesis: Current hypothesis. + :param list paths: Candidate paths to choose from. + :return list: Selected subset of paths achieving (close to) full k-way transition coverage. + """ result = list() covered = set() step_count = 0 @@ -135,7 +152,14 @@ def greedy_set_cover(self, hypothesis: Automaton, paths: list): return result - def select_optimal_path(self, covered: set, paths: list) -> Path: + def select_optimal_path(self, covered: set, paths: list) -> Path | None: + """ + Selects the path that contributes the most new coverage among the candidates. + + :param set covered: Set of k-way transitions already covered. + :param list paths: Candidate paths to choose from. + :return Path | None: The path that adds the most new coverage, None if no path adds any. + """ result = None if self.optimize == 'steps': @@ -148,6 +172,12 @@ def select_optimal_path(self, covered: set, paths: list) -> Path: return result if len(result.kWayTransitions - covered) != 0 else None def generate_random_paths(self, hypothesis: Automaton) -> list: + """ + Generates a batch of random-length paths of random inputs. + + :param Automaton hypothesis: Current hypothesis. + :return list: List of generated Path instances. + """ result = list() for _ in range(self.num_generate_paths): @@ -158,13 +188,26 @@ def generate_random_paths(self, hypothesis: Automaton) -> list: return result - def generate_prefix_steps(self, hypothesis: Automaton): + def generate_prefix_steps(self, hypothesis: Automaton) -> Iterator[tuple]: + """ + Yields paths built from each state's prefix, followed by all k-length continuations, and a random walk. + + :param Automaton hypothesis: Current hypothesis. + :return Iterator[tuple]: Iterator of generated input sequences. + """ for state in reversed(hypothesis.states): prefix = state.prefix for steps in sorted(product(self.alphabet, repeat=self.k), key=lambda k: random()): yield prefix + steps + tuple(choices(self.alphabet, k=self.random_walk_len)) def create_path(self, hypothesis: Automaton, steps: tuple) -> Path: + """ + Executes the given steps on the hypothesis and records the k-way transitions it covers. + + :param Automaton hypothesis: Current hypothesis. + :param tuple steps: Input sequence to execute on the hypothesis. + :return Path: The path with its start/end states, steps, and covered k-way transitions. + """ transitions = set() transitions_log = list() @@ -190,7 +233,14 @@ def create_path(self, hypothesis: Automaton, steps: tuple) -> Path: return Path(hypothesis.initial_state, end_states[-1], steps, transitions, transitions_log) - def check_path(self, hypothesis: Automaton, steps: tuple): + def check_path(self, hypothesis: Automaton, steps: tuple) -> tuple | None: + """ + Executes an input sequence step by step on both SUL and hypothesis, checking for output divergence. + + :param Automaton hypothesis: Current hypothesis. + :param tuple steps: Input sequence to execute. + :return tuple | None: Counterexample inputs (prefix of steps), None if no counterexample is found. + """ self.reset_hyp_and_sul(hypothesis) for i, s in enumerate(steps): diff --git a/aalpy/oracles/PacOracle.py b/aalpy/oracles/PacOracle.py index 9519ccfc4e7..f84cd431549 100644 --- a/aalpy/oracles/PacOracle.py +++ b/aalpy/oracles/PacOracle.py @@ -1,7 +1,9 @@ +# Probably approximately correct (PAC) equivalence oracle. from math import ceil, log from random import choice, randint from aalpy.base import Oracle, SUL +from aalpy.base.Automaton import Automaton class PacOracle(Oracle): @@ -13,8 +15,18 @@ class PacOracle(Oracle): Queries are of random length in a predefined range. """ - def __init__(self, alphabet: list, sul: SUL, epsilon=0.01, delta=0.01, min_walk_len=10, max_walk_len=25): + def __init__(self, alphabet: list, sul: SUL, epsilon: float = 0.01, delta: float = 0.01, + min_walk_len: int = 10, max_walk_len: int = 25) -> None: + """ + Constructs the oracle. + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param float epsilon: Generalization error. + :param float delta: Confidence. + :param int min_walk_len: Minimum length of each random query. + :param int max_walk_len: Maximum length of each random query. + """ super().__init__(alphabet, sul) self.min_walk_len = min_walk_len self.max_walk_len = max_walk_len @@ -22,7 +34,13 @@ def __init__(self, alphabet: list, sul: SUL, epsilon=0.01, delta=0.01, min_walk_ self.delta = delta self.round = 0 - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> list | None: + """ + Performs a number of random-length queries, growing per round, until a counterexample is found. + + :param Automaton hypothesis: Current hypothesis. + :return list | None: Counterexample inputs, None if no counterexample is found. + """ self.round += 1 num_test_cases = 1 / self.epsilon * (log(1 / self.delta) + self.round * log(2)) diff --git a/aalpy/oracles/PerfectKnowledgeEqOracle.py b/aalpy/oracles/PerfectKnowledgeEqOracle.py index b627a75bcfe..4252cd4e7f5 100644 --- a/aalpy/oracles/PerfectKnowledgeEqOracle.py +++ b/aalpy/oracles/PerfectKnowledgeEqOracle.py @@ -1,3 +1,4 @@ +# Equivalence oracle that computes exact counterexamples via bisimilarity checking against the true model. from aalpy.base import Oracle, SUL, DeterministicAutomaton from aalpy.utils import bisimilar @@ -7,9 +8,22 @@ class PerfectKnowledgeEqOracle(Oracle): Oracle that can be used when developing and testing deterministic learning algorithms, so that the focus is put off equivalence query. """ - def __init__(self, alphabet: list, sul: SUL, model_under_learning: DeterministicAutomaton): + def __init__(self, alphabet: list, sul: SUL, model_under_learning: DeterministicAutomaton) -> None: + """ + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param DeterministicAutomaton model_under_learning: The ground-truth model to compare hypotheses against. + """ super().__init__(alphabet, sul, ) self.model_under_learning = model_under_learning - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: DeterministicAutomaton) -> tuple | None: + """ + Checks bisimilarity between the hypothesis and the ground-truth model. + + :param DeterministicAutomaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ return bisimilar(hypothesis, self.model_under_learning, return_cex=True) diff --git a/aalpy/oracles/ProvidedSequencesOracleWrapper.py b/aalpy/oracles/ProvidedSequencesOracleWrapper.py index e724f7f47f8..518e02acb84 100644 --- a/aalpy/oracles/ProvidedSequencesOracleWrapper.py +++ b/aalpy/oracles/ProvidedSequencesOracleWrapper.py @@ -1,24 +1,35 @@ +# Equivalence oracle wrapper that first replays user-provided candidate counterexamples before delegating. from aalpy.base import Oracle, SUL +from aalpy.base.Automaton import Automaton class ProvidedSequencesOracleWrapper(Oracle): - def __init__(self, alphabet: list, sul: SUL, oracle: Oracle, provided_counterexamples: list): + """ + Oracle wrapper which first executes provided sequences (possible counterexamples) and then switches to another + oracle instance. + """ + + def __init__(self, alphabet: list, sul: SUL, oracle: Oracle, provided_counterexamples: list) -> None: """ - Oracle wrapper which first executes provided sequences (possible counterexamples) and then switches to another - oracle instance. - - Args: - alphabet: input alphabet - sul: system under learning - oracle: oracle which will be used once all provided counterexamples are used - provided_counterexamples: list of input sequance lists. eg [[1,2,3], [2,3,1], ...] where 1,2,3 are elements - of input alphabet + Constructs the oracle wrapper. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param Oracle oracle: Oracle which will be used once all provided counterexamples are used. + :param list provided_counterexamples: List of input sequence lists, e.g. [[1,2,3], [2,3,1], ...] where + 1,2,3 are elements of the input alphabet. """ super().__init__(alphabet, sul) self.provided_counterexamples = provided_counterexamples self.oracle = oracle - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | list | None: + """ + Replays the remaining provided counterexamples, then delegates to the wrapped oracle. + + :param Automaton hypothesis: Current hypothesis. + :return tuple | list | None: Counterexample inputs, None if no counterexample is found. + """ for provided_cex in self.provided_counterexamples.copy(): inputs = [] self.reset_hyp_and_sul(hypothesis) diff --git a/aalpy/oracles/RandomWalkEqOracle.py b/aalpy/oracles/RandomWalkEqOracle.py index a190e10aa9a..3c2b0c28ea6 100644 --- a/aalpy/oracles/RandomWalkEqOracle.py +++ b/aalpy/oracles/RandomWalkEqOracle.py @@ -1,7 +1,9 @@ +# Equivalence oracle that performs random walks with a per-step reset probability. import random from aalpy.automata import Onfsm, Mdp, StochasticMealyMachine from aalpy.base import Oracle, SUL +from aalpy.base.Automaton import Automaton automaton_dict = {Onfsm: 'onfsm', Mdp: 'mdp', StochasticMealyMachine: 'smm'} @@ -12,20 +14,17 @@ class RandomWalkEqOracle(Oracle): that the system will reset and a new query asked. """ - def __init__(self, alphabet: list, sul: SUL, num_steps=5000, reset_after_cex=True, reset_prob=0.09): + def __init__(self, alphabet: list, sul: SUL, num_steps: int = 5000, reset_after_cex: bool = True, + reset_prob: float = 0.09) -> None: """ - - Args: - alphabet: input alphabet - - sul: system under learning - - num_steps: number of steps to be preformed - - reset_after_cex: if true, num_steps will be preformed after every counter example, else the total number - or steps will equal to num_steps - - reset_prob: probability that the new query will be asked + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int num_steps: Number of steps to be performed. + :param bool reset_after_cex: If true, num_steps will be performed after every counterexample, else the + total number of steps will equal num_steps. + :param float reset_prob: Probability that a new query will be asked after each step. """ super().__init__(alphabet, sul) @@ -35,7 +34,14 @@ def __init__(self, alphabet: list, sul: SUL, num_steps=5000, reset_after_cex=Tru self.random_steps_done = 0 self.automata_type = None - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | list | None: + """ + Performs a random walk, resetting probabilistically, until a counterexample is found or the step limit + is reached. + + :param Automaton hypothesis: Current hypothesis. + :return tuple | list | None: Counterexample inputs, None if no counterexample is found. + """ if not self.automata_type: self.automata_type = automaton_dict.get(type(hypothesis), 'det') @@ -85,6 +91,9 @@ def find_cex(self, hypothesis): return None - def reset_counter(self): + def reset_counter(self) -> None: + """ + Resets the count of random steps performed since the last reset/counterexample. + """ if self.reset_after_cex: self.random_steps_done = 0 \ No newline at end of file diff --git a/aalpy/oracles/RandomWordEqOracle.py b/aalpy/oracles/RandomWordEqOracle.py index f401d88db8e..0e3610b3fa4 100644 --- a/aalpy/oracles/RandomWordEqOracle.py +++ b/aalpy/oracles/RandomWordEqOracle.py @@ -1,7 +1,9 @@ +# Equivalence oracle that performs full-reset random walks of random length in a predefined range. from statistics import mean from aalpy.automata import Onfsm, Mdp, StochasticMealyMachine from aalpy.base import Oracle, SUL +from aalpy.base.Automaton import Automaton from random import randint, choice automaton_dict = {Onfsm: 'onfsm', Mdp: 'mdp', StochasticMealyMachine: 'smm'} @@ -12,22 +14,18 @@ class RandomWordEqOracle(Oracle): Equivalence oracle where queries are of random length in a predefined range. """ - def __init__(self, alphabet: list, sul: SUL, num_walks=500, min_walk_len=10, max_walk_len=30, - reset_after_cex=True): + def __init__(self, alphabet: list, sul: SUL, num_walks: int = 500, min_walk_len: int = 10, + max_walk_len: int = 30, reset_after_cex: bool = True) -> None: """ - Args: - alphabet: input alphabet - - sul: system under learning - - num_walks: number of walks to perform during search for cex - - min_walk_len: minimum length of each walk - - max_walk_len: maximum length of each walk - - reset_after_cex: if True, num_walks will be preformed after every counter example, else the total number - or walks will equal to num_walks + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int num_walks: Number of walks to perform during search for a counterexample. + :param int min_walk_len: Minimum length of each walk. + :param int max_walk_len: Maximum length of each walk. + :param bool reset_after_cex: If True, num_walks will be performed after every counterexample, else the + total number of walks will equal num_walks. """ super().__init__(alphabet, sul) @@ -40,7 +38,14 @@ def __init__(self, alphabet: list, sul: SUL, num_walks=500, min_walk_len=10, max self.walk_lengths = [randint(min_walk_len, max_walk_len) for _ in range(num_walks)] - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | list | None: + """ + Performs random-length walks from the initial state until a counterexample is found or num_walks is + reached. + + :param Automaton hypothesis: Current hypothesis. + :return tuple | list | None: Counterexample inputs, None if no counterexample is found. + """ if not self.automata_type: self.automata_type = automaton_dict.get(type(hypothesis), 'det') @@ -94,6 +99,9 @@ def find_cex(self, hypothesis): return None - def reset_counter(self): + def reset_counter(self) -> None: + """ + Resets the count of walks performed since the last reset/counterexample. + """ if self.reset_after_cex: self.num_walks_done = 0 diff --git a/aalpy/oracles/StatePrefixEqOracle.py b/aalpy/oracles/StatePrefixEqOracle.py index bb78794dd83..4fb088eceee 100644 --- a/aalpy/oracles/StatePrefixEqOracle.py +++ b/aalpy/oracles/StatePrefixEqOracle.py @@ -1,7 +1,9 @@ +# Equivalence oracle that starts guided random walks from every state of the hypothesis. import random from aalpy.base.Oracle import Oracle from aalpy.base.SUL import SUL +from aalpy.base.Automaton import Automaton class StatePrefixEqOracle(Oracle): @@ -12,21 +14,17 @@ class StatePrefixEqOracle(Oracle): rand_walk_len exactly walk_per_state times during learning. Therefore excessive testing of initial states is avoided. """ - def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12, max_tests=None, depth_first=True): + def __init__(self, alphabet: list, sul: SUL, walks_per_state: int = 25, walk_len: int = 12, + max_tests: int | None = None, depth_first: bool = True) -> None: """ - Args: - - alphabet: input alphabet - - sul: system under learning - - walks_per_state:individual walks per state of the automaton over the whole learning process - - walk_len:length of random walk - - max_tests:number of maximum tests. If set to None, this parameter will be ignored. - - depth_first:first explore the newest states + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int walks_per_state: Individual walks per state of the automaton over the whole learning process. + :param int walk_len: Length of random walk. + :param int | None max_tests: Number of maximum tests. If set to None, this parameter will be ignored. + :param bool depth_first: First explore the newest states. """ super().__init__(alphabet, sul) @@ -37,7 +35,13 @@ def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12, ma self.freq_dict = dict() - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | None: + """ + Starts random walks from every state that still needs coverage until a counterexample is found. + + :param Automaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ states_to_cover = [] for state in hypothesis.states: if state.prefix is None: diff --git a/aalpy/oracles/TransitionFocusOracle.py b/aalpy/oracles/TransitionFocusOracle.py index 6fca855aebc..c1f2f4e4939 100644 --- a/aalpy/oracles/TransitionFocusOracle.py +++ b/aalpy/oracles/TransitionFocusOracle.py @@ -1,7 +1,9 @@ +# Equivalence oracle that biases random walks towards same-state or different-state transitions. import random from aalpy.base.Oracle import Oracle from aalpy.base.SUL import SUL +from aalpy.base.Automaton import Automaton class TransitionFocusOracle(Oracle): @@ -11,14 +13,16 @@ class TransitionFocusOracle(Oracle): all interesting behavior occurs on the transitions between states and potential bugs can be found only by focusing on transitions. """ - def __init__(self, alphabet, sul: SUL, num_random_walks=500, walk_len=20, same_state_prob=0.2): + def __init__(self, alphabet: list, sul: SUL, num_random_walks: int = 500, walk_len: int = 20, + same_state_prob: float = 0.2) -> None: """ - Args: - alphabet: input alphabet - sul: system under learning - num_random_walks: number of walks - walk_len: length of each walk - same_state_prob: probability that the next input will lead to same state transition + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int num_random_walks: Number of walks. + :param int walk_len: Length of each walk. + :param float same_state_prob: Probability that the next input will lead to a same-state transition. """ super().__init__(alphabet, sul) @@ -26,8 +30,14 @@ def __init__(self, alphabet, sul: SUL, num_random_walks=500, walk_len=20, same_s self.steps_per_walk = walk_len self.same_state_prob = same_state_prob - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> list | None: + """ + Performs random walks biased towards same-state or different-state transitions until a counterexample is + found. + :param Automaton hypothesis: Current hypothesis. + :return list | None: Counterexample inputs, None if no counterexample is found. + """ for _ in range(self.num_walks): self.reset_hyp_and_sul(hypothesis) diff --git a/aalpy/oracles/UserInputEqOracle.py b/aalpy/oracles/UserInputEqOracle.py index df2826d5db5..e8a471c3072 100644 --- a/aalpy/oracles/UserInputEqOracle.py +++ b/aalpy/oracles/UserInputEqOracle.py @@ -1,4 +1,6 @@ +# Interactive equivalence oracle driven by user input at the command line. from aalpy.base import Oracle, SUL +from aalpy.base.Automaton import Automaton from aalpy.utils.FileHandler import visualize_automaton @@ -22,12 +24,24 @@ class UserInputEqOracle(Oracle): reset - resets the current state of the hypothesis and clears inputs """ - def __init__(self, alphabet: list, sul: SUL): + def __init__(self, alphabet: list, sul: SUL) -> None: + """ + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + """ super().__init__(alphabet, sul) self.curr_hypothesis = 0 - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> list | None: + """ + Visualizes the current hypothesis and lets the user interactively step through it to build or reject a + counterexample. + :param Automaton hypothesis: Current hypothesis. + :return list | None: Counterexample inputs, None if the user indicates no counterexample exists. + """ self.reset_hyp_and_sul(hypothesis) self.curr_hypothesis += 1 diff --git a/aalpy/oracles/WMethodEqOracle.py b/aalpy/oracles/WMethodEqOracle.py index b629d637bc6..fac756c38a1 100644 --- a/aalpy/oracles/WMethodEqOracle.py +++ b/aalpy/oracles/WMethodEqOracle.py @@ -1,7 +1,10 @@ +# Equivalence oracles based on the W-method and its randomized variant. +from collections.abc import Iterator from random import shuffle, choice, randint from aalpy.base.Oracle import Oracle from aalpy.base.SUL import SUL +from aalpy.base.Automaton import Automaton from itertools import product @@ -11,29 +14,28 @@ class WMethodEqOracle(Oracle): finite-state machines'. """ - def __init__(self, alphabet: list, sul: SUL, max_number_of_states): + def __init__(self, alphabet: list, sul: SUL, max_number_of_states: int) -> None: """ - Args: + Constructs the oracle. - alphabet: input alphabet - sul: system under learning - max_number_of_states: maximum number of states in the automaton + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int max_number_of_states: Maximum number of states in the automaton. """ super().__init__(alphabet, sul) self.m = max_number_of_states self.cache = set() - def test_suite(self, cover, depth, char_set): + def test_suite(self, cover: list, depth: int, char_set: list) -> Iterator[tuple]: """ - Construct the test suite for the W Method using - the provided state cover and characterization set, + Constructs the test suite for the W Method using the provided state cover and characterization set, exploring up to a given depth. - Args: - cover: list of states to cover - depth: maximum length of middle part - char_set: characterization set + :param list cover: List of states to cover. + :param int depth: Maximum length of middle part. + :param list char_set: Characterization set. + :return Iterator[tuple]: Iterator of generated test sequences. """ # fix the length of the middle part per loop # to avoid generating large sequences early on @@ -44,8 +46,13 @@ def test_suite(self, cover, depth, char_set): for (s, c) in product(cover, char_set): yield s + m + c - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | None: + """ + Runs the W-method test suite against the SUL until a counterexample is found. + :param Automaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ if not hypothesis.characterization_set: hypothesis.characterization_set = hypothesis.compute_characterization_set() @@ -85,17 +92,14 @@ class RandomWMethodEqOracle(Oracle): walk an element from the characterization set is added to the test case. """ - def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12): + def __init__(self, alphabet: list, sul: SUL, walks_per_state: int = 25, walk_len: int = 12) -> None: """ - Args: - - alphabet: input alphabet - - sul: system under learning + Constructs the oracle. - walks_per_state: number of random walks that should start from each state - - walk_len: length of random walk + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int walks_per_state: Number of random walks that should start from each state. + :param int walk_len: Length of random walk. """ super().__init__(alphabet, sul) @@ -103,8 +107,14 @@ def __init__(self, alphabet: list, sul: SUL, walks_per_state=25, walk_len=12): self.random_walk_len = walk_len self.freq_dict = dict() - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | None: + """ + Performs random walks from each state, ending with a characterizing suffix, until a counterexample is + found. + :param Automaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ if not hypothesis.characterization_set: hypothesis.characterization_set = hypothesis.compute_characterization_set() # fix for non-minimal intermediate hypothesis that can occur in KV diff --git a/aalpy/oracles/WpMethodEqOracle.py b/aalpy/oracles/WpMethodEqOracle.py index b3c0ac453a3..89e921bec41 100644 --- a/aalpy/oracles/WpMethodEqOracle.py +++ b/aalpy/oracles/WpMethodEqOracle.py @@ -1,16 +1,21 @@ +# Equivalence oracles based on the Wp-method and its randomized variant. import random +from collections.abc import Iterator + from aalpy.base.Oracle import Oracle from aalpy.base.SUL import SUL +from aalpy.base.Automaton import Automaton, AutomatonState from itertools import chain, product -def state_characterization_set(hypothesis, alphabet, state): +def state_characterization_set(hypothesis: Automaton, alphabet: list, state: AutomatonState) -> list[tuple]: """ - Return a list of sequences that distinguish the given state from all other states in the hypothesis. - Args: - hypothesis: hypothesis automaton - alphabet: input alphabet - state: state for which to find distinguishing sequences + Returns a list of sequences that distinguish the given state from all other states in the hypothesis. + + :param Automaton hypothesis: Hypothesis automaton. + :param list alphabet: Input alphabet. + :param AutomatonState state: State for which to find distinguishing sequences. + :return list[tuple]: List of distinguishing sequences. """ result = [] for i in range(len(hypothesis.states)): @@ -22,14 +27,15 @@ def state_characterization_set(hypothesis, alphabet, state): return result -def first_phase_it(alphabet, state_cover, depth, char_set): +def first_phase_it(alphabet: list, state_cover: set, depth: int, char_set: list) -> Iterator[tuple]: """ - Return an iterator that generates all possible sequences for the first phase of the Wp-method. - Args: - alphabet: input alphabet - state_cover: list of states to cover - depth: maximum length of middle part - char_set: characterization set + Returns an iterator that generates all possible sequences for the first phase of the Wp-method. + + :param list alphabet: Input alphabet. + :param set state_cover: Set of state prefixes to cover. + :param int depth: Maximum length of middle part. + :param list char_set: Characterization set. + :return Iterator[tuple]: Iterator of generated test sequences. """ char_set = char_set or [()] for d in range(depth): @@ -40,14 +46,15 @@ def first_phase_it(alphabet, state_cover, depth, char_set): yield s + m + c -def second_phase_it(hyp, alphabet, difference, depth): +def second_phase_it(hyp: Automaton, alphabet: list, difference: set, depth: int) -> Iterator[tuple]: """ - Return an iterator that generates all possible sequences for the second phase of the Wp-method. - Args: - hyp: hypothesis automaton - alphabet: input alphabet - difference: set of sequences that are in the transition cover but not in the state cover - depth: maximum length of middle part + Returns an iterator that generates all possible sequences for the second phase of the Wp-method. + + :param Automaton hyp: Hypothesis automaton. + :param list alphabet: Input alphabet. + :param set difference: Set of sequences that are in the transition cover but not in the state cover. + :param int depth: Maximum length of middle part. + :return Iterator[tuple]: Iterator of generated test sequences. """ state_mapping = {} for d in range(depth): @@ -68,12 +75,25 @@ class WpMethodEqOracle(Oracle): Implements the Wp-method equivalence oracle. """ - def __init__(self, alphabet: list, sul: SUL, max_number_of_states=4): + def __init__(self, alphabet: list, sul: SUL, max_number_of_states: int = 4) -> None: + """ + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int max_number_of_states: Maximum number of states in the automaton. + """ super().__init__(alphabet, sul) self.m = max_number_of_states self.cache = set() - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | None: + """ + Runs the Wp-method test suite (first and second phase) against the SUL until a counterexample is found. + + :param Automaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ if not hypothesis.characterization_set: hypothesis.characterization_set = hypothesis.compute_characterization_set() @@ -124,13 +144,29 @@ class RandomWpMethodEqOracle(Oracle): """ def __init__( - self, alphabet: list, sul: SUL, min_length=1, expected_length=10, num_tests=1000,): + self, alphabet: list, sul: SUL, min_length: int = 1, expected_length: int = 10, + num_tests: int = 1000,) -> None: + """ + Constructs the oracle. + + :param list alphabet: Input alphabet. + :param SUL sul: System under learning. + :param int min_length: Minimum length of the random middle part. + :param int expected_length: Expected length of the random middle part (geometric distribution parameter). + :param int num_tests: Number of random tests to perform. + """ super().__init__(alphabet, sul) self.min_length = min_length self.expected_length = expected_length self.bound = num_tests - def find_cex(self, hypothesis): + def find_cex(self, hypothesis: Automaton) -> tuple | None: + """ + Samples random tests (prefix + random word + suffix) until a counterexample is found. + + :param Automaton hypothesis: Current hypothesis. + :return tuple | None: Counterexample inputs, None if no counterexample is found. + """ # fix for non-minimal intermediate hypothesis that can occur in KV hypothesis.characterization_set = hypothesis.compute_characterization_set() if not hypothesis.characterization_set: diff --git a/aalpy/oracles/__init__.py b/aalpy/oracles/__init__.py index fc2bbe6b7ea..4d167a6d91b 100644 --- a/aalpy/oracles/__init__.py +++ b/aalpy/oracles/__init__.py @@ -1,3 +1,4 @@ +# Exposes all built-in equivalence oracle implementations. from .BreadthFirstExplorationEqOracle import BreadthFirstExplorationEqOracle from .CacheBasedEqOracle import CacheBasedEqOracle from .KWayStateCoverageEqOracle import KWayStateCoverageEqOracle diff --git a/aalpy/utils/AutomatonGenerators.py b/aalpy/utils/AutomatonGenerators.py index 2d975b69e98..3fc91192bcd 100644 --- a/aalpy/utils/AutomatonGenerators.py +++ b/aalpy/utils/AutomatonGenerators.py @@ -1,36 +1,34 @@ +# Random generators for deterministic, stochastic, non-deterministic and visibly pushdown automata. import random import warnings +from typing import Any from aalpy.automata import Dfa, DfaState, MdpState, Mdp, MealyMachine, MealyState, \ MooreMachine, MooreState, OnfsmState, Onfsm, MarkovChain, McState, StochasticMealyState, StochasticMealyMachine, \ Sevpa, SevpaState, SevpaAlphabet, SevpaTransition -def generate_random_deterministic_automata(automaton_type, - num_states, - input_alphabet_size, - output_alphabet_size=None, - ensure_minimality=True, - **kwargs - ): +def generate_random_deterministic_automata(automaton_type: str, + num_states: int, + input_alphabet_size: int, + output_alphabet_size: int | None = None, + ensure_minimality: bool = True, + **kwargs: Any + ) -> Dfa | MealyMachine | MooreMachine: """ Generates a random deterministic automata of 'automaton_type'. - Args: - automaton_type: type of automaton, either 'dfa', 'mealy', or 'moore' - num_states: number of states - input_alphabet_size: size of input alphabet - output_alphabet_size: size of output alphabet. (ignored for DFAs) - ensure_minimality: ensure that the automaton is minimal - **kwargs: - : 'num_accepting_states' number of accepting states for DFA generation. If not defined, half of states will - be accepting - - Returns: - - Random deterministic automaton of user defined type, size. If ensure_minimality is set to False returned - automaton is not necessarily minimal. If minimality is reacquired and random automaton cannot be produced in - multiple interactions, non-minimal automaton will be returned and a warning message printed. + :param str automaton_type: type of automaton, either 'dfa', 'mealy', or 'moore' + :param int num_states: number of states + :param int input_alphabet_size: size of input alphabet + :param int | None output_alphabet_size: size of output alphabet (ignored for DFAs) + :param bool ensure_minimality: ensure that the automaton is minimal + :param Any kwargs: 'num_accepting_states' number of accepting states for DFA generation (if not defined, half + of states will be accepting), 'custom_input_alphabet', 'custom_output_alphabet' + :return Dfa | MealyMachine | MooreMachine: Random deterministic automaton of user defined type, size. If + ensure_minimality is set to False returned automaton is not necessarily minimal. If minimality is reacquired + and random automaton cannot be produced in multiple interactions, non-minimal automaton will be returned and + a warning message printed. """ assert automaton_type in {'dfa', 'mealy', 'moore'} @@ -143,22 +141,17 @@ def generate_random_deterministic_automata(automaton_type, return random_automaton -def generate_random_mealy_machine(num_states, input_alphabet, output_alphabet, - compute_prefixes=False, ensure_minimality=True) -> MealyMachine: +def generate_random_mealy_machine(num_states: int, input_alphabet: list, output_alphabet: list, + compute_prefixes: bool = False, ensure_minimality: bool = True) -> MealyMachine: """ Generates a random Mealy machine. Kept for backwards compatibility. - Args: - - num_states: number of states - input_alphabet: input alphabet - output_alphabet: output alphabet - compute_prefixes: if true, shortest path to reach each state will be computed (Default value = False) - ensure_minimality: returned automaton will be minimal - - Returns: - - Mealy machine with num_states states + :param int num_states: number of states + :param list input_alphabet: input alphabet + :param list output_alphabet: output alphabet + :param bool compute_prefixes: if true, shortest path to reach each state will be computed + :param bool ensure_minimality: returned automaton will be minimal + :return MealyMachine: Mealy machine with num_states states """ random_mealy_machine = generate_random_deterministic_automata('mealy', num_states, @@ -172,23 +165,17 @@ def generate_random_mealy_machine(num_states, input_alphabet, output_alphabet, return random_mealy_machine -def generate_random_moore_machine(num_states, input_alphabet, output_alphabet, - compute_prefixes=False, ensure_minimality=True) -> MooreMachine: +def generate_random_moore_machine(num_states: int, input_alphabet: list, output_alphabet: list, + compute_prefixes: bool = False, ensure_minimality: bool = True) -> MooreMachine: """ Generates a random Moore machine. - Args: - - num_states: number of states - input_alphabet: input alphabet - output_alphabet: output alphabet - compute_prefixes: if true, shortest path to reach each state will be computed (Default value = False) - ensure_minimality: returned automaton will be minimal - - Returns: - - Random Moore machine with num_states states - + :param int num_states: number of states + :param list input_alphabet: input alphabet + :param list output_alphabet: output alphabet + :param bool compute_prefixes: if true, shortest path to reach each state will be computed + :param bool ensure_minimality: returned automaton will be minimal + :return MooreMachine: Random Moore machine with num_states states """ random_moore_machine = generate_random_deterministic_automata('moore', num_states, input_alphabet_size=len(input_alphabet), @@ -201,23 +188,17 @@ def generate_random_moore_machine(num_states, input_alphabet, output_alphabet, return random_moore_machine -def generate_random_dfa(num_states, alphabet, num_accepting_states=1, - compute_prefixes=False, ensure_minimality=True) -> Dfa: +def generate_random_dfa(num_states: int, alphabet: list, num_accepting_states: int = 1, + compute_prefixes: bool = False, ensure_minimality: bool = True) -> Dfa: """ Generates a random DFA. - Args: - - num_states: number of states - alphabet: input alphabet - num_accepting_states: number of accepting states (Default value = 1) - compute_prefixes: if true, shortest path to reach each state will be computed (Default value = False) - ensure_minimality: returned automaton will be minimal - - Returns: - - Randomly generated DFA - + :param int num_states: number of states + :param list alphabet: input alphabet + :param int num_accepting_states: number of accepting states + :param bool compute_prefixes: if true, shortest path to reach each state will be computed + :param bool ensure_minimality: returned automaton will be minimal + :return Dfa: Randomly generated DFA """ if num_states <= num_accepting_states: num_accepting_states = num_states // 2 @@ -233,21 +214,16 @@ def generate_random_dfa(num_states, alphabet, num_accepting_states=1, return random_dfa -def generate_random_mdp(num_states, input_size, output_size, possible_probabilities=None): +def generate_random_mdp(num_states: int, input_size: int, output_size: int, + possible_probabilities: list[tuple] | None = None) -> Mdp: """ Generates random MDP. - Args: - - num_states: number of states - input_size: number of inputs - output_size: user predefined outputs - possible_probabilities: list of possible probability pairs to choose from - - Returns: - - random MDP - + :param int num_states: number of states + :param int input_size: number of inputs + :param int output_size: user predefined outputs + :param list[tuple] | None possible_probabilities: list of possible probability pairs to choose from + :return Mdp: random MDP """ deterministic_model = generate_random_deterministic_automata('moore', num_states, input_size, output_size) @@ -296,21 +272,16 @@ def generate_random_mdp(num_states, input_size, output_size, possible_probabilit return Mdp(mdp_states[0], mdp_states) -def generate_random_smm(num_states, input_size, output_size, possible_probabilities=None): +def generate_random_smm(num_states: int, input_size: int, output_size: int, + possible_probabilities: list[tuple] | None = None) -> StochasticMealyMachine: """ Generates random SMM. - Args: - - num_states: number of states - input_size: number of inputs - output_size: number of outputs - possible_probabilities: list of possible probability pairs to choose from - - Returns: - - random SMM - + :param int num_states: number of states + :param int input_size: number of inputs + :param int output_size: number of outputs + :param list[tuple] | None possible_probabilities: list of possible probability pairs to choose from + :return StochasticMealyMachine: random SMM """ deterministic_model = generate_random_deterministic_automata('mealy', num_states, input_size, output_size) @@ -357,21 +328,16 @@ def generate_random_smm(num_states, input_size, output_size, possible_probabilit return StochasticMealyMachine(smm_states[0], smm_states) -def generate_random_ONFSM(num_states, num_inputs, num_outputs, multiple_out_prob=0.33): +def generate_random_ONFSM(num_states: int, num_inputs: int, num_outputs: int, + multiple_out_prob: float = 0.33) -> Onfsm: """ Randomly generate an observable non-deterministic finite-state machine. - Args: - - num_states: number of states - num_inputs: number of inputs - num_outputs: number of outputs - multiple_out_prob: probability that state will have multiple outputs (Default value = 0.5) - - Returns: - - randomly generated ONFSM - + :param int num_states: number of states + :param int num_inputs: number of inputs + :param int num_outputs: number of outputs + :param float multiple_out_prob: probability that state will have multiple outputs + :return Onfsm: randomly generated ONFSM """ inputs = [f'i{i + 1}' for i in range(num_inputs)] outputs = [f'o{i + 1}' for i in range(num_outputs)] @@ -401,7 +367,13 @@ def generate_random_ONFSM(num_states, num_inputs, num_outputs, multiple_out_prob return Onfsm(states[0], states) -def generate_random_markov_chain(num_states): +def generate_random_markov_chain(num_states: int) -> MarkovChain: + """ + Generates a random Markov chain. + + :param int num_states: number of states + :return MarkovChain: randomly generated Markov chain + """ assert num_states >= 3 possible_probabilities = [1.0, 1.0, 0.8, 0.5, 0.9] states = [] @@ -426,7 +398,15 @@ def generate_random_markov_chain(num_states): return MarkovChain(states[0], states) -def _has_transition(state: SevpaState, transition_letter, stack_guard) -> bool: +def _has_transition(state: SevpaState, transition_letter: Any, stack_guard: tuple | None) -> bool: + """ + Checks whether a SEVPA state already has a transition matching the given letter (and stack guard). + + :param SevpaState state: state to check + :param Any transition_letter: input letter of the transition + :param tuple | None stack_guard: stack guard of the transition, None for internal transitions + :return bool: True if a matching transition already exists, False otherwise + """ transitions = state.transitions[transition_letter] if transitions is not None: if stack_guard is None: # internal transition @@ -441,21 +421,19 @@ def _has_transition(state: SevpaState, transition_letter, stack_guard) -> bool: return False -def generate_random_sevpa(num_states, internal_alphabet_size, call_alphabet_size, return_alphabet_size - , acceptance_prob, return_transition_prob): +def generate_random_sevpa(num_states: int, internal_alphabet_size: int, call_alphabet_size: int, + return_alphabet_size: int, acceptance_prob: float, + return_transition_prob: float) -> Sevpa: """ Generate a random Single Entry Visibly Pushdown Automaton (SEVPA). - Args: - num_states (int): The number of states in the SEVPA. - internal_alphabet_size (int): The size of the internal alphabet. - call_alphabet_size (int): The size of the call alphabet. - return_alphabet_size (int): The size of the return alphabet. - acceptance_prob (float): The probability of a state being an accepting state. - return_transition_prob (float): The probability of generating a return transition. - - Returns: - Sevpa: A randomly generated SEVPA. + :param int num_states: The number of states in the SEVPA. + :param int internal_alphabet_size: The size of the internal alphabet. + :param int call_alphabet_size: The size of the call alphabet. + :param int return_alphabet_size: The size of the return alphabet. + :param float acceptance_prob: The probability of a state being an accepting state. + :param float return_transition_prob: The probability of generating a return transition. + :return Sevpa: A randomly generated SEVPA. """ internal_alphabet = [f'i{i}' for i in range(internal_alphabet_size)] diff --git a/aalpy/utils/BenchmarkSULs.py b/aalpy/utils/BenchmarkSULs.py index 6a812b8784a..393b5bc318b 100644 --- a/aalpy/utils/BenchmarkSULs.py +++ b/aalpy/utils/BenchmarkSULs.py @@ -1,4 +1,11 @@ -def get_Angluin_dfa(): +# Hand-crafted example automata and systems under learning used throughout benchmarks, examples and tests. +from typing import Any + + +def get_Angluin_dfa() -> 'Dfa': + """ + :return Dfa: The classical DFA example used by Angluin to illustrate L*. + """ from aalpy.automata import Dfa angluin_dfa = { @@ -11,10 +18,12 @@ def get_Angluin_dfa(): return Dfa.from_state_setup(angluin_dfa) -def get_benchmark_ONFSM(): +def get_benchmark_ONFSM() -> 'Onfsm': """ Returns ONFSM presented in 'Learning Finite State Models of Observable Nondeterministic Systems in a Testing Context'. + + :return Onfsm: The example ONFSM. """ from aalpy.automata import Onfsm, OnfsmState @@ -40,9 +49,11 @@ def get_benchmark_ONFSM(): return Onfsm(a, [a, b, c, d]) -def get_ONFSM(): +def get_ONFSM() -> 'Onfsm': """ Returns example of an ONFSM. + + :return Onfsm: The example ONFSM. """ from aalpy.automata import Onfsm, OnfsmState @@ -86,7 +97,10 @@ def get_ONFSM(): return Onfsm(q0, [q0, q1, q2, q3, q4, q5, q6, q7, q8]) -def get_faulty_coffee_machine_MDP(): +def get_faulty_coffee_machine_MDP() -> 'Mdp': + """ + :return Mdp: An MDP modeling a coffee machine that occasionally serves coffee without the beep step. + """ from aalpy.automata import Mdp, MdpState q0 = MdpState("q0", "init") @@ -106,7 +120,10 @@ def get_faulty_coffee_machine_MDP(): return mdp -def get_weird_coffee_machine_MDP(): +def get_weird_coffee_machine_MDP() -> 'Mdp': + """ + :return Mdp: An MDP modeling a coffee machine with an alternate 'koin' input path that can crash the machine. + """ from aalpy.automata import Mdp, MdpState q0 = MdpState("q0", "init") @@ -154,7 +171,11 @@ def get_weird_coffee_machine_MDP(): return mdp -def get_faulty_coffee_machine_SMM(): +def get_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine': + """ + :return StochasticMealyMachine: A stochastic Mealy machine modeling a coffee machine that occasionally serves + coffee without the beep step. + """ from aalpy.automata import StochasticMealyMachine, StochasticMealyState s0 = StochasticMealyState('q0') @@ -174,7 +195,10 @@ def get_faulty_coffee_machine_SMM(): return smm -def get_minimal_faulty_coffee_machine_SMM(): +def get_minimal_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine': + """ + :return StochasticMealyMachine: A minimal 2-state stochastic Mealy machine modeling the faulty coffee machine. + """ from aalpy.automata import StochasticMealyMachine, StochasticMealyState s0 = StochasticMealyState('q0') @@ -191,7 +215,10 @@ def get_minimal_faulty_coffee_machine_SMM(): return smm -def get_faulty_mqtt_SMM(): +def get_faulty_mqtt_SMM() -> 'StochasticMealyMachine': + """ + :return StochasticMealyMachine: A stochastic Mealy machine modeling a faulty MQTT broker. + """ from aalpy.automata import StochasticMealyMachine, StochasticMealyState s0 = StochasticMealyState('q0') @@ -223,7 +250,11 @@ def get_faulty_mqtt_SMM(): return smm -def get_small_gridworld(): +def get_small_gridworld() -> 'StochasticMealyMachine': + """ + :return StochasticMealyMachine: A stochastic Mealy machine modeling a small 2x2 gridworld with mud and grass + tiles. + """ from aalpy.automata import StochasticMealyMachine, StochasticMealyState s0 = StochasticMealyState('q0') @@ -274,12 +305,24 @@ def get_small_gridworld(): class MockMqttExample: + """ + Mock implementation of an MQTT broker's connect/publish/subscribe API, used as a system under learning. + """ - def __init__(self): + def __init__(self) -> None: + """ + Creates the mock broker in its initial, disconnected state. + """ self.state = 'CONCLOSED' self.topics = set() - def subscribe(self, topic: str): + def subscribe(self, topic: str) -> str: + """ + Subscribes to a topic, unless it contains a newline or a null character. + + :param str topic: Topic to subscribe to. + :return str: The resulting broker state. + """ if '\n' in topic or '\u0000' in topic: self.state = 'CONCLOSED' self.topics.clear() @@ -289,7 +332,13 @@ def subscribe(self, topic: str): return self.state - def unsubscribe(self, topic): + def unsubscribe(self, topic: str) -> str: + """ + Unsubscribes from a topic, unless it contains a newline or a null character. + + :param str topic: Topic to unsubscribe from. + :return str: The resulting broker state. + """ if '\n' in topic or '\u0000' in topic: self.state = 'CONCLOSED' self.topics.clear() @@ -300,7 +349,12 @@ def unsubscribe(self, topic): return self.state - def connect(self): + def connect(self) -> str: + """ + Connects the mock broker, or resets it if already connected. + + :return str: The resulting broker state. + """ if self.state == 'CONCLOSED': self.state = 'CONNACK' else: @@ -308,12 +362,23 @@ def connect(self): self.state = 'CONCLOSED' return self.state - def disconnect(self): + def disconnect(self) -> str: + """ + Disconnects the mock broker and clears its subscribed topics. + + :return str: The resulting broker state. + """ self.state = 'CONCLOSED' self.topics.clear() return self.state - def publish(self, topic): + def publish(self, topic: str) -> str: + """ + Publishes to a topic, unless it contains a newline or a null character. + + :param str topic: Topic to publish to. + :return str: The resulting broker state. + """ if '\n' in topic or '\u0000' in topic: self.state = 'CONCLOSED' self.topics.clear() @@ -332,7 +397,13 @@ class DateValidator: The format of the dates is %d/%m/%Y' """ - def is_date_accepted(self, date_string: str): + def is_date_accepted(self, date_string: str) -> bool: + """ + Checks whether a date string is accepted by the validator. + + :param str date_string: Date string in %d/%m/%Y format. + :return bool: True if the date is accepted, False otherwise. + """ values = date_string.split('/') if len(values) != 3: return False @@ -358,7 +429,10 @@ def is_date_accepted(self, date_string: str): return True -def get_small_pomdp(): +def get_small_pomdp() -> 'Mdp': + """ + :return Mdp: An MDP with partially observable states (a small POMDP-like example). + """ from aalpy.automata import Mdp, MdpState q0 = MdpState("q0", "init") @@ -387,7 +461,15 @@ def get_small_pomdp(): return Mdp(q0, [q0, q1, q2, q3, q4]) -def is_balanced(test_string, call_return_map, allow_empty_string): +def is_balanced(test_string: str, call_return_map: dict, allow_empty_string: bool) -> bool: + """ + Checks whether a string of call/return symbols is balanced with respect to a call-return mapping. + + :param str test_string: The string to check. + :param dict call_return_map: Map from call (opening) symbols to their matching return (closing) symbols. + :param bool allow_empty_string: Whether an empty string counts as balanced. + :return bool: True if the string is balanced, False otherwise. + """ stack = [] # Create a set of open and close characters for faster lookup open_chars = set(call_return_map.keys()) @@ -408,23 +490,52 @@ def is_balanced(test_string, call_return_map, allow_empty_string): return not stack if allow_empty_string else not stack and len(test_string) > 0 -def get_balanced_string_sul(call_return_map, allow_empty_string): +def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) -> 'SUL': + """ + Creates a SUL that accepts balanced strings of call/return symbols. + + :param dict call_return_map: Map from call (opening) symbols to their matching return (closing) symbols. + :param bool allow_empty_string: Whether an empty string counts as balanced. + :return SUL: The constructed SUL. + """ from aalpy.base import SUL class BalancedStringSUL(SUL): - def __init__(self, call_return_map, allow_empty_string): + """ + System under learning that checks whether the sequence of inputs seen so far is a balanced string. + """ + + def __init__(self, call_return_map: dict, allow_empty_string: bool) -> None: + """ + Creates a balanced-string SUL. + + :param dict call_return_map: Map from call (opening) symbols to their matching return (closing) symbols. + :param bool allow_empty_string: Whether an empty string counts as balanced. + """ super(BalancedStringSUL, self).__init__() self.call_return_map = call_return_map self.allow_empty_string = allow_empty_string self.sting_under_test = [] - def pre(self): + def pre(self) -> None: + """ + Resets the accumulated string under test. + """ self.sting_under_test = [] - def post(self): + def post(self) -> None: + """ + Performs no additional cleanup. + """ pass - def step(self, letter): + def step(self, letter: Any) -> bool: + """ + Appends the letter to the string under test and checks whether it is balanced. + + :param Any letter: Single element of the input alphabet. + :return bool: True if the accumulated string is balanced, False otherwise. + """ if letter: self.sting_under_test += letter return is_balanced(self.sting_under_test, self.call_return_map, self.allow_empty_string) diff --git a/aalpy/utils/BenchmarkSevpaModels.py b/aalpy/utils/BenchmarkSevpaModels.py index 8d2714e88fc..94630d3383d 100644 --- a/aalpy/utils/BenchmarkSevpaModels.py +++ b/aalpy/utils/BenchmarkSevpaModels.py @@ -1,8 +1,14 @@ +# Collection of example SEVPAs (Single-Entry Visibly Pushdown Automata) used for benchmarking learning algorithms. from aalpy.automata.Sevpa import Sevpa from aalpy.utils import load_automaton_from_file -def sevpa_for_L1(): +def sevpa_for_L1() -> Sevpa: + """ + Builds an example SEVPA for language L1. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'b': [('q1', 'pop', ('q0', 'a'))] }), @@ -12,7 +18,12 @@ def sevpa_for_L1(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L2(): +def sevpa_for_L2() -> Sevpa: + """ + Builds an example SEVPA for language L2. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'d': [('q1', 'pop', ('q0', 'a')), ('q1', 'pop', ('q0', 'b'))], 'c': [('q1', 'pop', ('q0', 'a')), ('q1', 'pop', ('q0', 'b'))] @@ -25,7 +36,12 @@ def sevpa_for_L2(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L3(): +def sevpa_for_L3() -> Sevpa: + """ + Builds an example SEVPA for language L3. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'g': [('q6', 'pop', ('q0', 'd')), ('q4', 'pop', ('q0', 'b'))], @@ -50,7 +66,12 @@ def sevpa_for_L3(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L4(): +def sevpa_for_L4() -> Sevpa: + """ + Builds an example SEVPA for language L4. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'c': [('q2', 'pop', ('q0', 'b'))] }), @@ -62,7 +83,12 @@ def sevpa_for_L4(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L5(): +def sevpa_for_L5() -> Sevpa: + """ + Builds an example SEVPA for language L5. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'d': [('q2', 'pop', ('q0', 'c'))] }), @@ -77,7 +103,12 @@ def sevpa_for_L5(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L7(): +def sevpa_for_L7() -> Sevpa: + """ + Builds an example SEVPA for language L7 (balanced parentheses and brackets). + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {')': [('q1', 'pop', ('q0', '(')), ('q1', 'pop', ('q1', '('))], @@ -95,7 +126,12 @@ def sevpa_for_L7(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L8(): +def sevpa_for_L8() -> Sevpa: + """ + Builds an example SEVPA for language L8 (balanced parentheses, braces and brackets). + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {')': [('q1', 'pop', ('q0', '(')), ('q1', 'pop', ('q1', '('))], @@ -115,7 +151,12 @@ def sevpa_for_L8(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L9(): +def sevpa_for_L9() -> Sevpa: + """ + Builds an example SEVPA for language L9 (balanced brackets, braces, parentheses and angle brackets). + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {']': [('q1', 'pop', ('q0', '[')), ('q1', 'pop', ('q1', '['))], @@ -140,7 +181,12 @@ def sevpa_for_L9(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L10(): +def sevpa_for_L10() -> Sevpa: + """ + Builds an example SEVPA for language L10. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { "q0": (False, {"b": [("qb", None, None)], }), @@ -171,7 +217,12 @@ def sevpa_for_L10(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L11(): +def sevpa_for_L11() -> Sevpa: + """ + Builds an example SEVPA for language L11. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'i1': [('q2', None, None)], 'r1': [('q3', 'pop', ('q0', 'c2')), @@ -204,7 +255,12 @@ def sevpa_for_L11(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L12(): +def sevpa_for_L12() -> Sevpa: + """ + Builds an example SEVPA for language L12. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {']': [('q1', 'pop', ('q0', '['))], ')': [('q1', 'pop', ('q0', '('))] @@ -217,7 +273,12 @@ def sevpa_for_L12(): return Sevpa.from_state_setup(state_setup, init_state_id="q0", ) -def sevpa_for_L13(): +def sevpa_for_L13() -> Sevpa: + """ + Builds an example SEVPA for language L13. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'c': [('q1', None, None)], 'b': [('q1', None, None)], @@ -236,7 +297,12 @@ def sevpa_for_L13(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L14(): +def sevpa_for_L14() -> Sevpa: + """ + Builds an example SEVPA for language L14. + + :return Sevpa: The constructed SEVPA. + """ state_setup = { 'q0': (False, {'a': [('q1', None, None)], 'b': [('q1', None, None)], @@ -259,7 +325,12 @@ def sevpa_for_L14(): return Sevpa.from_state_setup(state_setup, init_state_id="q0") -def sevpa_for_L15(): +def sevpa_for_L15() -> Sevpa: + """ + Builds an example SEVPA for language L15 (Dyck order 1). + + :return Sevpa: The constructed SEVPA. + """ # Dyck order 1 state_setup = { @@ -290,4 +361,3 @@ def sevpa_for_L15(): m = load_automaton_from_file('test.dot', automaton_type='vpa') print('Loaded') print(m) - diff --git a/aalpy/utils/BenchmarkVpaModels.py b/aalpy/utils/BenchmarkVpaModels.py index 62b8d701ed7..455d815a20a 100644 --- a/aalpy/utils/BenchmarkVpaModels.py +++ b/aalpy/utils/BenchmarkVpaModels.py @@ -1,9 +1,15 @@ +# Collection of example VPAs (Visibly Pushdown Automata) used for benchmarking learning algorithms. import random from aalpy.automata.Vpa import Vpa, VpaAlphabet -def vpa_L1(): +def vpa_L1() -> Vpa: + """ + Builds an example VPA for language L1. + + :return Vpa: The constructed VPA. + """ # we always ensure that n >= 1 call_set = ['a'] @@ -21,7 +27,12 @@ def vpa_L1(): return vpa -def vpa_L2(): +def vpa_L2() -> Vpa: + """ + Builds an example VPA for language L2. + + :return Vpa: The constructed VPA. + """ call_set = ['a', 'b'] return_set = ['c', 'd'] internal_set = [] @@ -40,7 +51,12 @@ def vpa_L2(): return vpa -def vpa_for_L3(): +def vpa_for_L3() -> Vpa: + """ + Builds an example VPA for language L3. + + :return Vpa: The constructed VPA. + """ call_set = ['a', 'c', 'b', 'd'] return_set = ['e', 'g', 'f', 'h'] internal_set = [] @@ -69,7 +85,12 @@ def vpa_for_L3(): return vpa -def vpa_L3(): +def vpa_L3() -> Vpa: + """ + Builds an example VPA for a variant of language L3. + + :return Vpa: The constructed VPA. + """ call_set = ['a', 'b'] return_set = ['c', 'd'] internal_set = [] @@ -90,7 +111,12 @@ def vpa_L3(): return vpa -def vpa_L4(): +def vpa_L4() -> Vpa: + """ + Builds an example VPA for language L4 (Dyck order 2). + + :return Vpa: The constructed VPA. + """ # Dyck order 2 call_set = ['(', '['] @@ -113,7 +139,12 @@ def vpa_L4(): return vpa -def vpa_L6(): +def vpa_L6() -> Vpa: + """ + Builds an example VPA for language L6 (Dyck order 3). + + :return Vpa: The constructed VPA. + """ # Dyck order 3 call_set = ['(', '[', '{'] @@ -139,7 +170,12 @@ def vpa_L6(): return vpa -def vpa_L8(): +def vpa_L8() -> Vpa: + """ + Builds an example VPA for language L8 (Dyck order 4). + + :return Vpa: The constructed VPA. + """ # Dyck order 4 call_set = ['(', '[', '{', '<'] @@ -168,7 +204,12 @@ def vpa_L8(): return vpa -def vpa_L9(): +def vpa_L9() -> Vpa: + """ + Builds an example VPA for language L9 (Dyck order 2, single-nested). + + :return Vpa: The constructed VPA. + """ # Dyck order 2 (single-nested) call_set = ['(', '['] @@ -194,7 +235,12 @@ def vpa_L9(): return vpa -def vpa_L10(): +def vpa_L10() -> Vpa: + """ + Builds an example VPA for language L10 (Dyck order 1 with internal symbols). + + :return Vpa: The constructed VPA. + """ # Dyck order 1 call_set = ['('] @@ -220,7 +266,12 @@ def vpa_L10(): return vpa -def vpa_L11(): +def vpa_L11() -> Vpa: + """ + Builds an example VPA for language L11 (Dyck order 2 with internal symbols). + + :return Vpa: The constructed VPA. + """ # Dyck order 2 call_set = ['(', '['] @@ -249,7 +300,12 @@ def vpa_L11(): return vpa -def vpa_L12(): +def vpa_L12() -> Vpa: + """ + Builds an example VPA for language L12 (Dyck order 1 with a multi-letter internal sequence). + + :return Vpa: The constructed VPA. + """ # Dyck order 1 call_set = ['('] @@ -277,7 +333,12 @@ def vpa_L12(): return vpa -def vpa_for_L16(): +def vpa_for_L16() -> Vpa: + """ + Builds an example VPA for a small testing language L16. + + :return Vpa: The constructed VPA. + """ # just a testing language call_set = ['a'] @@ -297,7 +358,13 @@ def vpa_for_L16(): return vpa -def vpa_for_odd_parentheses(): +def vpa_for_odd_parentheses() -> Vpa: + """ + Builds a VPA accepting only an odd number of fully balanced parentheses, e.g. () and ((()))), but + rejecting odd pairs or multiple groups. + + :return Vpa: The constructed VPA. + """ # VPA for accepting only odd number of fully balanced parentheses # It accepts patterns like () and ((()))), but rejects odd pairs or multiple groups @@ -324,7 +391,12 @@ def vpa_for_odd_parentheses(): return vpa -def vpa_for_even_parentheses(): +def vpa_for_even_parentheses() -> Vpa: + """ + Builds a VPA accepting only an even number of fully balanced parentheses. + + :return Vpa: The constructed VPA. + """ # VPA for accepting only even number of fully balanced parentheses call_set = ['('] @@ -350,23 +422,53 @@ def vpa_for_even_parentheses(): return vpa -def gen_arithmetic_data(num_sequences=3000, min_seq_len=2, max_seq_len=8): +def gen_arithmetic_data(num_sequences: int = 3000, min_seq_len: int = 2, max_seq_len: int = 8) \ + -> tuple[list, VpaAlphabet]: + """ + Generates random arithmetic-expression traces (using a validating SUL based on Python's ast module) in the + format expected by RPNI. + + :param int num_sequences: Number of traces to generate. + :param int min_seq_len: Minimum length of a generated sequence. + :param int max_seq_len: Maximum length of a generated sequence. + :return tuple[list, VpaAlphabet]: The generated traces in RPNI input/output format, and the used VPA alphabet. + """ import ast from aalpy.base import SUL from aalpy.utils import convert_i_o_traces_for_RPNI class ArithmeticSUL(SUL): - def __init__(self): + """ + SUL that validates whether the string under test is a syntactically valid arithmetic expression. + """ + + def __init__(self) -> None: + """ + Creates the arithmetic-expression SUL with an empty string under test. + """ super().__init__() self.string_under_test = '' - def pre(self): + def pre(self) -> None: + """ + Resets the string under test. + """ self.string_under_test = '' - def post(self): + def post(self) -> None: + """ + No cleanup necessary. + """ pass - def step(self, letter): + def step(self, letter: str | None) -> bool: + """ + Appends the letter to the string under test and checks whether it still parses as a valid + arithmetic expression. + + :param str | None letter: Next token to append, or None. + :return bool: True if the string under test is a valid arithmetic expression. + """ if letter: self.string_under_test += ' ' + letter if len(self.string_under_test) > 0 else letter @@ -401,7 +503,12 @@ def step(self, letter): return rpni_format, alphabet -def vpa_json(): +def vpa_json() -> Vpa: + """ + Builds an example VPA that recognizes a simplified JSON-like structure. + + :return Vpa: The constructed VPA. + """ # Define call, return, and internal symbols for JSON call_set = ['{', '['] return_set = ['}', ']'] @@ -457,7 +564,12 @@ def vpa_json(): return vpa -def get_all_VPAs(): +def get_all_VPAs() -> list[Vpa]: + """ + Loads and builds all example VPAs, including one loaded from a DOT file. + + :return list[Vpa]: All example VPAs. + """ from aalpy import load_automaton_from_file arithmetics_vpa = load_automaton_from_file('../DotModels/arithmetics.dot', 'vpa') return [vpa_L1(), vpa_L2(), vpa_L3(), vpa_L4(), diff --git a/aalpy/utils/DataHandler.py b/aalpy/utils/DataHandler.py index 71a94e8ecce..250251b7dc2 100644 --- a/aalpy/utils/DataHandler.py +++ b/aalpy/utils/DataHandler.py @@ -1,3 +1,4 @@ +# Data handlers/tokenizers for loading sequence data used by the Alergia algorithm. from abc import ABC, abstractmethod @@ -8,7 +9,13 @@ class DataHandler(ABC): """ @abstractmethod - def tokenize_data(self, path): + def tokenize_data(self, path: str) -> list: + """ + Tokenizes data found at the given path. + + :param str path: path to the data file. + :return list: list of tokenized sequences. + """ pass @@ -19,7 +26,13 @@ class CharacterTokenizer(DataHandler): Each input sequence is in the separate line. """ - def tokenize_data(self, path): + def tokenize_data(self, path: str) -> list[list[str]]: + """ + Tokenizes each line of the file into a list of single characters. + + :param str path: path to the data file. + :return list[list[str]]: list of tokenized sequences, one per line. + """ data = [] lines = open(path).read().splitlines() for l in lines: @@ -34,7 +47,14 @@ class DelimiterTokenizer(DataHandler): Each input sequence is in the separate line. """ - def tokenize_data(self, path, delimiter=','): + def tokenize_data(self, path: str, delimiter: str = ',') -> list[list[str]]: + """ + Tokenizes each line of the file by splitting on the given delimiter. + + :param str path: path to the data file. + :param str delimiter: delimiter separating inputs in a line. + :return list[list[str]]: list of tokenized sequences, one per line. + """ data = [] lines = open(path).read().splitlines() for l in lines: @@ -50,7 +70,15 @@ class IODelimiterTokenizer(DataHandler): Each [output, tuple(input,output)*] sequence is in the separate line. """ - def tokenize_data(self, path, io_delimiter='/', word_delimiter=','): + def tokenize_data(self, path: str, io_delimiter: str = '/', word_delimiter: str = ',') -> list[list]: + """ + Tokenizes each line of the file into an initial output followed by (input, output) tuples. + + :param str path: path to the data file. + :param str io_delimiter: delimiter separating an input from its output within a word. + :param str word_delimiter: delimiter separating words (initial output and input/output pairs) in a line. + :return list[list]: list of tokenized sequences, one per line. + """ data = [] lines = open(path).read().splitlines() for l in lines: @@ -67,7 +95,13 @@ def tokenize_data(self, path, io_delimiter='/', word_delimiter=','): return data -def try_int(x): +def try_int(x: str) -> int | str: + """ + Converts a string to an int if it represents a digit, otherwise returns it unchanged. + + :param str x: string to convert. + :return int | str: the converted integer, or the original string if not convertible. + """ if str.isdigit(x): return int(x) return x diff --git a/aalpy/utils/FileHandler.py b/aalpy/utils/FileHandler.py index e3f652e6001..a92bf9551a6 100644 --- a/aalpy/utils/FileHandler.py +++ b/aalpy/utils/FileHandler.py @@ -1,7 +1,9 @@ +# Serialization, deserialization and Graphviz-based visualization of automata to/from .dot/.png/.svg/.pdf files. import re import sys import traceback from pathlib import Path +from typing import Any from pydot import Dot, Node, Edge @@ -15,16 +17,26 @@ Sevpa: 'sevpa', Vpa: 'vpa'} -def _wrap_label(label): +def _wrap_label(label: str) -> str: """ Adds a " " around a label if not already present on both ends. + + :param str label: label to wrap. + :return str: the wrapped label. """ if label[0] == '\"' and label[-1] == '\"': return label return f'\"{label}\"' -def _get_node(state, automaton_type): +def _get_node(state: Any, automaton_type: str) -> Node | None: + """ + Creates a pydot Node for a given automaton state, styled according to the automaton type. + + :param Any state: automaton state for which the node is created. + :param str automaton_type: type tag of the automaton (e.g. 'dfa', 'mealy', 'moore'). + :return Node | None: the created node, or None if automaton_type is not recognized. + """ if automaton_type == 'dfa': if state.is_accepting: return Node(state.state_id, label=_wrap_label(state.state_id), shape='doublecircle') @@ -48,7 +60,17 @@ def _get_node(state, automaton_type): return Node(state.state_id, label=_wrap_label(state.state_id)) -def _add_transition_to_graph(graph, state, automaton_type, display_same_state_trans, round_floats): +def _add_transition_to_graph(graph: Dot, state: Any, automaton_type: str, display_same_state_trans: bool, + round_floats: int | None) -> None: + """ + Adds all outgoing transitions of a state as edges to the graph. + + :param Dot graph: pydot graph to which edges are added. + :param Any state: automaton state whose transitions are added. + :param str automaton_type: type tag of the automaton. + :param bool display_same_state_trans: if False, self-loops are skipped. + :param int | None round_floats: number of decimal places to round probabilities to, or None to not round. + """ if automaton_type == 'dfa' or automaton_type == 'moore': for i in state.transitions.keys(): new_state = state.transitions[i] @@ -126,22 +148,17 @@ def _add_transition_to_graph(graph, state, automaton_type, display_same_state_tr graph.add_edge(edge) -def visualize_automaton(automaton, path="LearnedModel", file_type="pdf", display_same_state_trans=True): +def visualize_automaton(automaton: Any, path: str | Path = "LearnedModel", file_type: str = "pdf", + display_same_state_trans: bool = True) -> None: """ Create a graphical representation of the automaton. Function is round in the separate thread in the background. If possible, it will be opened by systems default program. - Args: - - automaton: automaton to be visualized - - path: pathlike or str, file in which visualization will be saved (Default value = "LearnedModel.pdf") - - file_type: type of file/visualization. Can be ['png', 'svg', 'pdf'] (Default value = "pdf") - - display_same_state_trans: if True, same state transitions will be displayed (Default value = True) - + :param Any automaton: automaton to be visualized + :param str | Path path: file in which visualization will be saved + :param str file_type: type of file/visualization. Can be ['png', 'svg', 'pdf'] + :param bool display_same_state_trans: if True, same state transitions will be displayed """ print('Visualization started in the background thread.') @@ -154,29 +171,21 @@ def visualize_automaton(automaton, path="LearnedModel", file_type="pdf", display visualization_thread.start() -def save_automaton_to_file(automaton, path="LearnedModel", file_type="dot", - display_same_state_trans=True, visualize=False, round_floats=None): +def save_automaton_to_file(automaton: Any, path: str | Path = "LearnedModel", file_type: str = "dot", + display_same_state_trans: bool = True, visualize: bool = False, + round_floats: int | None = None) -> str | None: """ The Standard of the automata strictly follows the syntax found at: https://automata.cs.ru.nl/Syntax/Overview. For non-deterministic and stochastic systems syntax can be found on AALpy's Wiki. - Args: - - automaton: automaton to be saved to file - - path: pathlike or str, file in which visualization will be saved (Default value = "LearnedModel") - - file_type: type of file/visualization. Can be ['dot', 'png', 'svg', 'pdf'] (Default value = "dot) - - display_same_state_trans: True, should not be set to false except from the visualization method - (Default value = True) - - visualize: visualize the automaton - - round_floats: for stochastic automata, round the floating point numbers to defined number of decimal places - - Returns: - + :param Any automaton: automaton to be saved to file + :param str | Path path: file in which visualization will be saved + :param str file_type: type of file/visualization. Can be ['dot', 'png', 'svg', 'pdf'] + :param bool display_same_state_trans: True, should not be set to false except from the visualization method + :param bool visualize: visualize the automaton + :param int | None round_floats: for stochastic automata, round the floating point numbers to defined number of + decimal places + :return str | None: the dot representation as a string if file_type is 'string', otherwise None """ path = Path(path) file_type = file_type.lower() @@ -224,7 +233,16 @@ def save_automaton_to_file(automaton, path="LearnedModel", file_type="dot", vpa_pop_pattern = r"(\S+)\s*/\s*pop\(\s*(.*?)\s*\)" -def _process_label(label, source, destination, automaton_type): +def _process_label(label: str, source: Any, destination: Any, automaton_type: str) -> None: + """ + Parses a single transition label from a loaded dot file and adds the corresponding transition to the source + state. + + :param str label: the (already unwrapped) transition label. + :param Any source: the source state of the transition. + :param Any destination: the destination state of the transition. + :param str automaton_type: type tag of the automaton. + """ if automaton_type == 'dfa' or automaton_type == 'moore': source.transitions[int(label) if label.isdigit() else label] = destination if automaton_type == 'mealy': @@ -288,7 +306,16 @@ def _process_label(label, source, destination, automaton_type): source.transitions[input_symbol].append(transition) -def _process_node_label(node, label, node_label_dict, node_type, automaton_type): +def _process_node_label(node: Node, label: str, node_label_dict: dict, node_type: type, automaton_type: str) -> None: + """ + Parses a node's label from a loaded dot file and creates the corresponding automaton state. + + :param Node node: pydot node being processed. + :param str label: the (already unwrapped) node label. + :param dict node_label_dict: map from node name to created state, updated in place. + :param type node_type: state class to instantiate (e.g. DfaState). + :param str automaton_type: type tag of the automaton. + """ node_name = node.get_name() if automaton_type == 'mdp' or automaton_type == 'mc': node_label_dict[node_name] = node_type(node_name, label) @@ -315,7 +342,18 @@ def _strip_label(label: str) -> str: return label -def _process_node_label_prime(node_name, label, line, node_label_dict, node_type, automaton_type): +def _process_node_label_prime(node_name: str, label: str, line: str, node_label_dict: dict, node_type: type, + automaton_type: str) -> None: + """ + Parses a node's label and its source dot line to create the corresponding automaton state. + + :param str node_name: name of the node/state. + :param str label: the (already unwrapped) node label. + :param str line: the raw dot file line the node was parsed from. + :param dict node_label_dict: map from node name to created state, updated in place. + :param type node_type: state class to instantiate (e.g. DfaState). + :param str automaton_type: type tag of the automaton. + """ if automaton_type == 'mdp' or automaton_type == 'mc': node_label_dict[node_name] = node_type(node_name, label) else: @@ -341,26 +379,18 @@ def _process_node_label_prime(node_name, label, line, node_label_dict, node_type transition_pattern = r'(\w+)\s*->\s*(\w+)\s*(.*)?;?' -def load_automaton_from_file(path, automaton_type, compute_prefixes=False): +def load_automaton_from_file(path: str | Path, automaton_type: str, compute_prefixes: bool = False) -> Any: """ Loads the automaton from the file. Standard of the automatas strictly follows syntax found at: https://automata.cs.ru.nl/Syntax/Overview. For non-deterministic and stochastic systems syntax can be found on AALpy's Wiki. - Args: - - path: pathlike or str to the file - - automaton_type: type of the automaton, one of ['dfa', 'mealy', 'moore', 'mdp', 'smm', - 'onfsm', 'ndmoore', 'mc', 'sevpa', 'vpa'] - - compute_prefixes: it True, shortest path to reach every state will be computed and saved in the prefix of - the state. Useful when loading the model to use them as a equivalence oracle. (Default value = False) - - Returns: - - loaded automaton - + :param str | Path path: path to the file + :param str automaton_type: type of the automaton, one of ['dfa', 'mealy', 'moore', 'mdp', 'smm', + 'onfsm', 'ndmoore', 'mc', 'sevpa', 'vpa'] + :param bool compute_prefixes: it True, shortest path to reach every state will be computed and saved in the + prefix of the state. Useful when loading the model to use them as a equivalence oracle. + :return Any: loaded automaton """ assert automaton_type in automaton_types.values() diff --git a/aalpy/utils/HelperFunctions.py b/aalpy/utils/HelperFunctions.py index d11ea789110..3a22e3d81de 100644 --- a/aalpy/utils/HelperFunctions.py +++ b/aalpy/utils/HelperFunctions.py @@ -1,7 +1,9 @@ +# Miscellaneous helper functions used across learning algorithms, oracles and utilities. import random import string from itertools import product from collections import defaultdict +from typing import Any from aalpy import Mdp, MarkovChain, McState, MooreMachine, Dfa, DfaState @@ -10,12 +12,10 @@ def extend_set(list_to_extend: list, new_elements: list) -> list: """ Helper function to extend a list while maintaining set property. They are stored as lists, so with this function set property is maintained. - :return - - Returns: - - list of elements that were added to the set + :param list list_to_extend: List that is extended in place. + :param list new_elements: Elements to add if not already present. + :return list: List of elements that were added to the set. """ set_repr = set(list_to_extend) added_elements = [s for s in new_elements if s not in set_repr] @@ -23,43 +23,32 @@ def extend_set(list_to_extend: list, new_elements: list) -> list: return added_elements -def all_prefixes(li): +def all_prefixes(li: list) -> list[tuple]: """ Returns all prefixes of a list. - Args: - li: list from which to compute all prefixes - - Returns: - list of all prefixes - + :param list li: List from which to compute all prefixes. + :return list[tuple]: List of all prefixes. """ return [tuple(li[:i + 1]) for i in range(len(li))] -def all_suffixes(li): +def all_suffixes(li: list) -> list[tuple]: """ Returns all suffixes of a list. - Args: - li: list from which to compute all suffixes - - Returns: - list of all suffixes - + :param list li: List from which to compute all suffixes. + :return list[tuple]: List of all suffixes. """ return [tuple(li[len(li) - i - 1:]) for i in range(len(li))] -def profile_function(function: callable, sort_key='cumtime'): +def profile_function(function: callable, sort_key: str = 'cumtime') -> None: """ + Profiles a callable and prints the profiling results. - Args: - function: callable: - sort_key: (Default value = 'cumtime') - - Returns: - prints the profiling results + :param callable function: Callable to profile. + :param str sort_key: Key used to sort the profiling statistics (Default value = 'cumtime'). """ import cProfile pr = cProfile.Profile() @@ -69,25 +58,23 @@ def profile_function(function: callable, sort_key='cumtime'): pr.print_stats(sort=sort_key) -def random_string_generator(size=10, chars=string.ascii_lowercase + string.digits): +def random_string_generator(size: int = 10, chars: str = string.ascii_lowercase + string.digits) -> str: """ + Generates a random string. - Args: - - size: (Default value = 10) - chars: (Default value = string.ascii_lowercase + string.digits) - - Returns: - - a random string of length size + :param int size: Length of the generated string (Default value = 10). + :param str chars: Pool of characters to choose from (Default value = string.ascii_lowercase + string.digits). + :return str: A random string of length size. """ import random return ''.join(random.choice(chars) for _ in range(size)) -def print_learning_info(info: dict): +def print_learning_info(info: dict[str, Any]) -> None: """ Print learning statistics. + + :param dict[str, Any] info: Dictionary of learning statistics as produced by a learning algorithm's info dict. """ print('-----------------------------------') print('Learning Finished.') @@ -112,15 +99,12 @@ def print_learning_info(info: dict): print('-----------------------------------') -def print_observation_table(ot, table_type): +def print_observation_table(ot: Any, table_type: str) -> None: """ Prints the whole observation table. - Args: - - ot: observation table - table_type: 'det', 'non-det', or 'stoc' - + :param Any ot: Observation table. + :param str table_type: 'det', 'non-det', 'abstracted-non-det', or 'stoc'. """ if table_type == 'det': s_set, extended_s, e_set, table = ot.S, ot.s_dot_a(), ot.E, ot.T @@ -171,16 +155,13 @@ def print_observation_table(ot, table_type): print('-' * row_len) -def is_suffix_of(suffix, trace) -> bool: +def is_suffix_of(suffix: tuple, trace: tuple) -> bool: """ + Checks whether a sequence is a suffix of another sequence. - Args: - suffix: target suffix - trace: trace in question - - Returns: - - True if suffix is the suffix of trace. + :param tuple suffix: Target suffix. + :param tuple trace: Trace in question. + :return bool: True if suffix is the suffix of trace. """ if len(trace) < len(suffix): return False @@ -188,24 +169,25 @@ def is_suffix_of(suffix, trace) -> bool: return trace[-len(suffix):] == suffix -def get_cex_prefixes(cex, automaton_type): +def get_cex_prefixes(cex: tuple, automaton_type: str) -> list[tuple]: """ Returns all prefixes of the stochastic automaton. - Args: - cex: counterexample - automaton_type: `mdp` or `smm` - - Returns: - - all prefixes of the counterexample based on the `automaton_type` + :param tuple cex: Counterexample. + :param str automaton_type: `mdp` or `smm`. + :return list[tuple]: All prefixes of the counterexample based on the `automaton_type`. """ if automaton_type == 'mdp': return [tuple(cex[:i + 1]) for i in range(0, len(cex), 2)] return [tuple(cex[:i]) for i in range(0, len(cex) + 1, 2)] -def get_available_oracles_and_err_msg(): +def get_available_oracles_and_err_msg() -> tuple[set, str]: + """ + Looks up the equivalence oracles that are supported for non-deterministic and stochastic learning. + + :return tuple[set, str]: Set of available oracle classes and a warning message describing the restriction. + """ from aalpy.oracles import RandomWalkEqOracle from aalpy.oracles import RandomWordEqOracle available_oracles = {RandomWalkEqOracle, RandomWordEqOracle} @@ -217,7 +199,7 @@ def get_available_oracles_and_err_msg(): return available_oracles, available_oracles_msg -def make_input_complete(automaton, missing_transition_go_to='self_loop'): +def make_input_complete(automaton: Any, missing_transition_go_to: str = 'self_loop') -> Any: """ Makes the automaton input complete/enabled. If a input is not defined in a state, it will lead to the self loop. In case of Mealy Machines, Stochastic Mealy machines and ONFSM 'epsilon' is used as output. @@ -226,14 +208,9 @@ def make_input_complete(automaton, missing_transition_go_to='self_loop'): (Mealy machines and their derivatives), 'epsilon' is used as an output value. If a state has an output value, it is either False (in case of DFA) or 'sink_state' in case of Moore machines and its derivatives. - Args: - - automaton: automaton that is potentially not input complete - missing_transition_go_to: either 'self_loop' or 'sink_state'. - - Returns: - - an input complete automaton + :param Any automaton: Automaton that is potentially not input complete. + :param str missing_transition_go_to: Either 'self_loop' or 'sink_state'. + :return Any: An input complete automaton. """ from aalpy.base import DeterministicAutomaton from aalpy.automata import Dfa, MooreState, MealyMachine, Mdp, StochasticMealyMachine, Onfsm, \ @@ -279,11 +256,15 @@ def make_input_complete(automaton, missing_transition_go_to='self_loop'): return automaton -def convert_i_o_traces_for_RPNI(sequences, automaton_type="mealy"): +def convert_i_o_traces_for_RPNI(sequences: list, automaton_type: str = "mealy") -> list[tuple]: """ Converts a list of input-output sequences to RPNI format. Eg. [[(1,'a'), (2,'b'), (3,'c')], [(6,'7'), (4,'e'), (3,'c')]] to [((1,), 'a'), ((1, 2), 'b'), ((1, 2, 3), 'c'), ((6,), '7'), ((6, 4), 'e'), ((6, 4, 3), 'c')] + + :param list sequences: List of input-output traces. + :param str automaton_type: Either "mealy", "moore" or "dfa". + :return list[tuple]: List of (input_sequence, output) pairs in RPNI format. """ rpni_sequences = [] seen = set() @@ -309,7 +290,12 @@ def convert_i_o_traces_for_RPNI(sequences, automaton_type="mealy"): return rpni_sequences -def visualize_classification_tree(root_node): +def visualize_classification_tree(root_node: Any) -> None: + """ + Visualizes a classification tree and writes it to a PDF file. + + :param Any root_node: Root node of the classification tree. + """ from pydot import Dot, Node, Edge graph = Dot('classification_tree', graph_type='digraph') @@ -336,7 +322,14 @@ def visualize_classification_tree(root_node): graph.write(path='classification_tree.pdf', format='pdf') -def is_balanced(input_seq, vpa_alphabet): +def is_balanced(input_seq: list, vpa_alphabet: Any) -> bool: + """ + Checks whether an input sequence is balanced with respect to a VPA alphabet's call/return symbols. + + :param list input_seq: Input sequence to check. + :param Any vpa_alphabet: VPA alphabet, exposing call_alphabet and return_alphabet. + :return bool: True if the sequence is balanced, False otherwise. + """ counter = 0 for i in input_seq: if i in vpa_alphabet.call_alphabet: @@ -348,8 +341,18 @@ def is_balanced(input_seq, vpa_alphabet): return counter == 0 -def generate_input_output_data_from_automata(model, num_sequences=4000, min_seq_len=1, max_seq_len=16, - sequance_type='io_traces'): +def generate_input_output_data_from_automata(model: Any, num_sequences: int = 4000, min_seq_len: int = 1, + max_seq_len: int = 16, sequance_type: str = 'io_traces') -> list: + """ + Generates random input-output data by executing random input sequences on an automaton. + + :param Any model: Automaton from which the data is generated. + :param int num_sequences: Number of sequences to generate. + :param int min_seq_len: Minimum sequence length. + :param int max_seq_len: Maximum sequence length. + :param str sequance_type: Either 'io_traces' or 'labeled_sequences'. + :return list: The generated dataset. + """ assert sequance_type in {'io_traces', 'labeled_sequences'} alphabet = model.get_input_alphabet() @@ -371,7 +374,18 @@ def generate_input_output_data_from_automata(model, num_sequences=4000, min_seq_ return dataset -def generate_input_output_data_from_vpa(vpa, num_sequences=1000, max_seq_len=16, max_attempts=None): +def generate_input_output_data_from_vpa(vpa: Any, num_sequences: int = 1000, max_seq_len: int = 16, + max_attempts: int | None = None) -> list[tuple]: + """ + Generates random input-output data by executing random (mostly balanced) input sequences on a VPA. + + :param Any vpa: Visibly pushdown automaton from which the data is generated. + :param int num_sequences: Number of sequences to generate. + :param int max_seq_len: Maximum sequence length. + :param int | None max_attempts: Maximum number of generation attempts before giving up (Default value = None, + meaning num_sequences * 50). + :return list[tuple]: List of (input_sequence, output) pairs. + """ alphabet = vpa.input_alphabet.get_merged_alphabet() data_set, in_set = [], set() @@ -411,15 +425,25 @@ def generate_input_output_data_from_vpa(vpa, num_sequences=1000, max_seq_len=16, return data_set -def product_with_possible_empty_iterable(*iterables, repeat=1): +def product_with_possible_empty_iterable(*iterables: Any, repeat: int = 1) -> product: """ Words like regular product, but if one of the iterables is empty it will just ignore it, instead of returning []. + + :param Any iterables: Iterables to compute the product of. + :param int repeat: Number of times to repeat the product computation. + :return product: Cartesian product of the non-empty iterables. """ non_empty_iterables = [it for it in iterables if it] return product(*non_empty_iterables, repeat=repeat) def dfa_from_moore(moore_model: MooreMachine) -> Dfa: + """ + Converts a Moore machine with a Boolean (or None) output domain to a DFA. + + :param MooreMachine moore_model: Moore machine to convert. + :return Dfa: The equivalent DFA. + """ dfa_state_map = dict() # define states for moore_state in moore_model.states: @@ -439,7 +463,15 @@ def dfa_from_moore(moore_model: MooreMachine) -> Dfa: initial_state = dfa_state_map[moore_model.initial_state.state_id] return Dfa(initial_state, list(dfa_state_map.values())) -def mc_from_mdp(mdp: Mdp, input_symbol=None) -> MarkovChain: +def mc_from_mdp(mdp: Mdp, input_symbol: Any = None) -> MarkovChain: + """ + Converts an MDP with a single (or explicitly chosen) input symbol to a Markov chain. + + :param Mdp mdp: MDP to convert. + :param Any input_symbol: Input symbol to use for the conversion (Default value = None, meaning the MDP's only + input symbol is used). + :return MarkovChain: The equivalent Markov chain. + """ alphabet = mdp.get_input_alphabet() if len(alphabet) != 1 and input_symbol is None: raise ValueError('Cannot convert MDP with several inputs to Markov chain.') @@ -456,12 +488,18 @@ def mc_from_mdp(mdp: Mdp, input_symbol=None) -> MarkovChain: initial_state = state_map[mdp.initial_state.state_id] return MarkovChain(initial_state, list(state_map.values())) -def mc_format_to_mdp(data): - # a hack to learn MC's with GSM -> treat them as MDP with a single input +def mc_format_to_mdp(data: list) -> list: + """ + Converts Markov chain formatted data to MDP format by treating it as an MDP with a single input. + a hack to learn MC's with GSM -> treat them as MDP with a single input + + :param list data: List of Markov chain sequences. + :return list: List of sequences reformatted as MDP input-output sequences. + """ augmented_data = [] for sequence in data: new_sequence = [sequence[0]] for item in sequence[1:]: new_sequence.append(('Input', item)) augmented_data.append(new_sequence) - return augmented_data \ No newline at end of file + return augmented_data diff --git a/aalpy/utils/ModelChecking.py b/aalpy/utils/ModelChecking.py index 3a5105fb9ce..f0731a0daa1 100644 --- a/aalpy/utils/ModelChecking.py +++ b/aalpy/utils/ModelChecking.py @@ -1,10 +1,11 @@ +# Model checking utilities: PRISM export, property evaluation, bisimilarity and test-case generation. import itertools as it import os import re from collections import defaultdict from queue import Queue from random import choices -from typing import Tuple, Union +from typing import Any import aalpy.paths from aalpy.SULs import AutomatonSUL @@ -15,7 +16,13 @@ prism_prob_output_regex = re.compile("Result: (\d+\.\d+)") -def get_properties_file(exp_name): +def get_properties_file(exp_name: str) -> str: + """ + Looks up the path to the properties file of a predefined stochastic experiment. + + :param str exp_name: Name of the experiment. + :return str: Path to the corresponding properties file. + """ property_files = { 'first_grid': aalpy.paths.path_to_properties + 'first_eval.props', 'second_grid': aalpy.paths.path_to_properties + 'second_eval.props', @@ -28,7 +35,13 @@ def get_properties_file(exp_name): return property_files[exp_name] -def get_correct_prop_values(exp_name): +def get_correct_prop_values(exp_name: str) -> list: + """ + Looks up the reference (ground truth) property values of a predefined stochastic experiment. + + :param str exp_name: Name of the experiment. + :return list: List of correct property values. + """ correct_model_properties = { 'first_grid': {'prob1': 0.96217534, 'prob2': 0.6499274956800001, 'prob3': 0.6911765746880001}, 'second_grid': {'prob1': 0.93480795088125, 'prob2': 0.6711947700000002, 'prob3': 0.9742903305241055, @@ -50,32 +63,41 @@ def get_correct_prop_values(exp_name): return list(correct_model_properties[exp_name].values()) -def _target_string(target, orig_id_to_int_id): +def _target_string(target: tuple, orig_id_to_int_id: dict) -> str: + """ + Formats a single MDP transition target as a PRISM update expression. + + :param tuple target: Tuple of (target_state, probability). + :param dict orig_id_to_int_id: Map from original state ids to PRISM integer location ids. + :return str: The PRISM update expression string. + """ target_state = target[0] target_prob = target[1] target_id = orig_id_to_int_id[target_state.state_id] return f"{target_prob} : (loc'={target_id})" -def _sanitize_for_prism(symbol): +def _sanitize_for_prism(symbol: str) -> str: + """ + Sanitizes a symbol that clashes with a PRISM keyword. + + :param str symbol: Symbol to sanitize. + :return str: The original symbol, or a mangled version if it is a PRISM keyword. + """ if symbol in ["mdp", "init", "module", "endmodule", "label"]: return "___" + symbol + "___" else: return symbol -def mdp_2_prism_format(mdp: Mdp, name: str, output_path=None): +def mdp_2_prism_format(mdp: Mdp, name: str, output_path: str | None = None) -> str: """ - Translates MDP to Prims modelling language. - - Args: - - mdp: markov decision process - - name: name of the mdp/experiment - - output_path: output file (Default value = None) + Translates MDP to Prism modelling language. + :param Mdp mdp: Markov decision process. + :param str name: Name of the mdp/experiment. + :param str | None output_path: Output file to which the model is written (Default value = None). + :return str: The Prism model as a string. """ module_string = "mdp" module_string += os.linesep @@ -123,7 +145,14 @@ def mdp_2_prism_format(mdp: Mdp, name: str, output_path=None): return module_string -def evaluate_all_properties(prism_file_name, properties_file_name): +def evaluate_all_properties(prism_file_name: str, properties_file_name: str) -> dict[str, float]: + """ + Runs PRISM on a model file against a properties file and collects the resulting probabilities. + + :param str prism_file_name: Path to the PRISM model file. + :param str properties_file_name: Path to the properties file. + :return dict[str, float]: Map from property name (e.g. 'prop1') to its computed probability. + """ import subprocess import io from os import path @@ -148,16 +177,13 @@ def evaluate_all_properties(prism_file_name, properties_file_name): return results -def model_check_properties(model: Mdp, properties: str): +def model_check_properties(model: Mdp, properties: str) -> dict[str, float]: """ + Model checks a set of properties against an MDP using PRISM. - Args: - model: Markov Decision Process that serves as a basis for model checking. - properties: Properties file. It should point to a file under the path_to_properties folder. - - Returns: - - results of model checking + :param Mdp model: Markov Decision Process that serves as a basis for model checking. + :param str properties: Properties file. It should point to a file under the path_to_properties folder. + :return dict[str, float]: Results of model checking. """ from os import remove from aalpy.utils import mdp_2_prism_format @@ -172,21 +198,17 @@ def model_check_properties(model: Mdp, properties: str): return data -def model_check_experiment(path_to_properties, correct_prop_values, mdp, precision=4): +def model_check_experiment(path_to_properties: str, correct_prop_values: list, mdp: Mdp, precision: int = 4) -> tuple[dict, dict]: """ For our stochastic experiments you can use this function. For example, check learn_stochastic_system_and_do_model_checking in Examples.py - Args: - path_to_properties: path to the properties file - correct_prop_values: correct values of all properties. In list, where property at index i corresponds to the - i-th element of the list. - mdp: MDP - precision: precision to which round up results - - Returns: - - results of model checking and absolute differance to the correct results + :param str path_to_properties: Path to the properties file. + :param list correct_prop_values: Correct values of all properties. In list, where property at index i corresponds + to the i-th element of the list. + :param Mdp mdp: MDP. + :param int precision: Precision to which round up results. + :return tuple[dict, dict]: Results of model checking and absolute difference to the correct results. """ model_checking_results = model_check_properties(mdp, path_to_properties) @@ -198,18 +220,15 @@ def model_check_experiment(path_to_properties, correct_prop_values, mdp, precisi return results, diff_2_correct -def stop_based_on_confidence(hypothesis, property_based_stopping, print_level=2): +def stop_based_on_confidence(hypothesis: Any, property_based_stopping: tuple, print_level: int = 2) -> bool: """ + Determines whether learning should stop based on model checking confidence. - Args: - - hypothesis: Markov decision process - property_based_stopping: a tuple (path to properties file, list of correct property values, max allowed error) - print_level: 2 or 3 if output of model checking is to be printed during learning - - Returns: - - True if absolute error for all properties is smaller then property_based_stopping[2] + :param Any hypothesis: Markov decision process (or StochasticMealyMachine, converted internally to an MDP). + :param tuple property_based_stopping: A tuple (path to properties file, list of correct property values, + max allowed error). + :param int print_level: 2 or 3 if output of model checking is to be printed during learning. + :return bool: True if absolute error for all properties is smaller then property_based_stopping[2]. """ from aalpy.automata.StochasticMealyMachine import smm_to_mdp_conversion @@ -235,13 +254,16 @@ def stop_based_on_confidence(hypothesis, property_based_stopping, print_level=2) return True -def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex=False) -> Union[bool, None, tuple]: +def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex: bool = False) -> bool | None | tuple: """ Checks whether the provided automata are bisimilar. If return_cex the function returns a counter example or None, otherwise a Boolean is returned. - Returns: - object: true or false if return_cex is set to False, otherwise None (no counterexample) or a counterexample + :param DeterministicAutomaton a1: First automaton. + :param DeterministicAutomaton a2: Second automaton. + :param bool return_cex: Whether to return a counterexample instead of a boolean. + :return bool | None | tuple: True or false if return_cex is set to False, otherwise None (no counterexample) or + a counterexample. """ # TODO allow states as inputs instead of automata @@ -256,7 +278,7 @@ def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex raise NotImplementedError( f"bisimilarity is not implemented for {a1.__class__.__name__}. Supported: {', '.join(t.__name__ for t in supported_automaton_types)}") - to_check: Queue[Tuple[AutomatonState, AutomatonState]] = Queue() + to_check: Queue[tuple[AutomatonState, AutomatonState]] = Queue() to_check.put((a1.initial_state, a2.initial_state)) requirements = dict() requirements[(a1.initial_state, a2.initial_state)] = () @@ -284,23 +306,17 @@ def bisimilar(a1: DeterministicAutomaton, a2: DeterministicAutomaton, return_cex return None if return_cex else True -def compare_automata(aut_1: DeterministicAutomaton, aut_2: DeterministicAutomaton, num_cex=10): +def compare_automata(aut_1: DeterministicAutomaton, aut_2: DeterministicAutomaton, num_cex: int = 10) -> list: """ Finds cases of non-conformance between first and second automaton. This is done by performing RandomW equivalence check. It is possible that number of found counterexamples is smaller than num_cex, as no counterexample will be a suffix of a previously found counterexample. - Args: - - aut_1: first automaton - - aut_2: second automaton - - num_cex: max. number of searches for counterexamples - - Returns: - - A list of input sequences that revel different behaviour on both automata. Counterexamples are sorted by length. + :param DeterministicAutomaton aut_1: First automaton. + :param DeterministicAutomaton aut_2: Second automaton. + :param int num_cex: Max. number of searches for counterexamples. + :return list: A list of input sequences that revel different behaviour on both automata. Counterexamples are + sorted by length. """ # from aalpy.oracles import RandomWMethodEqOracle @@ -346,43 +362,60 @@ def compare_automata(aut_1: DeterministicAutomaton, aut_2: DeterministicAutomato class TestCaseWrapperSUL(SUL): - def __init__(self, sul): + """ + SUL wrapper that records every membership query as a test case (input sequence, output sequence). + """ + + def __init__(self, sul: SUL) -> None: + """ + Creates a test-case-recording wrapper around a SUL. + + :param SUL sul: The wrapped system under learning. + """ super().__init__() self.sul = sul self.test_cases = [] self.test_case_inputs = None self.test_case_outputs = None - def pre(self): + def pre(self) -> None: + """ + Resets the recorded input/output buffers and the wrapped SUL. + """ self.test_case_inputs = [] self.test_case_outputs = [] return self.sul.pre() - def post(self): + def post(self) -> None: + """ + Stores the recorded query as a test case and performs cleanup on the wrapped SUL. + """ if self.test_case_inputs and self.test_case_outputs: self.test_cases.append((tuple(self.test_case_inputs), tuple(self.test_case_outputs))) return self.sul.post() - def step(self, letter): + def step(self, letter: Any) -> Any: + """ + Executes an action on the wrapped SUL and records the input/output pair. + + :param Any letter: Single input that is executed on the SUL. + :return Any: Output received after executing the input. + """ output = self.sul.step(letter) self.test_case_inputs.append(letter) self.test_case_outputs.append(output) return output -def generate_test_cases(automaton: DeterministicAutomaton, oracle): +def generate_test_cases(automaton: DeterministicAutomaton, oracle: Any) -> list: """ Uses parametrized eq. oracle to construct test cases on the automaton. If automaton are big (200+ states), increase recursion depth if necessary (eg. sys.setrecursionlimit(10000)). - Args: - - automaton: deterministic automaton that serves as a basis for test case generation - oracle: oracle that will construct test-cases and record inputs and outputs - - Returns: - - List of test cases, where each testcase is a tuple containing two elements, and input and an output sequance. + :param DeterministicAutomaton automaton: Deterministic automaton that serves as a basis for test case generation. + :param Any oracle: Oracle that will construct test-cases and record inputs and outputs. + :return list: List of test cases, where each testcase is a tuple containing two elements, and input and an + output sequance. """ from copy import deepcopy @@ -397,22 +430,25 @@ def generate_test_cases(automaton: DeterministicAutomaton, oracle): return wrapped_sul.test_cases -def statistical_model_checking(model, goals, max_num_steps, num_tests=105967): +def statistical_model_checking(model: Any, goals: set, max_num_steps: int, num_tests: int = 105967) -> float: """ + Estimates the probability of reaching a goal output within a bounded number of steps by sampling random tests. - - Args: - model: model on which model checking is performed - goals: set of goal outputs - max_num_steps: bounded length of tests - num_tests: num of tests that will be performed - - Returns: - - num of tests containing element of goals set / num_tests + :param Any model: Model on which model checking is performed. + :param set goals: Set of goal outputs. + :param int max_num_steps: Bounded length of tests. + :param int num_tests: Num of tests that will be performed. + :return float: Num of tests containing element of goals set / num_tests. """ - def compute_output_sequence(model, seq): + def compute_output_sequence(model: Any, seq: list) -> set: + """ + Executes an input sequence on the model from its initial state and collects the observed outputs. + + :param Any model: Model on which the sequence is executed. + :param list seq: Input sequence to execute. + :return set: Set of observed outputs. + """ model.reset_to_initial() observed_outputs = {model.step(i) for i in seq} return observed_outputs diff --git a/aalpy/utils/Sampling.py b/aalpy/utils/Sampling.py index 3e6d4540e6b..8745b3e5df7 100644 --- a/aalpy/utils/Sampling.py +++ b/aalpy/utils/Sampling.py @@ -1,10 +1,22 @@ +# Utilities for sampling and building input/output datasets from automata. +from collections.abc import Callable from functools import wraps from random import randint, choices, random +from typing import Any from aalpy import MooreMachine, Dfa, NDMooreMachine, Mdp, MarkovChain from aalpy.base import Automaton, DeterministicAutomaton + def get_io_traces(automaton: Automaton, input_traces: list) -> list: + """ + Computes input/output traces for a list of input sequences executed on an automaton. + + :param Automaton automaton: Automaton on which the input traces are executed. + :param list input_traces: List of input sequences. + :return list: List of traces, where each trace is a list of (input, output) pairs, prefixed with the initial + output for Moore-like automata. + """ moore_automata = (MooreMachine, Dfa, NDMooreMachine, Mdp, MarkovChain) is_moore = isinstance(automaton, moore_automata) @@ -19,6 +31,13 @@ def get_io_traces(automaton: Automaton, input_traces: list) -> list: def get_labeled_sequences(automaton: Automaton, input_traces: list) -> list: + """ + Computes the final output label for a list of input sequences executed on an automaton. + + :param Automaton automaton: Automaton on which the input traces are executed. + :param list input_traces: List of input sequences. + :return list: List of (input_sequence, output) pairs, where output is the label reached after the sequence. + """ moore_automata = (MooreMachine, Dfa, NDMooreMachine, Mdp, MarkovChain) is_moore = isinstance(automaton, moore_automata) @@ -34,7 +53,15 @@ def get_labeled_sequences(automaton: Automaton, input_traces: list) -> list: return data -def get_data_from_input_sequence(automaton: Automaton, input_sequence: list, data_format: str = "io_sequences"): +def get_data_from_input_sequence(automaton: Automaton, input_sequence: list, data_format: str = "io_sequences") -> list: + """ + Converts a list of input sequences to the requested data format. + + :param Automaton automaton: Automaton on which the input sequences are executed. + :param list input_sequence: List of input sequences. + :param str data_format: Either 'io_sequences' or 'labeled_sequences'. + :return list: The dataset in the requested format. + """ if data_format == "io_sequences": return get_io_traces(automaton, input_sequence) elif data_format == "labeled_sequences": @@ -43,10 +70,33 @@ def get_data_from_input_sequence(automaton: Automaton, input_sequence: list, dat raise ValueError(f"invalid data_format {data_format}. must be 'io_sequences' or 'labeled_sequences'") -def support_automaton_arg(require_transform): - def decorator(f): +def support_automaton_arg(require_transform: bool) -> Callable: + """ + Creates a decorator that allows a sampling function's first argument to be either an alphabet or an automaton, + and adds an `include_outputs` keyword argument that returns input/output traces instead of bare input sequences. + + :param bool require_transform: If true, an automaton passed as first argument is transformed into its input + alphabet before being passed to the wrapped function. + :return Callable: The decorator. + """ + def decorator(f: Callable) -> Callable: + """ + Wraps a sampling function to support an automaton as its first argument. + + :param Callable f: Sampling function to wrap. + :return Callable: The wrapped function. + """ @wraps(f) - def inner(alphabet, *args, include_outputs=False, **kwargs): + def inner(alphabet: Any, *args: Any, include_outputs: bool = False, **kwargs: Any) -> Any: + """ + Calls the wrapped sampling function, optionally converting the result to input/output traces. + + :param Any alphabet: Input alphabet, or an automaton from which the alphabet is derived. + :param Any args: Positional arguments forwarded to the wrapped function. + :param bool include_outputs: If true, returns input/output traces computed on the given automaton. + :param Any kwargs: Keyword arguments forwarded to the wrapped function. + :return Any: The sampled data, optionally converted to input/output traces. + """ automaton = None if isinstance(alphabet, Automaton): automaton = alphabet @@ -63,12 +113,29 @@ def inner(alphabet, *args, include_outputs=False, **kwargs): @support_automaton_arg(True) -def sample_with_length_limits(alphabet, nr_samples, min_len, max_len): +def sample_with_length_limits(alphabet: list, nr_samples: int, min_len: int, max_len: int) -> list: + """ + Samples random input sequences with lengths uniformly chosen between given limits. + + :param list alphabet: Input alphabet to sample from. + :param int nr_samples: Number of sequences to sample. + :param int min_len: Minimum sequence length. + :param int max_len: Maximum sequence length. + :return list: List of sampled input sequences. + """ return [choices(alphabet, k = randint(min_len, max_len)) for _ in range(nr_samples)] @support_automaton_arg(True) -def sample_with_term_prob(alphabet, nr_samples, term_prob): +def sample_with_term_prob(alphabet: list, nr_samples: int, term_prob: float) -> list: + """ + Samples random input sequences whose length is determined by a per-step termination probability. + + :param list alphabet: Input alphabet to sample from. + :param int nr_samples: Number of sequences to sample. + :param float term_prob: Probability of terminating the sequence at each step. + :return list: List of sampled input sequences. + """ ret = [] for _ in range(nr_samples): k = 0 @@ -79,7 +146,14 @@ def sample_with_term_prob(alphabet, nr_samples, term_prob): @support_automaton_arg(False) -def get_complete_sample(automaton: DeterministicAutomaton): +def get_complete_sample(automaton: DeterministicAutomaton) -> list: + """ + Generates a complete sample of an automaton, combining state prefixes, single-input infixes and the + characterization set suffixes. + + :param DeterministicAutomaton automaton: Automaton for which the complete sample is generated. + :return list: List of input sequences forming the complete sample. + """ alphabet = automaton.get_input_alphabet() automaton.compute_prefixes() char_set = automaton.compute_characterization_set() diff --git a/aalpy/utils/__init__.py b/aalpy/utils/__init__.py index 409d5e9eebd..1b7f74deee9 100644 --- a/aalpy/utils/__init__.py +++ b/aalpy/utils/__init__.py @@ -1,3 +1,4 @@ +# Public API re-exports for aalpy.utils. from .AutomatonGenerators import ( generate_random_dfa, generate_random_mealy_machine, diff --git a/pyproject.toml b/pyproject.toml index 0daf1694326..6b02c94dcbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "aalpy" version = "1.6.2" description = "An active automata learning library" readme = "README.md" -requires-python = ">=3.6" +requires-python = ">=3.10" license = { text = "MIT" } authors = [ { name = "Edi Muskardin" } @@ -30,3 +30,7 @@ license-files = ["LICENSE.txt"] [tool.setuptools.packages.find] include = ["aalpy*"] + +# pip install build twine +# python -m build +# python -m twine upload dist/* \ No newline at end of file From e74a2bba6a0ab024c7a1ff89e2738981962c9881 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:07:54 +0200 Subject: [PATCH 02/25] consistent return type in cache query --- aalpy/base/SUL.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aalpy/base/SUL.py b/aalpy/base/SUL.py index dd1e991900f..8ebf11e7ca5 100644 --- a/aalpy/base/SUL.py +++ b/aalpy/base/SUL.py @@ -146,7 +146,7 @@ def query(self, word: tuple) -> list: cached_query = self.cache.in_cache(word) if cached_query: self.num_cached_queries += 1 - return cached_query + return list(cached_query) # get outputs using default query method out = self.sul.query(word) From 0deb020159db371e831e9b5ea3e112e7e8f3ef0a Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:08:32 +0200 Subject: [PATCH 03/25] return constructed NDMooreMachine in to_state_setup --- aalpy/automata/NonDeterministicMooreMachine.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aalpy/automata/NonDeterministicMooreMachine.py b/aalpy/automata/NonDeterministicMooreMachine.py index 1d14492407a..de90cc9b54c 100644 --- a/aalpy/automata/NonDeterministicMooreMachine.py +++ b/aalpy/automata/NonDeterministicMooreMachine.py @@ -31,9 +31,11 @@ class NDMooreMachine(Automaton[NDMooreState[InputType, OutputType]]): non-deterministically. """ - def to_state_setup(self) -> None: + def to_state_setup(self) -> dict: """ - Builds a state setup dictionary for the non-deterministic Moore machine. + Converts the non-deterministic Moore machine to a state setup dictionary. + + :return dict: Map from state_id to tuple(output, transitions_dict). """ state_setup = dict() @@ -53,6 +55,8 @@ def set_dict_entry(state: NDMooreState) -> None: continue set_dict_entry(state) + return state_setup + @staticmethod def from_state_setup(state_setup: dict, **kwargs) -> 'NDMooreMachine': """ From 6b45d8d48ea032a0f810c9172d1804e926840c6e Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:09:01 +0200 Subject: [PATCH 04/25] Fix VPA execute_sequance and to_state_setup --- aalpy/automata/Vpa.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/aalpy/automata/Vpa.py b/aalpy/automata/Vpa.py index 0168165289b..f6b8ed74db1 100644 --- a/aalpy/automata/Vpa.py +++ b/aalpy/automata/Vpa.py @@ -186,6 +186,25 @@ def step(self, letter: str | None) -> bool: return self.current_state.is_accepting and self.stack == [] + def execute_sequence(self, origin_state: VpaState, seq: list[str], stack: list) -> list[bool]: + """ + Executes an input sequence on the VPA starting from a given state and stack configuration. + + A VPA's actual configuration is the pair (state, stack), not just the state, since the stack is + what makes call/return symbols visibly balanced. The generic Automaton.execute_sequence only + resets current_state, so it cannot start from a well-defined configuration on its own; stack is + therefore a required parameter here rather than defaulted, so callers always state explicitly + which configuration they mean to start from (pass [] to start fresh from origin_state). + + :param VpaState origin_state: State from which the sequence execution starts. + :param list[str] seq: Input sequence to execute. + :param list stack: Stack content to start from. + :return list[bool]: The output response for the executed sequence. + """ + self.current_state = origin_state + self.stack = list(stack) + return [self.step(s) for s in seq] + def to_state_setup(self) -> dict: """ Converts the VPA to a state setup dictionary. @@ -200,7 +219,8 @@ def to_state_setup(self) -> dict: sorted_states = sorted(self.states, key=lambda x: len(x.prefix) if x.prefix is not None else len(self.states)) for s in sorted_states: state_setup_dict[s.state_id] = ( - s.is_accepting, {k: (v.target_state.state_id, v.action) for k, v in s.transitions.items()}) + s.is_accepting, + {k: [(t.target_state.state_id, t.action, t.stack_guard) for t in v] for k, v in s.transitions.items()}) return state_setup_dict From 885001a05370ed3698a7fe6529607306a4b3df42 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:10:23 +0200 Subject: [PATCH 05/25] minor bug fixes --- aalpy/SULs/TomitaSUL.py | 2 +- aalpy/learning_algs/stochastic_passive/ActiveAleriga.py | 7 +++++-- aalpy/utils/AutomatonGenerators.py | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/aalpy/SULs/TomitaSUL.py b/aalpy/SULs/TomitaSUL.py index a319c012cf6..0b8ce970905 100644 --- a/aalpy/SULs/TomitaSUL.py +++ b/aalpy/SULs/TomitaSUL.py @@ -44,7 +44,7 @@ def step(self, letter: str) -> bool: :param str letter: Single element of the input alphabet. :return bool: Whether the current string (previous string + letter) is accepted by the grammar. """ - if input: + if letter is not None: self.string += str(letter) return self.tomita_level(self.string) diff --git a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py index ee338a9e9db..acf4d14d7eb 100644 --- a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py +++ b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py @@ -57,9 +57,12 @@ def sample(self, sul: SUL, model: 'Mdp') -> list: walk_len = randint(self.min_walk_len, self.max_walk_len) random_walk = tuple(choice(input_al) for _ in range(walk_len)) + # the SUL's initial output (before any input) must be queried separately: sul.query(random_walk) + # returns exactly one output per input in random_walk, none of which is the initial output + initial_output = sul.query(())[0] outputs = sul.query(random_walk) - sample = [outputs.pop(0)] + sample = [initial_output] for i in range(len(outputs)): sample.append((random_walk[i], outputs[i])) @@ -90,7 +93,7 @@ def run_active_Alergia(data: list, sul: SUL, sampler: Sampler, n_iter: int, eps: for i in range(n_iter): if print_info: print(f'Active Alergia Iteration: {i}') - model = run_Alergia(data, automaton_type='mdp', eps=eps, compatibility_checker=compatibility_checker) + model = run_Alergia(data, automaton_type=automaton_type, eps=eps, compatibility_checker=compatibility_checker) new_samples = sampler.sample(sul, model) data.extend(new_samples) diff --git a/aalpy/utils/AutomatonGenerators.py b/aalpy/utils/AutomatonGenerators.py index 3fc91192bcd..ee66f04a9cc 100644 --- a/aalpy/utils/AutomatonGenerators.py +++ b/aalpy/utils/AutomatonGenerators.py @@ -481,7 +481,7 @@ def generate_random_sevpa(num_states: int, internal_alphabet_size: int, call_alp for state in states: for internal_letter in internal_alphabet: - if state.transitions[internal_letter] is None: + if not state.transitions[internal_letter]: target_state = random.choice(states) state.transitions[internal_letter].append( SevpaTransition(target_state, internal_letter, None, None)) From 4fe08324351db5c07ebc30ebf4336a10ef65e313 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:11:32 +0200 Subject: [PATCH 06/25] minor bug fixes --- .../AbstractedOnfsmObservationTable.py | 17 ++++++++++++++--- aalpy/utils/HelperFunctions.py | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py index f353fd5903a..bd7c4f214c7 100644 --- a/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py +++ b/aalpy/learning_algs/non_deterministic/AbstractedOnfsmObservationTable.py @@ -53,6 +53,9 @@ def update_obs_table(self, s_set: list[tuple[tuple, tuple]] | None = None, :param list[tuple] | None e_set: Suffixes of E set on which to perform membership queries. """ + if s_set is None: + s_set = self.S + self.S_dot_A + self.observation_table.query_missing_observations(s_set, e_set) self.abstract_obs_table() self.clean_obs_table() @@ -228,14 +231,21 @@ def get_distinctive_input_sequence(self, first_row: tuple[tuple, tuple], second_ return None - def update_E(self, seq: tuple) -> None: + def update_E(self, seq: tuple) -> list[tuple]: """ Adds a suffix to the E set if not already present. :param tuple seq: Suffix to add. + :return list[tuple]: The newly added suffix as a single-element list, or an empty list if it was + already present. The caller (run_abstracted_ONFSM_Lstar) passes this straight on as the + e_set of update_obs_table(), so it must not be None/omitted - without a return statement + here it was always None, silently making update_obs_table() query every column of E instead + of just the new one. """ if seq not in self.E: self.E.append(seq) + return [seq] + return [] def clean_obs_table(self) -> None: """ @@ -254,16 +264,17 @@ def clean_obs_table(self) -> None: for s in tmp_S: hashed_s_row = self.row_to_hashable(s) if hashed_s_row in hashed_rows_from_s: + # self.S is the very same list object as self.observation_table.S (aliased in + # abstract_obs_table), so removing from one already removes from the other - + # calling .remove() on both raised ValueError: x not in list on the second call. if s in self.S: self.S.remove(s) - self.observation_table.S.remove(s) size = len(s[0]) for row_prefix in tmp_both_S: s_both_row = (row_prefix[0][:size], row_prefix[1][:size]) if s != row_prefix and s == s_both_row: if row_prefix in self.S: self.S.remove(row_prefix) - self.observation_table.S.remove(s) else: hashed_rows_from_s.add(hashed_s_row) diff --git a/aalpy/utils/HelperFunctions.py b/aalpy/utils/HelperFunctions.py index 3a22e3d81de..4196ade91f5 100644 --- a/aalpy/utils/HelperFunctions.py +++ b/aalpy/utils/HelperFunctions.py @@ -163,6 +163,8 @@ def is_suffix_of(suffix: tuple, trace: tuple) -> bool: :param tuple trace: Trace in question. :return bool: True if suffix is the suffix of trace. """ + if len(suffix) == 0: + return True if len(trace) < len(suffix): return False else: From 17ea01682f808126103a35b2a88f0b4b342a4dfe Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:12:07 +0200 Subject: [PATCH 07/25] remove hardcoded direction from linear_cex_processing --- aalpy/learning_algs/deterministic/CounterExampleProcessing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/aalpy/learning_algs/deterministic/CounterExampleProcessing.py b/aalpy/learning_algs/deterministic/CounterExampleProcessing.py index 190694f0149..bae44ec8ced 100644 --- a/aalpy/learning_algs/deterministic/CounterExampleProcessing.py +++ b/aalpy/learning_algs/deterministic/CounterExampleProcessing.py @@ -121,8 +121,6 @@ def linear_cex_processing(sul: SUL, cex: tuple, hypothesis, suffix_closedness: b """ assert direction in {'fwd', 'bwd'} - direction = 'fwd' - distinguishing_suffix = None previous_output = None From ed731ff7cb0911ef44b7e4aa15834f205c543fcd Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:12:36 +0200 Subject: [PATCH 08/25] Fix lower bound check in KWayStateCoverageEqOracle.py --- aalpy/oracles/KWayStateCoverageEqOracle.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aalpy/oracles/KWayStateCoverageEqOracle.py b/aalpy/oracles/KWayStateCoverageEqOracle.py index 910fa50ec89..5a631579f06 100644 --- a/aalpy/oracles/KWayStateCoverageEqOracle.py +++ b/aalpy/oracles/KWayStateCoverageEqOracle.py @@ -76,9 +76,10 @@ def find_cex(self, hypothesis: Automaton) -> tuple | None: path += tuple(choices(self.alphabet, k=self.random_walk_len)) test_cases.append(path) - # lower bound (also accounts for single state hypothesis when a lower bound is not defined) + # lower bound (also accounts for hypotheses with fewer states than k, where no k-wise + # combination/permutation exists at all, so test_cases would otherwise stay empty) lower_bound = self.num_test_lower_bound - if len(hypothesis.states) == 1 and lower_bound is None: + if len(hypothesis.states) < self.k and lower_bound is None: lower_bound = 50 while lower_bound is not None and len(test_cases) < lower_bound: From 65a881097d91077111cacfebde17f181b2093048 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:13:18 +0200 Subject: [PATCH 09/25] Fix sampling for Mealy machines --- .../deterministic/ObservationTree.py | 23 ++++++++++++++++++- .../rpni_helper_functions.py | 3 ++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/aalpy/learning_algs/deterministic/ObservationTree.py b/aalpy/learning_algs/deterministic/ObservationTree.py index 61f1dab4573..74db046c18b 100644 --- a/aalpy/learning_algs/deterministic/ObservationTree.py +++ b/aalpy/learning_algs/deterministic/ObservationTree.py @@ -232,6 +232,9 @@ def get_observation(self, inputs: list) -> list | None: current_node = current_node.get_successor(input_val) else: current_node = current_node.get_successor(input_val) + if current_node is None: + return None + output = current_node.output if output is None: return None @@ -252,12 +255,17 @@ def get_outputs(self, basis_state, inputs: list) -> list | None: for input_val in inputs: if self.automaton_type == 'mealy': output = current_node.get_output(input_val) + current_node = current_node.get_successor(input_val) else: + current_node = current_node.get_successor(input_val) + if current_node is None: + return None + output = current_node.output + if output is None: return None observation.append(output) - current_node = current_node.get_successor(input_val) return observation @@ -525,6 +533,19 @@ def _answer_ads_from_tree(self, ads: Ads, from_node) -> tuple[list | None, list next_input = ads.next_input(prev_output) if next_input is None: break + + # For DFA/Moore, the root of an Ads is labelled with the tuple() sentinel (see + # Ads.construct_ads): it splits the block on the states' own output, without sending an + # actual input. Previously this branch was missing here (unlike in + # SUL.adaptive_query, which does handle it), so current_node.get_successor(tuple()) + # always returned None and this method always reported "cannot answer from tree" for + # DFA/Moore -- even when the tree already had the answer -- forcing a real SUL query on + # every single ADS-based extension/separation step. Mirror adaptive_query's handling: + # just read the node's own output without moving to a successor or recording an input. + if next_input == tuple() and self.automaton_type != 'mealy': + prev_output = outputs_received[-1] if outputs_received else current_node.output + continue + inputs_sent.append(next_input) if self.automaton_type == 'mealy': diff --git a/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py b/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py index fa75c86850f..53cdaf6aeee 100644 --- a/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py +++ b/aalpy/learning_algs/deterministic_passive/rpni_helper_functions.py @@ -192,10 +192,11 @@ def _get_leaf_nodes(node: RpniNode) -> None: seq = [] if automaton_type == 'mealy' else [root_node.output] curr_node = root_node for i in node.prefix: - curr_node = curr_node.children[i] if automaton_type == 'mealy': seq.append((i, curr_node.output.get(i))) + curr_node = curr_node.children[i] else: + curr_node = curr_node.children[i] seq.append((i, curr_node.output)) paths.append(seq) From 0ae01cb27070fdf75353ab00ef1144b82e0663cf Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:14:13 +0200 Subject: [PATCH 10/25] fix sampling bug and infinite recursion in Score Function --- aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py | 3 ++- aalpy/utils/Sampling.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py b/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py index e385f9b0135..473573c991d 100644 --- a/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py +++ b/aalpy/learning_algs/general_passive/ScoreFunctionsGSM.py @@ -311,7 +311,8 @@ def transform_score(score: Any, transform: Callable) -> Any: if isinstance(score, Callable): return lambda *args: transform(score(*args)) if isinstance(score, ScoreCalculation): - score.score_function = lambda *args: transform(score.score_function(*args)) + original_score_function = score.score_function + score.score_function = lambda *args: transform(original_score_function(*args)) return score return transform(score) diff --git a/aalpy/utils/Sampling.py b/aalpy/utils/Sampling.py index 8745b3e5df7..040c5539907 100644 --- a/aalpy/utils/Sampling.py +++ b/aalpy/utils/Sampling.py @@ -22,7 +22,8 @@ def get_io_traces(automaton: Automaton, input_traces: list) -> list: traces = [] for input_trace in input_traces: - output_trace = automaton.execute_sequence(automaton.initial_state, input_trace) + output_trace = automaton.execute_sequence(automaton.initial_state, input_trace) if input_trace else [] + trace = list(zip(input_trace, output_trace)) if is_moore: trace = [automaton.initial_state.output] + trace From 3e7ecc938ba718eb1e91d085fba7c555fe002e86 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:14:50 +0200 Subject: [PATCH 11/25] Add automata tests --- tests/automata/test_automaton_base.py | 68 +++++ tests/automata/test_dfa.py | 279 ++++++++++++++++++ tests/automata/test_markov_chain.py | 128 ++++++++ tests/automata/test_mdp.py | 151 ++++++++++ tests/automata/test_mealy_machine.py | 146 +++++++++ tests/automata/test_moore_machine.py | 150 ++++++++++ .../test_non_deterministic_moore_machine.py | 150 ++++++++++ tests/automata/test_sevpa.py | 158 ++++++++++ .../automata/test_stochastic_mealy_machine.py | 160 ++++++++++ tests/automata/test_vpa.py | 177 +++++++++++ tests/conftest.py | 15 + tests/oracles/test_baseOracle.py | 2 +- .../test_kWayTransitionCoverageEqOracle.py | 15 +- tests/tests_imports.py | 47 +++ 14 files changed, 1640 insertions(+), 6 deletions(-) create mode 100644 tests/automata/test_automaton_base.py create mode 100644 tests/automata/test_dfa.py create mode 100644 tests/automata/test_markov_chain.py create mode 100644 tests/automata/test_mdp.py create mode 100644 tests/automata/test_mealy_machine.py create mode 100644 tests/automata/test_moore_machine.py create mode 100644 tests/automata/test_non_deterministic_moore_machine.py create mode 100644 tests/automata/test_sevpa.py create mode 100644 tests/automata/test_stochastic_mealy_machine.py create mode 100644 tests/automata/test_vpa.py create mode 100644 tests/conftest.py diff --git a/tests/automata/test_automaton_base.py b/tests/automata/test_automaton_base.py new file mode 100644 index 00000000000..6b647a22ebc --- /dev/null +++ b/tests/automata/test_automaton_base.py @@ -0,0 +1,68 @@ +import unittest + +from aalpy.automata import Dfa, DfaState +from aalpy.base import AutomatonState + + +class TestAutomatonState(unittest.TestCase): + def test_get_diff_and_same_state_transitions(self): + s0 = AutomatonState('s0') + s1 = AutomatonState('s1') + s0.transitions = {'a': s1, 'b': s0, 'c': s0} + + self.assertEqual(sorted(s0.get_diff_state_transitions()), ['a']) + self.assertEqual(sorted(s0.get_same_state_transitions()), ['b', 'c']) + + def test_all_self_loops(self): + s0 = AutomatonState('s0') + s0.transitions = {'a': s0, 'b': s0} + + self.assertEqual(s0.get_diff_state_transitions(), []) + self.assertEqual(sorted(s0.get_same_state_transitions()), ['a', 'b']) + + def test_all_different_states(self): + s0 = AutomatonState('s0') + s1 = AutomatonState('s1') + s2 = AutomatonState('s2') + s0.transitions = {'a': s1, 'b': s2} + + self.assertEqual(sorted(s0.get_diff_state_transitions()), ['a', 'b']) + self.assertEqual(s0.get_same_state_transitions(), []) + + def test_no_transitions(self): + s0 = AutomatonState('s0') + s0.transitions = {} + + self.assertEqual(s0.get_diff_state_transitions(), []) + self.assertEqual(s0.get_same_state_transitions(), []) + + def test_prefix_defaults_to_none(self): + s0 = AutomatonState('s0') + self.assertIsNone(s0.prefix) + + +class TestAutomatonGeneric(unittest.TestCase): + def test_size_property(self): + q0 = DfaState('q0', True) + q1 = DfaState('q1', False) + q0.transitions = {'a': q1} + q1.transitions = {'a': q0} + dfa = Dfa(q0, [q0, q1]) + self.assertEqual(dfa.size, 2) + + def test_current_state_initialized_to_initial_state(self): + q0 = DfaState('q0', True) + q0.transitions = {'a': q0} + dfa = Dfa(q0, [q0]) + self.assertIs(dfa.current_state, dfa.initial_state) + + def test_str_returns_string_representation(self): + q0 = DfaState('q0', True) + q0.transitions = {'a': q0} + dfa = Dfa(q0, [q0]) + self.assertIsInstance(str(dfa), str) + self.assertIn('q0', str(dfa)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_dfa.py b/tests/automata/test_dfa.py new file mode 100644 index 00000000000..126a8d7550c --- /dev/null +++ b/tests/automata/test_dfa.py @@ -0,0 +1,279 @@ +import pickle +import unittest + +from aalpy.automata import Dfa, DfaState + + +def parity_dfa(): + """2-state complete, minimal DFA accepting words with an even number of 'a's.""" + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + dfa = Dfa(q0, [q0, q1]) + dfa.compute_prefixes() + return dfa, q0, q1 + + +def contains_a_dfa_non_minimal(): + """3-state DFA accepting words containing at least one 'a'; q1 and q2 are equivalent.""" + q0 = DfaState('q0', is_accepting=False) + q1 = DfaState('q1', is_accepting=True) + q2 = DfaState('q2', is_accepting=True) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q2, 'b': q2} + q2.transitions = {'a': q2, 'b': q2} + dfa = Dfa(q0, [q0, q1, q2]) + dfa.compute_prefixes() + return dfa, q0, q1, q2 + + +class TestDfaState(unittest.TestCase): + def test_output_property_mirrors_is_accepting(self): + state = DfaState('s', is_accepting=True) + self.assertTrue(state.output) + state.is_accepting = False + self.assertFalse(state.output) + + def test_default_not_accepting(self): + state = DfaState('s') + self.assertFalse(state.is_accepting) + + +class TestDfaStep(unittest.TestCase): + def test_step_transitions_and_reports_acceptance(self): + dfa, q0, q1 = parity_dfa() + self.assertTrue(dfa.step('a') is False) # moved to q1 (non-accepting) + self.assertIs(dfa.current_state, q1) + self.assertTrue(dfa.step('a') is True) # back to q0 (accepting) + self.assertIs(dfa.current_state, q0) + + def test_step_none_does_not_move_but_reports_current_acceptance(self): + dfa, q0, q1 = parity_dfa() + dfa.step('a') + self.assertIs(dfa.current_state, q1) + result = dfa.step(None) + self.assertFalse(result) + self.assertIs(dfa.current_state, q1) + + def test_step_with_unknown_letter_raises(self): + dfa, _, _ = parity_dfa() + with self.assertRaises(KeyError): + dfa.step('unknown_letter') + + def test_reset_to_initial(self): + dfa, q0, _ = parity_dfa() + dfa.step('a') + dfa.step('a') + dfa.step('a') + self.assertIsNot(dfa.current_state, q0) + dfa.reset_to_initial() + self.assertIs(dfa.current_state, q0) + + +class TestDfaExecuteAndOutputSeq(unittest.TestCase): + def test_execute_sequence_matches_stepwise(self): + dfa, q0, q1 = parity_dfa() + result = dfa.execute_sequence(q0, ['a', 'a', 'b', 'a']) + self.assertEqual(result, [False, True, True, False]) + + def test_execute_sequence_empty_returns_state_output(self): + dfa, q0, q1 = parity_dfa() + result = dfa.execute_sequence(q1, []) + self.assertFalse(result) + self.assertIs(dfa.current_state, q1) + + def test_compute_output_seq_empty(self): + dfa, q0, q1 = parity_dfa() + self.assertEqual(dfa.compute_output_seq(q1, []), [False]) + + def test_compute_output_seq_does_not_mutate_current_state(self): + dfa, q0, q1 = parity_dfa() + dfa.reset_to_initial() + dfa.compute_output_seq(q1, ['a', 'a', 'a']) + self.assertIs(dfa.current_state, q0) + + +class TestDfaStructuralQueries(unittest.TestCase): + def test_size(self): + dfa, *_ = parity_dfa() + self.assertEqual(dfa.size, 2) + + def test_get_input_alphabet(self): + dfa, *_ = parity_dfa() + self.assertEqual(set(dfa.get_input_alphabet()), {'a', 'b'}) + + def test_get_state_by_id(self): + dfa, q0, q1 = parity_dfa() + self.assertIs(dfa.get_state_by_id('q1'), q1) + self.assertIsNone(dfa.get_state_by_id('does_not_exist')) + + def test_is_input_complete_true(self): + dfa, *_ = parity_dfa() + self.assertTrue(dfa.is_input_complete()) + + def test_is_input_complete_false(self): + q0 = DfaState('q0') + q1 = DfaState('q1') + q0.transitions = {'a': q1} # missing 'b' + q1.transitions = {'a': q1, 'b': q1} + dfa = Dfa(q0, [q0, q1]) + self.assertFalse(dfa.is_input_complete()) + + def test_get_shortest_path_same_state(self): + dfa, q0, _ = parity_dfa() + self.assertEqual(dfa.get_shortest_path(q0, q0), ()) + + def test_get_shortest_path_reachable(self): + dfa, q0, q1 = parity_dfa() + path = dfa.get_shortest_path(q0, q1) + self.assertEqual(dfa.execute_sequence(q0, list(path)), [False] * (len(path) - 1) + [False]) + # following the path from q0 must land exactly on q1 + self.assertIs(dfa.current_state, q1) + + def test_get_shortest_path_unreachable_returns_none(self): + dfa, q0, q1 = parity_dfa() + unreachable = DfaState('isolated') + unreachable.transitions = {'a': unreachable, 'b': unreachable} + dfa.states.append(unreachable) + self.assertIsNone(dfa.get_shortest_path(q0, unreachable)) + + def test_get_shortest_path_state_not_in_automaton_warns_and_returns_none(self): + dfa, q0, _ = parity_dfa() + foreign = DfaState('foreign') + with self.assertWarns(UserWarning): + result = dfa.get_shortest_path(q0, foreign) + self.assertIsNone(result) + + def test_is_strongly_connected_true(self): + dfa, *_ = parity_dfa() + self.assertTrue(dfa.is_strongly_connected()) + + def test_is_strongly_connected_false(self): + dfa, q0, q1, q2 = contains_a_dfa_non_minimal() + # once we leave q0 we can never return to it + self.assertFalse(dfa.is_strongly_connected()) + + def test_is_strongly_connected_single_state(self): + q0 = DfaState('q0', is_accepting=True) + q0.transitions = {'a': q0} + dfa = Dfa(q0, [q0]) + self.assertTrue(dfa.is_strongly_connected()) + + +class TestDfaCharacterizationSet(unittest.TestCase): + def test_is_minimal_true_for_minimal_dfa(self): + dfa, *_ = parity_dfa() + self.assertTrue(dfa.is_minimal()) + + def test_is_minimal_false_for_redundant_states(self): + dfa, *_ = contains_a_dfa_non_minimal() + self.assertFalse(dfa.is_minimal()) + + def test_compute_characterization_set_distinguishes_all_states(self): + dfa, q0, q1 = parity_dfa() + char_set = dfa.compute_characterization_set() + self.assertIsNotNone(char_set) + outputs = {tuple(tuple(dfa.compute_output_seq(s, list(seq))) for seq in char_set) for s in (q0, q1)} + self.assertEqual(len(outputs), 2) + + def test_compute_characterization_set_return_same_states_for_non_minimal(self): + dfa, q0, q1, q2 = contains_a_dfa_non_minimal() + s1, s2 = dfa.compute_characterization_set(return_same_states=True) + self.assertEqual({s1, s2}, {q1, q2}) + + def test_compute_characterization_set_return_same_states_none_for_minimal(self): + dfa, *_ = parity_dfa() + s1, s2 = dfa.compute_characterization_set(return_same_states=True) + self.assertIsNone(s1) + self.assertIsNone(s2) + + +class TestDfaMinimize(unittest.TestCase): + def test_minimize_reduces_redundant_states(self): + dfa, *_ = contains_a_dfa_non_minimal() + dfa.minimize() + self.assertEqual(dfa.size, 2) + + def test_minimize_preserves_language(self): + dfa, q0, q1, q2 = contains_a_dfa_non_minimal() + words = [[], ['b'], ['a'], ['b', 'b'], ['a', 'b', 'a'], ['b', 'a', 'a']] + expected = {tuple(w): dfa.execute_sequence(q0, w) for w in words} + dfa.minimize() + for w, exp in expected.items(): + self.assertEqual(dfa.execute_sequence(dfa.initial_state, list(w)), exp) + + def test_minimize_noop_on_already_minimal_dfa(self): + dfa, *_ = parity_dfa() + dfa.minimize() + self.assertEqual(dfa.size, 2) + + def test_minimize_warns_on_incomplete_automaton(self): + q0 = DfaState('q0') + q1 = DfaState('q1') + q0.transitions = {'a': q1} + dfa = Dfa(q0, [q0, q1]) + with self.assertWarns(UserWarning): + dfa.minimize() + # nothing should have been merged + self.assertEqual(dfa.size, 2) + + +class TestDfaStateSetupRoundtrip(unittest.TestCase): + def test_to_state_setup_from_state_setup_roundtrip(self): + dfa, q0, q1 = parity_dfa() + setup = dfa.to_state_setup() + rebuilt = Dfa.from_state_setup(setup) + + for w in [[], ['a'], ['b'], ['a', 'a'], ['a', 'b', 'a', 'a']]: + self.assertEqual(rebuilt.execute_sequence(rebuilt.initial_state, w), + dfa.execute_sequence(dfa.initial_state, w)) + + def test_from_state_setup_first_key_is_initial_state(self): + setup = { + 'a': (True, {'x': 'b', 'y': 'a'}), + 'b': (False, {'x': 'a', 'y': 'b'}), + } + dfa = Dfa.from_state_setup(setup) + self.assertEqual(dfa.initial_state.state_id, 'a') + self.assertTrue(dfa.initial_state.is_accepting) + + def test_copy_produces_independent_deep_copy(self): + dfa, q0, q1 = parity_dfa() + dfa_copy = dfa.copy() + self.assertEqual(dfa.size, dfa_copy.size) + # mutating the copy must not affect the original + dfa_copy.get_state_by_id('q0').is_accepting = False + self.assertTrue(dfa.get_state_by_id('q0').is_accepting) + + def test_pickle_roundtrip(self): + dfa, q0, q1 = parity_dfa() + restored = pickle.loads(pickle.dumps(dfa)) + for w in [[], ['a'], ['b', 'a']]: + self.assertEqual(restored.execute_sequence(restored.initial_state, w), + dfa.execute_sequence(dfa.initial_state, w)) + + +class TestDfaEquality(unittest.TestCase): + def test_eq_true_for_bisimilar_automata_with_different_structure(self): + minimal, *_ = parity_dfa() + non_minimal, *_ = contains_a_dfa_non_minimal() + + # build a differently-labeled but equivalent 2-state DFA for the 'contains a' language + r0 = DfaState('r0', is_accepting=False) + r1 = DfaState('r1', is_accepting=True) + r0.transitions = {'a': r1, 'b': r0} + r1.transitions = {'a': r1, 'b': r1} + relabeled = Dfa(r0, [r0, r1]) + + # non_minimal has states named differently but same language as `relabeled` + self.assertEqual(non_minimal, relabeled) + + def test_eq_false_for_different_languages(self): + parity, *_ = parity_dfa() + contains_a, *_ = contains_a_dfa_non_minimal() + self.assertNotEqual(parity, contains_a) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_markov_chain.py b/tests/automata/test_markov_chain.py new file mode 100644 index 00000000000..39be0689184 --- /dev/null +++ b/tests/automata/test_markov_chain.py @@ -0,0 +1,128 @@ +import random +import unittest + +from aalpy.automata import MarkovChain, McState + + +def deterministic_chain(): + """s0(A) -[1.0]-> s1(B) -[1.0]-> s2(C), s2 is terminal (no outgoing transitions).""" + s0 = McState('s0', output='A') + s1 = McState('s1', output='B') + s2 = McState('s2', output='C') + s0.transitions.append((s1, 1.0)) + s1.transitions.append((s2, 1.0)) + return MarkovChain(s0, [s0, s1, s2]), s0, s1, s2 + + +def branching_chain(): + s0 = McState('s0', output='A') + s1 = McState('s1', output='B') + s2 = McState('s2', output='C') + s0.transitions.append((s1, 0.5)) + s0.transitions.append((s2, 0.5)) + return MarkovChain(s0, [s0, s1, s2]), s0, s1, s2 + + +class TestMcState(unittest.TestCase): + def test_default_transitions_is_empty_list(self): + state = McState('s', output='A') + self.assertEqual(state.transitions, []) + + +class TestMarkovChainStep(unittest.TestCase): + def test_step_moves_and_returns_output(self): + mc, s0, s1, s2 = deterministic_chain() + self.assertEqual(mc.step(), 'B') + self.assertIs(mc.current_state, s1) + + def test_step_on_terminal_state_returns_output_without_moving(self): + mc, s0, s1, s2 = deterministic_chain() + mc.step() + mc.step() + self.assertIs(mc.current_state, s2) + # s2 has no outgoing transitions, so stepping again is a no-op + self.assertEqual(mc.step(), 'C') + self.assertIs(mc.current_state, s2) + + def test_reset_to_initial(self): + mc, s0, s1, s2 = deterministic_chain() + mc.step() + self.assertIsNot(mc.current_state, s0) + mc.reset_to_initial() + self.assertIs(mc.current_state, s0) + + def test_step_respects_branching_distribution(self): + mc, s0, s1, s2 = branching_chain() + for seed in range(20): + mc.reset_to_initial() + random.seed(seed) + output = mc.step() + self.assertIn(output, ('B', 'C')) + self.assertIn(mc.current_state, (s1, s2)) + + +class TestMarkovChainExecuteSequence(unittest.TestCase): + def test_execute_sequence_matches_stepwise(self): + mc, s0, s1, s2 = deterministic_chain() + # MarkovChain.step() ignores its argument; only the sequence's length matters + result = mc.execute_sequence(s0, [None, None]) + self.assertEqual(result, ['B', 'C']) + self.assertIs(mc.current_state, s2) + + def test_execute_sequence_empty_returns_empty_list(self): + mc, s0, s1, s2 = deterministic_chain() + self.assertEqual(mc.execute_sequence(s0, []), []) + + def test_execute_sequence_resets_to_origin_state_first(self): + mc, s0, s1, s2 = deterministic_chain() + mc.reset_to_initial() + mc.step() # move to s1 + result = mc.execute_sequence(s0, [None]) + self.assertEqual(result, ['B']) + + +class TestMarkovChainStepTo(unittest.TestCase): + def test_step_to_moves_to_matching_output_state(self): + mc, s0, s1, s2 = branching_chain() + result = mc.step_to('C') + self.assertEqual(result, 'C') + self.assertIs(mc.current_state, s2) + + def test_step_to_returns_none_for_unreachable_output(self): + mc, s0, s1, s2 = branching_chain() + result = mc.step_to('does_not_exist') + self.assertIsNone(result) + self.assertIs(mc.current_state, s0) # state unchanged on failed step_to + + +class TestMarkovChainUnimplemented(unittest.TestCase): + def test_to_state_setup_not_implemented(self): + mc, *_ = deterministic_chain() + with self.assertRaises(NotImplementedError): + mc.to_state_setup() + + def test_from_state_setup_not_implemented(self): + with self.assertRaises(NotImplementedError): + MarkovChain.from_state_setup({}) + + def test_copy_not_implemented(self): + mc, *_ = deterministic_chain() + with self.assertRaises(NotImplementedError): + mc.copy() + + def test_get_input_alphabet_not_supported(self): + # McState.transitions is a plain list (not a dict), so the generic Automaton.get_input_alphabet + # (which does state.transitions.keys()) cannot work on a MarkovChain. Documents current behaviour. + mc, *_ = deterministic_chain() + with self.assertRaises(AttributeError): + mc.get_input_alphabet() + + +class TestMarkovChainStructural(unittest.TestCase): + def test_size(self): + mc, *_ = deterministic_chain() + self.assertEqual(mc.size, 3) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_mdp.py b/tests/automata/test_mdp.py new file mode 100644 index 00000000000..35b24c1feb1 --- /dev/null +++ b/tests/automata/test_mdp.py @@ -0,0 +1,151 @@ +import random +import unittest + +from aalpy.automata import Mdp, MdpState + + +def deterministic_mdp(): + """ + 2-state MDP over alphabet {a, b} with only probability-1.0 transitions, so behaviour is deterministic. + s0(A) --a[1.0]--> s1 s0 --b[1.0]--> s0 + s1(B) --a[1.0]--> s0 s1 --b[1.0]--> s1 + """ + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s0.transitions['a'].append((s1, 1.0)) + s0.transitions['b'].append((s0, 1.0)) + s1.transitions['a'].append((s0, 1.0)) + s1.transitions['b'].append((s1, 1.0)) + return Mdp(s0, [s0, s1]), s0, s1 + + +def branching_mdp(): + """s0 --a--> s1 (0.5) or s2 (0.5).""" + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s2 = MdpState('s2', output='C') + s0.transitions['a'].append((s1, 0.5)) + s0.transitions['a'].append((s2, 0.5)) + return Mdp(s0, [s0, s1, s2]), s0, s1, s2 + + +class TestMdpState(unittest.TestCase): + def test_default_transitions_is_empty_defaultdict(self): + state = MdpState('s') + self.assertEqual(state.transitions['unused_key'], []) + + def test_default_output_is_none(self): + state = MdpState('s') + self.assertIsNone(state.output) + + +class TestMdpStep(unittest.TestCase): + def test_step_moves_and_returns_output(self): + mdp, s0, s1 = deterministic_mdp() + self.assertEqual(mdp.step('a'), 'B') + self.assertIs(mdp.current_state, s1) + + def test_step_none_returns_current_output_without_moving(self): + mdp, s0, s1 = deterministic_mdp() + mdp.step('a') + self.assertEqual(mdp.step(None), 'B') + self.assertIs(mdp.current_state, s1) + + def test_step_unknown_letter_raises_on_empty_distribution(self): + mdp, *_ = deterministic_mdp() + with self.assertRaises(IndexError): + mdp.step('unknown_letter') + + def test_reset_to_initial(self): + mdp, s0, s1 = deterministic_mdp() + mdp.step('a') + self.assertIsNot(mdp.current_state, s0) + mdp.reset_to_initial() + self.assertIs(mdp.current_state, s0) + + def test_step_respects_branching_distribution(self): + mdp, s0, s1, s2 = branching_mdp() + for seed in range(20): + mdp.reset_to_initial() + random.seed(seed) + output = mdp.step('a') + self.assertIn(output, ('B', 'C')) + self.assertIn(mdp.current_state, (s1, s2)) + + +class TestMdpExecuteSequence(unittest.TestCase): + def test_execute_sequence_matches_stepwise(self): + mdp, s0, s1 = deterministic_mdp() + result = mdp.execute_sequence(s0, ['a', 'b', 'a', 'a']) + self.assertEqual(result, ['B', 'B', 'A', 'B']) + self.assertIs(mdp.current_state, s1) + + def test_execute_sequence_empty_returns_empty_list(self): + mdp, s0, s1 = deterministic_mdp() + self.assertEqual(mdp.execute_sequence(s0, []), []) + + def test_execute_sequence_resets_to_origin_state_first(self): + mdp, s0, s1 = deterministic_mdp() + mdp.reset_to_initial() + mdp.step('a') # move to s1 + result = mdp.execute_sequence(s0, ['a']) + # regardless of where the mdp was, execute_sequence starts fresh from origin_state + self.assertEqual(result, ['B']) + + +class TestMdpStepTo(unittest.TestCase): + def test_step_to_moves_to_matching_output_state(self): + mdp, s0, s1, s2 = branching_mdp() + result = mdp.step_to('a', 'C') + self.assertEqual(result, 'C') + self.assertIs(mdp.current_state, s2) + + def test_step_to_returns_none_for_unreachable_output(self): + mdp, s0, s1, s2 = branching_mdp() + result = mdp.step_to('a', 'does_not_exist') + self.assertIsNone(result) + self.assertIs(mdp.current_state, s0) # state unchanged on failed step_to + + +class TestMdpStateSetupRoundtrip(unittest.TestCase): + def test_to_state_setup_from_state_setup_roundtrip(self): + mdp, s0, s1 = deterministic_mdp() + setup = mdp.to_state_setup() + rebuilt = Mdp.from_state_setup(setup) + + for w in [[], ['a'], ['b'], ['a', 'a', 'b']]: + rebuilt.reset_to_initial() + outputs = [rebuilt.step(letter) for letter in w] + mdp.reset_to_initial() + expected = [mdp.step(letter) for letter in w] + self.assertEqual(outputs, expected) + + def test_from_state_setup_first_key_is_initial_state(self): + setup = { + 's0': ('A', {'a': [('s1', 1.0)]}), + 's1': ('B', {'a': [('s0', 1.0)]}), + } + mdp = Mdp.from_state_setup(setup) + self.assertEqual(mdp.initial_state.state_id, 's0') + self.assertEqual(mdp.initial_state.output, 'A') + + def test_to_state_setup_puts_initial_state_first(self): + mdp, s0, s1 = deterministic_mdp() + # reorder states so initial_state is not first in the list + mdp.states = [s1, s0] + mdp.to_state_setup() + self.assertIs(mdp.states[0], s0) + + +class TestMdpStructural(unittest.TestCase): + def test_get_input_alphabet(self): + mdp, *_ = deterministic_mdp() + self.assertEqual(set(mdp.get_input_alphabet()), {'a', 'b'}) + + def test_size(self): + mdp, *_ = deterministic_mdp() + self.assertEqual(mdp.size, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_mealy_machine.py b/tests/automata/test_mealy_machine.py new file mode 100644 index 00000000000..f92dbc460bc --- /dev/null +++ b/tests/automata/test_mealy_machine.py @@ -0,0 +1,146 @@ +import pickle +import unittest + +from aalpy.automata import MealyMachine, MealyState + + +def sample_mealy(): + """ + 2-state Mealy machine over alphabet {x, y}. + s0 --x/o1--> s1 s0 --y/o2--> s0 + s1 --x/o3--> s0 s1 --y/o1--> s1 + """ + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'x': s1, 'y': s0} + s0.output_fun = {'x': 'o1', 'y': 'o2'} + s1.transitions = {'x': s0, 'y': s1} + s1.output_fun = {'x': 'o3', 'y': 'o1'} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm, s0, s1 + + +class TestMealyState(unittest.TestCase): + def test_defaults_are_empty(self): + state = MealyState('s') + self.assertEqual(state.transitions, {}) + self.assertEqual(state.output_fun, {}) + + +class TestMealyStep(unittest.TestCase): + def test_step_returns_output_and_moves_state(self): + mm, s0, s1 = sample_mealy() + output = mm.step('x') + self.assertEqual(output, 'o1') + self.assertIs(mm.current_state, s1) + + def test_step_sequence(self): + mm, s0, s1 = sample_mealy() + outputs = [mm.step(i) for i in ['x', 'x', 'y', 'x']] + self.assertEqual(outputs, ['o1', 'o3', 'o2', 'o1']) + + def test_step_unknown_letter_raises(self): + mm, *_ = sample_mealy() + with self.assertRaises(KeyError): + mm.step('z') + + +class TestMealyExecuteAndOutputSeq(unittest.TestCase): + def test_execute_sequence(self): + mm, s0, s1 = sample_mealy() + result = mm.execute_sequence(s0, ['x', 'y', 'x']) + self.assertEqual(result, ['o1', 'o1', 'o3']) + + def test_execute_sequence_empty_returns_empty_list(self): + mm, s0, _ = sample_mealy() + self.assertEqual(mm.execute_sequence(s0, []), []) + + def test_compute_output_seq_does_not_mutate_current_state(self): + mm, s0, s1 = sample_mealy() + mm.reset_to_initial() + mm.compute_output_seq(s1, ['x', 'x']) + self.assertIs(mm.current_state, s0) + + +class TestMealyStructural(unittest.TestCase): + def test_get_input_alphabet(self): + mm, *_ = sample_mealy() + self.assertEqual(set(mm.get_input_alphabet()), {'x', 'y'}) + + def test_is_input_complete_true(self): + mm, *_ = sample_mealy() + self.assertTrue(mm.is_input_complete()) + + def test_is_input_complete_false(self): + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'x': s1} + s0.output_fun = {'x': 'o1'} + s1.transitions = {'x': s1, 'y': s1} + s1.output_fun = {'x': 'o1', 'y': 'o1'} + mm = MealyMachine(s0, [s0, s1]) + self.assertFalse(mm.is_input_complete()) + + def test_find_distinguishing_seq(self): + mm, s0, s1 = sample_mealy() + seq = mm.find_distinguishing_seq(s0, s1, mm.get_input_alphabet()) + self.assertIsNotNone(seq) + self.assertNotEqual(mm.compute_output_seq(s0, seq), mm.compute_output_seq(s1, seq)) + + def test_find_distinguishing_seq_same_state_is_none(self): + mm, s0, _ = sample_mealy() + self.assertIsNone(mm.find_distinguishing_seq(s0, s0, mm.get_input_alphabet())) + + def test_is_minimal(self): + mm, *_ = sample_mealy() + self.assertTrue(mm.is_minimal()) + + +class TestMealyStateSetupRoundtrip(unittest.TestCase): + def test_to_state_setup_from_state_setup_roundtrip(self): + mm, s0, s1 = sample_mealy() + setup = mm.to_state_setup() + rebuilt = MealyMachine.from_state_setup(setup) + + for w in [[], ['x'], ['y'], ['x', 'x', 'y'], ['y', 'x', 'x', 'y']]: + self.assertEqual(rebuilt.execute_sequence(rebuilt.initial_state, w), + mm.execute_sequence(mm.initial_state, w)) + + def test_copy_is_independent(self): + mm, s0, s1 = sample_mealy() + mm_copy = mm.copy() + mm_copy.get_state_by_id('s0').output_fun['x'] = 'CHANGED' + self.assertEqual(mm.get_state_by_id('s0').output_fun['x'], 'o1') + + def test_pickle_roundtrip(self): + mm, *_ = sample_mealy() + restored = pickle.loads(pickle.dumps(mm)) + for w in [[], ['x'], ['y', 'x']]: + self.assertEqual(restored.execute_sequence(restored.initial_state, w), + mm.execute_sequence(mm.initial_state, w)) + + +class TestMealyEquality(unittest.TestCase): + def test_eq_true_for_relabeled_equivalent_machine(self): + mm, *_ = sample_mealy() + + t0 = MealyState('t0') + t1 = MealyState('t1') + t0.transitions = {'x': t1, 'y': t0} + t0.output_fun = {'x': 'o1', 'y': 'o2'} + t1.transitions = {'x': t0, 'y': t1} + t1.output_fun = {'x': 'o3', 'y': 'o1'} + relabeled = MealyMachine(t0, [t0, t1]) + + self.assertEqual(mm, relabeled) + + def test_eq_false_for_different_outputs(self): + mm, *_ = sample_mealy() + other = mm.copy() + other.get_state_by_id('s0').output_fun['x'] = 'different_output' + self.assertNotEqual(mm, other) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_moore_machine.py b/tests/automata/test_moore_machine.py new file mode 100644 index 00000000000..9777cb9d576 --- /dev/null +++ b/tests/automata/test_moore_machine.py @@ -0,0 +1,150 @@ +import pickle +import unittest + +from aalpy.automata import Dfa, MooreMachine, MooreState + + +def sample_moore(): + """ + 3-state Moore machine over alphabet {x, y}, outputs 'A', 'B', 'C' per state. + s0(A) --x--> s1(B) s0 --y--> s0 + s1(B) --x--> s2(C) s1 --y--> s0 + s2(C) --x--> s2 s2 --y--> s0 + """ + s0 = MooreState('s0', output='A') + s1 = MooreState('s1', output='B') + s2 = MooreState('s2', output='C') + s0.transitions = {'x': s1, 'y': s0} + s1.transitions = {'x': s2, 'y': s0} + s2.transitions = {'x': s2, 'y': s0} + mm = MooreMachine(s0, [s0, s1, s2]) + mm.compute_prefixes() + return mm, s0, s1, s2 + + +def boolean_moore(): + s0 = MooreState('s0', output=True) + s1 = MooreState('s1', output=False) + s0.transitions = {'a': s1, 'b': s0} + s1.transitions = {'a': s0, 'b': s1} + mm = MooreMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm, s0, s1 + + +class TestMooreStep(unittest.TestCase): + def test_step_returns_output_of_reached_state(self): + mm, s0, s1, s2 = sample_moore() + self.assertEqual(mm.step('x'), 'B') + self.assertIs(mm.current_state, s1) + + def test_step_none_returns_current_output_without_moving(self): + mm, s0, s1, s2 = sample_moore() + mm.step('x') + self.assertEqual(mm.step(None), 'B') + self.assertIs(mm.current_state, s1) + + def test_step_unknown_letter_raises(self): + mm, *_ = sample_moore() + with self.assertRaises(KeyError): + mm.step('z') + + +class TestMooreExecuteAndOutputSeq(unittest.TestCase): + def test_execute_sequence(self): + mm, s0, s1, s2 = sample_moore() + result = mm.execute_sequence(s0, ['x', 'x', 'y', 'x']) + self.assertEqual(result, ['B', 'C', 'A', 'B']) + + def test_execute_sequence_empty_returns_state_output(self): + mm, s0, s1, s2 = sample_moore() + self.assertEqual(mm.execute_sequence(s1, []), 'B') + self.assertIs(mm.current_state, s1) + + def test_compute_output_seq_empty(self): + mm, s0, s1, s2 = sample_moore() + self.assertEqual(mm.compute_output_seq(s2, []), ['C']) + + def test_compute_output_seq_does_not_mutate_current_state(self): + mm, s0, s1, s2 = sample_moore() + mm.reset_to_initial() + mm.compute_output_seq(s2, ['x', 'y']) + self.assertIs(mm.current_state, s0) + + +class TestMooreCharacterizationSet(unittest.TestCase): + def test_is_minimal_true(self): + mm, *_ = sample_moore() + self.assertTrue(mm.is_minimal()) + + def test_is_minimal_false_for_redundant_states(self): + s0 = MooreState('s0', output='A') + s1 = MooreState('s1', output='B') + s2 = MooreState('s2', output='B') # equivalent to s1 + s0.transitions = {'x': s1, 'y': s0} + s1.transitions = {'x': s2, 'y': s2} + s2.transitions = {'x': s2, 'y': s2} + mm = MooreMachine(s0, [s0, s1, s2]) + self.assertFalse(mm.is_minimal()) + + +class TestMooreStateSetupRoundtrip(unittest.TestCase): + def test_to_state_setup_from_state_setup_roundtrip(self): + mm, s0, s1, s2 = sample_moore() + setup = mm.to_state_setup() + rebuilt = MooreMachine.from_state_setup(setup) + + for w in [[], ['x'], ['x', 'x'], ['x', 'x', 'y', 'x']]: + self.assertEqual(rebuilt.execute_sequence(rebuilt.initial_state, w), + mm.execute_sequence(mm.initial_state, w)) + + def test_pickle_roundtrip(self): + mm, *_ = sample_moore() + restored = pickle.loads(pickle.dumps(mm)) + for w in [[], ['x'], ['y', 'x']]: + self.assertEqual(restored.execute_sequence(restored.initial_state, w), + mm.execute_sequence(mm.initial_state, w)) + + +class TestMooreToDfa(unittest.TestCase): + def test_to_dfa_preserves_language(self): + mm, s0, s1 = boolean_moore() + dfa = MooreMachine.to_dfa(mm) + self.assertIsInstance(dfa, Dfa) + for w in [[], ['a'], ['b'], ['a', 'a'], ['a', 'b', 'a']]: + moore_outputs = mm.execute_sequence(mm.initial_state, w) + dfa_result = dfa.execute_sequence(dfa.initial_state, w) + self.assertEqual(dfa_result, moore_outputs) + + def test_to_dfa_rejects_non_boolean_outputs(self): + mm, *_ = sample_moore() # outputs are strings, not bool + with self.assertRaises(ValueError): + MooreMachine.to_dfa(mm) + + def test_to_dfa_preserves_state_count(self): + mm, *_ = boolean_moore() + dfa = MooreMachine.to_dfa(mm) + self.assertEqual(dfa.size, mm.size) + + +class TestMooreEquality(unittest.TestCase): + def test_eq_true_for_relabeled_equivalent_machine(self): + mm, *_ = boolean_moore() + + t0 = MooreState('t0', output=True) + t1 = MooreState('t1', output=False) + t0.transitions = {'a': t1, 'b': t0} + t1.transitions = {'a': t0, 'b': t1} + relabeled = MooreMachine(t0, [t0, t1]) + + self.assertEqual(mm, relabeled) + + def test_eq_false_for_different_outputs(self): + mm, *_ = boolean_moore() + other = mm.copy() + other.get_state_by_id('s0').output = False + self.assertNotEqual(mm, other) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_non_deterministic_moore_machine.py b/tests/automata/test_non_deterministic_moore_machine.py new file mode 100644 index 00000000000..2c6f24e4844 --- /dev/null +++ b/tests/automata/test_non_deterministic_moore_machine.py @@ -0,0 +1,150 @@ +import random +import unittest + +from aalpy.automata import NDMooreMachine, NDMooreState + + +def deterministic_ndmoore(): + """ + 2-state NDMoore machine over alphabet {a, b} where every input has a single successor. + s0(A) --a--> s1 s0 --b--> s0 + s1(B) --a--> s0 s1 --b--> s1 + """ + s0 = NDMooreState('s0', output='A') + s1 = NDMooreState('s1', output='B') + s0.transitions['a'].append(s1) + s0.transitions['b'].append(s0) + s1.transitions['a'].append(s0) + s1.transitions['b'].append(s1) + return NDMooreMachine(s0, [s0, s1]), s0, s1 + + +def branching_ndmoore(): + """s0 --a--> s1 or s2 (non-deterministic choice).""" + s0 = NDMooreState('s0', output='A') + s1 = NDMooreState('s1', output='B') + s2 = NDMooreState('s2', output='C') + s0.transitions['a'].append(s1) + s0.transitions['a'].append(s2) + return NDMooreMachine(s0, [s0, s1, s2]), s0, s1, s2 + + +class TestNDMooreState(unittest.TestCase): + def test_default_transitions_is_empty_list(self): + state = NDMooreState('s') + self.assertEqual(state.transitions['unused_key'], []) + + def test_default_output_is_none(self): + state = NDMooreState('s') + self.assertIsNone(state.output) + + +class TestNDMooreStep(unittest.TestCase): + def test_step_moves_and_returns_output(self): + mm, s0, s1 = deterministic_ndmoore() + self.assertEqual(mm.step('a'), 'B') + self.assertIs(mm.current_state, s1) + + def test_step_sequence(self): + mm, s0, s1 = deterministic_ndmoore() + outputs = [mm.step(i) for i in ['a', 'b', 'a']] + self.assertEqual(outputs, ['B', 'B', 'A']) + + def test_step_picks_among_non_deterministic_choices(self): + mm, s0, s1, s2 = branching_ndmoore() + seen = set() + for seed in range(30): + mm.reset_to_initial() + random.seed(seed) + output = mm.step('a') + self.assertIn(output, ('B', 'C')) + self.assertIn(mm.current_state, (s1, s2)) + seen.add(output) + # with enough seeds both branches should be exercised at least once + self.assertEqual(seen, {'B', 'C'}) + + def test_step_unknown_letter_raises_on_empty_options(self): + mm, *_ = deterministic_ndmoore() + with self.assertRaises(IndexError): + mm.step('unknown_letter') + + def test_reset_to_initial(self): + mm, s0, s1 = deterministic_ndmoore() + mm.step('a') + self.assertIsNot(mm.current_state, s0) + mm.reset_to_initial() + self.assertIs(mm.current_state, s0) + + +class TestNDMooreExecuteSequence(unittest.TestCase): + def test_execute_sequence_matches_stepwise(self): + mm, s0, s1 = deterministic_ndmoore() + result = mm.execute_sequence(s0, ['a', 'b', 'a']) + self.assertEqual(result, ['B', 'B', 'A']) + self.assertIs(mm.current_state, s0) + + def test_execute_sequence_empty_returns_empty_list(self): + mm, s0, s1 = deterministic_ndmoore() + self.assertEqual(mm.execute_sequence(s0, []), []) + + def test_execute_sequence_resets_to_origin_state_first(self): + mm, s0, s1 = deterministic_ndmoore() + mm.reset_to_initial() + mm.step('a') # move to s1 + result = mm.execute_sequence(s0, ['a']) + self.assertEqual(result, ['B']) + + +class TestNDMooreStateSetup(unittest.TestCase): + def test_to_state_setup_returns_dict(self): + mm, s0, s1 = deterministic_ndmoore() + setup = mm.to_state_setup() + self.assertIsInstance(setup, dict) + self.assertEqual(set(setup.keys()), {'s0', 's1'}) + + def test_to_state_setup_first_key_is_initial_state(self): + mm, s0, s1 = deterministic_ndmoore() + setup = mm.to_state_setup() + self.assertEqual(next(iter(setup)), 's0') + + def test_to_state_setup_from_state_setup_roundtrip(self): + mm, s0, s1 = deterministic_ndmoore() + setup = mm.to_state_setup() + rebuilt = NDMooreMachine.from_state_setup(setup) + + for w in [['a'], ['b'], ['a', 'a', 'b']]: + rebuilt.reset_to_initial() + outputs = [rebuilt.step(letter) for letter in w] + mm.reset_to_initial() + expected = [mm.step(letter) for letter in w] + self.assertEqual(outputs, expected) + + def test_from_state_setup_first_key_is_initial_state(self): + setup = { + 's0': ('A', {'a': ['s1']}), + 's1': ('B', {'a': ['s0']}), + } + mm = NDMooreMachine.from_state_setup(setup) + self.assertEqual(mm.initial_state.state_id, 's0') + self.assertEqual(mm.initial_state.output, 'A') + + def test_copy_produces_independent_deep_copy(self): + mm, s0, s1 = deterministic_ndmoore() + mm_copy = mm.copy() + self.assertEqual(mm.size, mm_copy.size) + mm_copy.get_state_by_id('s0').output = 'CHANGED' + self.assertEqual(mm.get_state_by_id('s0').output, 'A') + + +class TestNDMooreStructural(unittest.TestCase): + def test_get_input_alphabet(self): + mm, *_ = deterministic_ndmoore() + self.assertEqual(set(mm.get_input_alphabet()), {'a', 'b'}) + + def test_size(self): + mm, *_ = deterministic_ndmoore() + self.assertEqual(mm.size, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_sevpa.py b/tests/automata/test_sevpa.py new file mode 100644 index 00000000000..611ca6f42e0 --- /dev/null +++ b/tests/automata/test_sevpa.py @@ -0,0 +1,158 @@ +import unittest + +from aalpy.automata import Sevpa, SevpaAlphabet, SevpaState, SevpaTransition + + +def balanced_parens_sevpa(): + """ + Single-module 1-SEVPA recognizing the Dyck language, built via create_daisy_hypothesis: + call='(' pushes (q0, '('), return ')' pops iff the stack guard matches. + """ + alphabet = SevpaAlphabet(internal_alphabet=[], call_alphabet=['('], return_alphabet=[')']) + q0 = SevpaState('q0', is_accepting=True) + sevpa = Sevpa.create_daisy_hypothesis(q0, alphabet) + return sevpa, q0, alphabet + + +class TestSevpaAlphabet(unittest.TestCase): + def test_get_merged_alphabet(self): + alphabet = SevpaAlphabet(internal_alphabet=['i'], call_alphabet=['c'], return_alphabet=['r']) + self.assertEqual(alphabet.get_merged_alphabet(), ['i', 'c', 'r']) + + +class TestSevpaState(unittest.TestCase): + def test_default_transitions_is_empty_defaultdict(self): + state = SevpaState('s') + self.assertEqual(state.transitions['unused_key'], []) + + def test_default_not_accepting(self): + state = SevpaState('s') + self.assertFalse(state.is_accepting) + + +class TestSevpaStep(unittest.TestCase): + def test_reset_to_initial_returns_true_for_accepting_empty_stack(self): + sevpa, q0, _ = balanced_parens_sevpa() + self.assertTrue(sevpa.reset_to_initial()) + + def test_accepts_empty_word(self): + sevpa, *_ = balanced_parens_sevpa() + sevpa.reset_to_initial() + self.assertTrue(sevpa.step(None)) + + def test_accepts_balanced_word(self): + sevpa, *_ = balanced_parens_sevpa() + sevpa.reset_to_initial() + sevpa.step('(') + result = sevpa.step(')') + self.assertTrue(result) + self.assertEqual(sevpa.stack, [Sevpa.empty]) + + def test_rejects_incomplete_word(self): + sevpa, *_ = balanced_parens_sevpa() + sevpa.reset_to_initial() + result = sevpa.step('(') + self.assertFalse(result) + self.assertEqual(len(sevpa.stack), 2) + + def test_unmatched_return_symbol_traps_in_error_state(self): + sevpa, *_ = balanced_parens_sevpa() + sevpa.reset_to_initial() + result = sevpa.step(')') + self.assertFalse(result) + self.assertTrue(sevpa.error_state_reached) + + # once trapped, further steps stay False without raising + self.assertFalse(sevpa.step('(')) + self.assertFalse(sevpa.step(None)) + + def test_nested_balanced_word(self): + sevpa, *_ = balanced_parens_sevpa() + sevpa.reset_to_initial() + outputs = [sevpa.step(c) for c in '(())'] + self.assertEqual(outputs, [False, False, False, True]) + + def test_reset_to_initial_clears_error_state(self): + sevpa, *_ = balanced_parens_sevpa() + sevpa.reset_to_initial() + sevpa.step(')') + self.assertTrue(sevpa.error_state_reached) + sevpa.reset_to_initial() + self.assertFalse(sevpa.error_state_reached) + + +class TestSevpaStructural(unittest.TestCase): + def test_get_input_alphabet(self): + sevpa, *_ = balanced_parens_sevpa() + alphabet = sevpa.get_input_alphabet() + self.assertEqual(alphabet.call_alphabet, ['(']) + self.assertEqual(alphabet.return_alphabet, [')']) + self.assertEqual(alphabet.internal_alphabet, []) + + def test_get_state_by_id(self): + sevpa, q0, _ = balanced_parens_sevpa() + self.assertIs(sevpa.get_state_by_id('q0'), q0) + self.assertIsNone(sevpa.get_state_by_id('does_not_exist')) + + def test_get_error_state_none_for_single_state_automaton(self): + sevpa, *_ = balanced_parens_sevpa() + # only state present is initial & accepting, so there is no error state candidate + self.assertIsNone(sevpa.get_error_state()) + + def test_get_allowed_call_transitions(self): + sevpa, q0, _ = balanced_parens_sevpa() + allowed = sevpa.get_allowed_call_transitions() + self.assertEqual(allowed['('], {'q0'}) + + +class TestSevpaStateSetupRoundtrip(unittest.TestCase): + def test_to_state_setup_from_state_setup_roundtrip(self): + sevpa, q0, _ = balanced_parens_sevpa() + setup = sevpa.to_state_setup() + rebuilt = Sevpa.from_state_setup(setup, init_state_id='q0') + + for w in [[], ['('], ['(', ')'], ['(', '(', ')', ')']]: + rebuilt.reset_to_initial() + outputs = [rebuilt.step(letter) for letter in w] + sevpa.reset_to_initial() + expected = [sevpa.step(letter) for letter in w] + self.assertEqual(outputs, expected) + + +class TestSevpaExecuteSequence(unittest.TestCase): + def test_execute_sequence_from_initial_state(self): + sevpa, q0, _ = balanced_parens_sevpa() + result = sevpa.execute_sequence(q0, ['(', '(', ')', ')']) + self.assertEqual(result, [False, False, False, True]) + + def test_execute_sequence_from_state_with_different_prefix_raises(self): + sevpa, q0, _ = balanced_parens_sevpa() + foreign = SevpaState('foreign') + foreign.prefix = ('unrelated',) + with self.assertRaises(AssertionError): + sevpa.execute_sequence(foreign, ['(']) + + +class TestSevpaDeleteState(unittest.TestCase): + def test_delete_state_removes_state_and_references(self): + alphabet = SevpaAlphabet(internal_alphabet=['i'], call_alphabet=[], return_alphabet=[]) + q0 = SevpaState('q0', is_accepting=True) + q1 = SevpaState('q1', is_accepting=False) + q0.transitions['i'].append(SevpaTransition(q1, 'i', None)) + q1.transitions['i'].append(SevpaTransition(q1, 'i', None)) + sevpa = Sevpa(q0, [q0, q1]) + + sevpa.delete_state(q1) + + self.assertNotIn(q1, sevpa.states) + self.assertEqual(q0.transitions['i'], []) + + def test_delete_state_none_is_a_no_op(self): + sevpa, q0, _ = balanced_parens_sevpa() + num_states_before = len(sevpa.states) + sevpa.delete_state(None) + self.assertEqual(len(sevpa.states), num_states_before) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_stochastic_mealy_machine.py b/tests/automata/test_stochastic_mealy_machine.py new file mode 100644 index 00000000000..b56a5d904fa --- /dev/null +++ b/tests/automata/test_stochastic_mealy_machine.py @@ -0,0 +1,160 @@ +import random +import unittest + +from aalpy.automata import Mdp, StochasticMealyMachine, StochasticMealyState + + +def deterministic_smm(): + """ + 2-state SMM over alphabet {a, b}, only probability-1.0 transitions. + s0 --a/o1[1.0]--> s1 s0 --b/o2[1.0]--> s0 + s1 --a/o3[1.0]--> s0 s1 --b/o1[1.0]--> s1 + """ + s0 = StochasticMealyState('s0') + s1 = StochasticMealyState('s1') + s0.transitions['a'].append((s1, 'o1', 1.0)) + s0.transitions['b'].append((s0, 'o2', 1.0)) + s1.transitions['a'].append((s0, 'o3', 1.0)) + s1.transitions['b'].append((s1, 'o1', 1.0)) + return StochasticMealyMachine(s0, [s0, s1]), s0, s1 + + +def branching_smm(): + """s0 --a--> (s1, 'o1', 0.5) or (s2, 'o2', 0.5).""" + s0 = StochasticMealyState('s0') + s1 = StochasticMealyState('s1') + s2 = StochasticMealyState('s2') + s0.transitions['a'].append((s1, 'o1', 0.5)) + s0.transitions['a'].append((s2, 'o2', 0.5)) + return StochasticMealyMachine(s0, [s0, s1, s2]), s0, s1, s2 + + +class TestStochasticMealyState(unittest.TestCase): + def test_default_transitions_is_empty_defaultdict(self): + state = StochasticMealyState('s') + self.assertEqual(state.transitions['unused_key'], []) + + +class TestStochasticMealyStep(unittest.TestCase): + def test_step_returns_output_and_moves_state(self): + smm, s0, s1 = deterministic_smm() + output = smm.step('a') + self.assertEqual(output, 'o1') + self.assertIs(smm.current_state, s1) + + def test_step_sequence(self): + smm, s0, s1 = deterministic_smm() + outputs = [smm.step(i) for i in ['a', 'a', 'b']] + self.assertEqual(outputs, ['o1', 'o3', 'o2']) + + def test_step_respects_branching_distribution(self): + smm, s0, s1, s2 = branching_smm() + for seed in range(20): + smm.reset_to_initial() + random.seed(seed) + output = smm.step('a') + self.assertIn(output, ('o1', 'o2')) + self.assertIn(smm.current_state, (s1, s2)) + + def test_reset_to_initial(self): + smm, s0, s1 = deterministic_smm() + smm.step('a') + self.assertIsNot(smm.current_state, s0) + smm.reset_to_initial() + self.assertIs(smm.current_state, s0) + + +class TestStochasticMealyExecuteSequence(unittest.TestCase): + def test_execute_sequence_matches_stepwise(self): + smm, s0, s1 = deterministic_smm() + result = smm.execute_sequence(s0, ['a', 'a', 'b']) + self.assertEqual(result, ['o1', 'o3', 'o2']) + self.assertIs(smm.current_state, s0) + + def test_execute_sequence_empty_returns_empty_list(self): + smm, s0, s1 = deterministic_smm() + self.assertEqual(smm.execute_sequence(s0, []), []) + + def test_execute_sequence_resets_to_origin_state_first(self): + smm, s0, s1 = deterministic_smm() + smm.reset_to_initial() + smm.step('a') # move to s1 + result = smm.execute_sequence(s0, ['a']) + self.assertEqual(result, ['o1']) + + +class TestStochasticMealyStepTo(unittest.TestCase): + def test_step_to_moves_to_matching_output_state(self): + smm, s0, s1, s2 = branching_smm() + result = smm.step_to('a', 'o2') + self.assertEqual(result, 'o2') + self.assertIs(smm.current_state, s2) + + def test_step_to_returns_none_for_unreachable_output(self): + smm, s0, s1, s2 = branching_smm() + result = smm.step_to('a', 'does_not_exist') + self.assertIsNone(result) + self.assertIs(smm.current_state, s0) + + +class TestStochasticMealyStateSetupRoundtrip(unittest.TestCase): + def test_to_state_setup_from_state_setup_roundtrip(self): + smm, s0, s1 = deterministic_smm() + setup = smm.to_state_setup() + rebuilt = StochasticMealyMachine.from_state_setup(setup) + + for w in [['a'], ['b'], ['a', 'a', 'b'], ['b', 'a']]: + rebuilt.reset_to_initial() + outputs = [rebuilt.step(letter) for letter in w] + smm.reset_to_initial() + expected = [smm.step(letter) for letter in w] + self.assertEqual(outputs, expected) + + def test_from_state_setup_first_key_is_initial_state(self): + setup = { + 's0': {'a': [('s1', 'o1', 1.0)]}, + 's1': {'a': [('s0', 'o2', 1.0)]}, + } + smm = StochasticMealyMachine.from_state_setup(setup) + self.assertEqual(smm.initial_state.state_id, 's0') + + def test_to_state_setup_puts_initial_state_first(self): + smm, s0, s1 = deterministic_smm() + smm.states = [s1, s0] + smm.to_state_setup() + self.assertIs(smm.states[0], s0) + + +class TestStochasticMealyToMdp(unittest.TestCase): + def test_to_mdp_returns_mdp(self): + smm, *_ = deterministic_smm() + mdp = smm.to_mdp() + self.assertIsInstance(mdp, Mdp) + + def test_to_mdp_preserves_behavior(self): + smm, s0, s1 = deterministic_smm() + mdp = smm.to_mdp() + + # walk the SMM deterministically and confirm the MDP can reproduce the same output trace + # by following matching outputs via step_to + for w in [['a'], ['b'], ['a', 'a', 'b']]: + smm.reset_to_initial() + smm_outputs = [smm.step(letter) for letter in w] + + mdp.reset_to_initial() + mdp_outputs = [mdp.step_to(letter, out) for letter, out in zip(w, smm_outputs)] + self.assertEqual(mdp_outputs, smm_outputs) + + +class TestStochasticMealyStructural(unittest.TestCase): + def test_get_input_alphabet(self): + smm, *_ = deterministic_smm() + self.assertEqual(set(smm.get_input_alphabet()), {'a', 'b'}) + + def test_size(self): + smm, *_ = deterministic_smm() + self.assertEqual(smm.size, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/automata/test_vpa.py b/tests/automata/test_vpa.py new file mode 100644 index 00000000000..9f65a519d3b --- /dev/null +++ b/tests/automata/test_vpa.py @@ -0,0 +1,177 @@ +import random +import unittest + +from aalpy.automata import Vpa, VpaAlphabet, VpaState, VpaTransition + + +def balanced_parens_vpa(): + """ + Single-state VPA recognizing the Dyck language over '(' (push) / ')' (pop). + q0 is initial and accepting; accepted iff the stack is empty. + """ + q0 = VpaState('q0', is_accepting=True) + q0.transitions['('].append(VpaTransition(q0, q0, '(', 'push', '(')) + q0.transitions[')'].append(VpaTransition(q0, q0, ')', 'pop', '(')) + return Vpa(q0, [q0]), q0 + + +class TestVpaState(unittest.TestCase): + def test_default_transitions_is_empty_defaultdict(self): + state = VpaState('s') + self.assertEqual(state.transitions['unused_key'], []) + + def test_default_not_accepting(self): + state = VpaState('s') + self.assertFalse(state.is_accepting) + + +class TestVpaStep(unittest.TestCase): + def test_accepts_empty_word(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + self.assertTrue(vpa.step(None)) + + def test_accepts_balanced_word(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + vpa.step('(') + result = vpa.step(')') + self.assertTrue(result) + self.assertEqual(vpa.stack, []) + + def test_rejects_incomplete_word(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + result = vpa.step('(') + self.assertFalse(result) + self.assertEqual(vpa.stack, ['(']) + + def test_unmatched_return_symbol_traps_in_error_state(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + result = vpa.step(')') + self.assertFalse(result) + self.assertIs(vpa.current_state, Vpa.error_state) + + # once trapped, further steps stay False without raising + self.assertFalse(vpa.step('(')) + self.assertFalse(vpa.step(None)) + + def test_nested_balanced_word(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + outputs = [vpa.step(c) for c in '(())'] + self.assertEqual(outputs, [False, False, False, True]) + + def test_top_of_empty_stack_is_empty_list(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + self.assertEqual(vpa.top(), []) + + def test_top_reflects_last_pushed_symbol(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + vpa.step('(') + self.assertEqual(vpa.top(), '(') + + +class TestVpaExecuteSequence(unittest.TestCase): + def test_execute_sequence_matches_stepwise(self): + vpa, q0 = balanced_parens_vpa() + result = vpa.execute_sequence(q0, ['(', '(', ')', ')'], stack=[]) + self.assertEqual(result, [False, False, False, True]) + self.assertEqual(vpa.stack, []) + + def test_execute_sequence_empty_returns_empty_list(self): + vpa, q0 = balanced_parens_vpa() + self.assertEqual(vpa.execute_sequence(q0, [], stack=[]), []) + + def test_execute_sequence_ignores_leftover_stack_from_prior_use(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + vpa.step('(') # leaves the stack non-empty: ['('] + self.assertEqual(vpa.stack, ['(']) + + result = vpa.execute_sequence(q0, [')'], stack=[]) + # a stale stack would make ')' match and incorrectly report acceptance; + # execute_sequence must start from the given stack, not whatever was left over + self.assertEqual(result, [False]) + + def test_execute_sequence_resumes_from_explicit_stack(self): + vpa, q0 = balanced_parens_vpa() + result = vpa.execute_sequence(q0, [')'], stack=['(']) + # resuming as if one '(' had already been pushed makes the lone ')' balance out + self.assertEqual(result, [True]) + self.assertEqual(vpa.stack, []) + + def test_execute_sequence_with_explicit_stack_matches_manual_stepping(self): + vpa, q0 = balanced_parens_vpa() + vpa.reset_to_initial() + vpa.step('(') + vpa.step('(') + stack_after_two_pushes = list(vpa.stack) + manual_result = [vpa.step(c) for c in '))'] + + result = vpa.execute_sequence(q0, [')', ')'], stack=stack_after_two_pushes) + self.assertEqual(result, manual_result) + + +class TestVpaStructural(unittest.TestCase): + def test_get_input_alphabet(self): + vpa, q0 = balanced_parens_vpa() + alphabet = vpa.get_input_alphabet() + self.assertEqual(alphabet.call_alphabet, ['(']) + self.assertEqual(alphabet.return_alphabet, [')']) + self.assertEqual(alphabet.internal_alphabet, []) + + def test_get_merged_alphabet(self): + alphabet = VpaAlphabet(internal_alphabet=['i'], call_alphabet=['c'], return_alphabet=['r']) + self.assertEqual(alphabet.get_merged_alphabet(), ['i', 'c', 'r']) + + def test_is_input_complete_true(self): + vpa, q0 = balanced_parens_vpa() + self.assertTrue(vpa.is_input_complete()) + + def test_is_input_complete_false(self): + # the alphabet is inferred from ALL transitions present in the automaton, so we need a second + # state that does use ')' for the alphabet to include it, exposing q0's missing transition + q0 = VpaState('q0', is_accepting=False) + q1 = VpaState('q1', is_accepting=True) + q0.transitions['('].append(VpaTransition(q0, q1, '(', 'push', '(')) + q1.transitions[')'].append(VpaTransition(q1, q1, ')', 'pop', '(')) + # q0 has no ')' transition + vpa = Vpa(q0, [q0, q1]) + self.assertFalse(vpa.is_input_complete()) + + +class TestVpaStateSetupRoundtrip(unittest.TestCase): + def test_to_state_setup_from_state_setup_roundtrip(self): + vpa, q0 = balanced_parens_vpa() + setup = vpa.to_state_setup() + rebuilt = Vpa.from_state_setup(setup, init_state_id='q0') + + for w in [[], ['('], ['(', ')'], ['(', '(', ')', ')']]: + rebuilt.reset_to_initial() + outputs = [rebuilt.step(letter) for letter in w] + vpa.reset_to_initial() + expected = [vpa.step(letter) for letter in w] + self.assertEqual(outputs, expected) + + +class TestVpaRandomAcceptingWord(unittest.TestCase): + def test_generate_random_accepting_word_is_actually_accepting(self): + vpa, q0 = balanced_parens_vpa() + random.seed(0) + # the walk is randomized and may run out of steps before balancing; a generous + # max_steps budget keeps this reliable without pinning the RNG's exact trajectory + word = vpa.generate_random_accepting_word(min_steps=2, max_steps=200) + self.assertIsNotNone(word) + + vpa.reset_to_initial() + outputs = [vpa.step(letter) for letter in word] + self.assertTrue(outputs[-1]) + self.assertEqual(vpa.stack, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000000..9a9c2e62f62 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption('--exhaustive', action='store_true', default=False, + help='also run tests marked "exhaustive" (large seed/size sweeps, several minutes)') + + +def pytest_collection_modifyitems(config, items): + if config.getoption('--exhaustive'): + return + skip_exhaustive = pytest.mark.skip(reason='exhaustive sweep, use --exhaustive to run') + for item in items: + if 'exhaustive' in item.keywords: + item.add_marker(skip_exhaustive) diff --git a/tests/oracles/test_baseOracle.py b/tests/oracles/test_baseOracle.py index beeaae45e21..0e99c19c81f 100644 --- a/tests/oracles/test_baseOracle.py +++ b/tests/oracles/test_baseOracle.py @@ -32,7 +32,7 @@ def generate_dfa_suls(self, number_of_states=10, alphabet_size=10, num_accepting return learning_sul, validation_sul, alphabet - def test_validate_eq_oracle(self, alphabet, eq_oracle, learning_sul, validation_sul): + def validate_eq_oracle(self, alphabet, eq_oracle, learning_sul, validation_sul): """ Validates the correctness of the given eq_oracle via WMethodEqOracle. diff --git a/tests/oracles/test_kWayTransitionCoverageEqOracle.py b/tests/oracles/test_kWayTransitionCoverageEqOracle.py index 3c53d0fb6a5..0443e7115ca 100644 --- a/tests/oracles/test_kWayTransitionCoverageEqOracle.py +++ b/tests/oracles/test_kWayTransitionCoverageEqOracle.py @@ -1,5 +1,7 @@ import unittest +import pytest + from aalpy.oracles import KWayTransitionCoverageEqOracle from tests.oracles.test_baseOracle import BaseOracleTests @@ -10,21 +12,21 @@ def test_default(self): learning_sul, validation_sul, alphabet = self.generate_dfa_suls() eq_oracle = KWayTransitionCoverageEqOracle(alphabet, learning_sul) - self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) def test_k_4(self): learning_sul, validation_sul, alphabet = self.generate_dfa_suls(5, 5, 2) eq_oracle = KWayTransitionCoverageEqOracle( alphabet, learning_sul, k=4) - self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) def test_method_prefix(self): learning_sul, validation_sul, alphabet = self.generate_dfa_suls() eq_oracle = KWayTransitionCoverageEqOracle( alphabet, learning_sul, method='prefix') - self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) @unittest.expectedFailure def test_max_number_of_steps_10(self): @@ -32,10 +34,13 @@ def test_max_number_of_steps_10(self): eq_oracle = KWayTransitionCoverageEqOracle( alphabet, learning_sul, max_number_of_steps=10, max_path_len=10) - self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + @pytest.mark.exhaustive def test_default_large_dfa(self): + # exercises the oracle at real scale (50 states, alphabet 10); learning + validating here takes + # ~10s, which is fine for an opt-in exhaustive run but too slow for the default fast suite learning_sul, validation_sul, alphabet = self.generate_dfa_suls(50, 10, 10) eq_oracle = KWayTransitionCoverageEqOracle(alphabet, learning_sul) - self.test_validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) diff --git a/tests/tests_imports.py b/tests/tests_imports.py index 26212cd1a32..45e7fb35824 100644 --- a/tests/tests_imports.py +++ b/tests/tests_imports.py @@ -1,5 +1,10 @@ +import importlib +import pkgutil +import sys import unittest +import aalpy + class ImportTest(unittest.TestCase): @@ -20,3 +25,45 @@ def test_imports(self): except: assert False assert True + + +class CircularImportTest(unittest.TestCase): + """ + test_imports above only ever imports aalpy's submodules in one fixed order, in one process. A + circular dependency between two modules can hide behind that: it only surfaces when whichever + module is involved in the cycle gets imported *first*, before the module it depends on has been + (even partially) loaded. This test instead imports every submodule of aalpy as the very first + aalpy-related import of a clean module cache, one at a time, which is exactly the situation a + circular import fails in. + """ + + def test_every_submodule_imports_standalone(self): + submodules = sorted(name for _, name, _ in pkgutil.walk_packages(aalpy.__path__, prefix='aalpy.')) + self.assertGreater(len(submodules), 0, "no aalpy submodules were discovered, the test itself is broken") + + # every other test module in the suite already imported classes (Dfa, MealyMachine, ...) from the + # ORIGINAL aalpy modules at collection time. Re-importing here creates new, distinct class objects; + # if left in sys.modules afterward, later tests comparing an old-class instance against a new-class + # instance (e.g. bisimilar()'s `a1.__class__ != a2.__class__` check) would fail spuriously. So the + # original modules must be restored once this test is done, regardless of outcome. + original_modules = {name: module for name, module in sys.modules.items() + if name == 'aalpy' or name.startswith('aalpy.')} + + failures = {} + try: + for name in submodules: + # drop every previously (possibly partially) loaded aalpy module so this import + # starts from a clean slate, as if it were the first aalpy import in a fresh interpreter + for cached in [m for m in sys.modules if m == 'aalpy' or m.startswith('aalpy.')]: + del sys.modules[cached] + try: + importlib.import_module(name) + except ImportError as e: + failures[name] = str(e) + finally: + for cached in [m for m in sys.modules if m == 'aalpy' or m.startswith('aalpy.')]: + del sys.modules[cached] + sys.modules.update(original_modules) + + self.assertEqual(failures, {}, f"modules that cannot be imported standalone (likely circular imports): " + f"{failures}") From cdf01f075f0314f0f2edf81b87b17a2cffbf70a6 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:15:11 +0200 Subject: [PATCH 12/25] Update minimum python version and add test cases to GitHubs CI/CD --- README.md | 24 ++++++++++++++++++++++-- pyproject.toml | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 29ba3164042..f336c0daf99 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [![GitHub issues](https://img.shields.io/github/issues/DES-Lab/AALpy)](https://github.com/DES-Lab/AALpy/issues) ![GitHub pull requests](https://img.shields.io/github/issues-pr/des-lab/aalpy) -[![Python 3.6](https://img.shields.io/badge/python-3.6%2B-blue)](https://www.python.org/downloads/release/python-360/) +[![Python 3.10](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/release/python-3100/) ![PyPI - Wheel](https://img.shields.io/pypi/wheel/aalpy) [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://GitHub.com/Naereen/StrapDown.js/graphs/commit-activity) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) @@ -52,7 +52,9 @@ To install current version of the master branch (it might contain bugfixes and a ```bash pip install https://github.com/DES-Lab/AALpy/archive/master.zip ``` -The minimum required version of Python is 3.6. +The minimum required version of Python is 3.10. +If you want to use older Python version (>= 3.6), you can use AALpy versions up to 1.6.2. + Ensure that you have [Graphviz](https://graphviz.org/) installed and added to your path if you want to visualize models. For manual installation, clone the repo and install `pydot` (the only dependency). @@ -145,6 +147,24 @@ If you use AALpy in your research, please cite us with of the following: If you have research suggestions or you need specific help concerning your research, feel free to start a [discussion](https://github.com/DES-Lab/AALpy/discussions). We are happy to help you and consult you in developing automata learning algorithms and in applying automata learning in various domains. + +## Testing + +Install the test dependencies once (`pytest` and `pytest-timeout` aren't needed to use AALpy itself, only to run its test suite): +```bash +pip install -e .[test] +``` + +Then, from the repository root, run AALpy's test suite: +```bash +pytest +``` + +For a more comprehensive test suite, which covers more seeds per learning experiment, run: +```bash +pytest --exhaustive +``` + ## Contributing Pull requests are welcome. For significant changes, please open an issue first to discuss what you would like to change. In case of any questions or possible bugs, please open issues. diff --git a/pyproject.toml b/pyproject.toml index 6b02c94dcbf..94a322ff04f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,12 @@ classifiers = [ "Operating System :: OS Independent" ] +[project.optional-dependencies] +test = [ +"pytest", +"pytest-timeout" +] + [project.urls] Homepage = "https://github.com/DES-Lab/AALpy" @@ -31,6 +37,15 @@ license-files = ["LICENSE.txt"] [tool.setuptools.packages.find] include = ["aalpy*"] +[tool.pytest.ini_options] +# a few test files cross-import from one another as `tests.oracles.test_x` (e.g. to reuse a shared +# BaseOracleTests helper); that only resolves if the repo root is on sys.path, which happens +# incidentally under `python -m pytest` (cwd is auto-added) but not for the plain `pytest` entry point +pythonpath = ["."] +markers = [ + "exhaustive: large parametrized sweeps (many seeds/sizes), skipped by default; run with `pytest --exhaustive`", +] + # pip install build twine # python -m build # python -m twine upload dist/* \ No newline at end of file From 1ee81c64eb76eecf9e7f67be81a4e0099f7dc5cf Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:15:33 +0200 Subject: [PATCH 13/25] Add base and oracle tests --- tests/base/test_cache_tree.py | 104 +++++++++++++++ tests/base/test_oracle.py | 70 +++++++++++ tests/base/test_sul.py | 119 ++++++++++++++++++ ...est_breadth_first_exploration_eq_oracle.py | 76 +++++++++++ tests/oracles/test_cache_based_eq_oracle.py | 78 ++++++++++++ .../test_k_way_state_coverage_eq_oracle.py | 75 +++++++++++ tests/oracles/test_pac_oracle.py | 95 ++++++++++++++ .../test_perfect_knowledge_eq_oracle.py | 92 ++++++++++++++ .../test_provided_sequences_oracle_wrapper.py | 94 ++++++++++++++ .../oracles/test_random_w_method_eq_oracle.py | 62 +++++++++ tests/oracles/test_random_walk_eq_oracle.py | 92 ++++++++++++++ tests/oracles/test_random_word_eq_oracle.py | 95 ++++++++++++++ tests/oracles/test_rwpmethod_oracle.py | 114 +++++++++++++++++ tests/oracles/test_state_prefix_eq_oracle.py | 79 ++++++++++++ tests/oracles/test_transition_focus_oracle.py | 72 +++++++++++ tests/oracles/test_user_input_eq_oracle.py | 81 ++++++++++++ tests/oracles/test_wmethod_oracle.py | 110 ++++++++++++++++ tests/oracles/test_wpmethod_oracle.py | 114 +++++++++++++++++ 18 files changed, 1622 insertions(+) create mode 100644 tests/base/test_cache_tree.py create mode 100644 tests/base/test_oracle.py create mode 100644 tests/base/test_sul.py create mode 100644 tests/oracles/test_breadth_first_exploration_eq_oracle.py create mode 100644 tests/oracles/test_cache_based_eq_oracle.py create mode 100644 tests/oracles/test_k_way_state_coverage_eq_oracle.py create mode 100644 tests/oracles/test_pac_oracle.py create mode 100644 tests/oracles/test_perfect_knowledge_eq_oracle.py create mode 100644 tests/oracles/test_provided_sequences_oracle_wrapper.py create mode 100644 tests/oracles/test_random_w_method_eq_oracle.py create mode 100644 tests/oracles/test_random_walk_eq_oracle.py create mode 100644 tests/oracles/test_random_word_eq_oracle.py create mode 100644 tests/oracles/test_rwpmethod_oracle.py create mode 100644 tests/oracles/test_state_prefix_eq_oracle.py create mode 100644 tests/oracles/test_transition_focus_oracle.py create mode 100644 tests/oracles/test_user_input_eq_oracle.py create mode 100644 tests/oracles/test_wmethod_oracle.py create mode 100644 tests/oracles/test_wpmethod_oracle.py diff --git a/tests/base/test_cache_tree.py b/tests/base/test_cache_tree.py new file mode 100644 index 00000000000..ae8e86796b9 --- /dev/null +++ b/tests/base/test_cache_tree.py @@ -0,0 +1,104 @@ +import unittest + +from aalpy.base.CacheTree import CacheDict, CacheTree + + +class TestCacheTree(unittest.TestCase): + def test_in_cache_on_empty_tree_returns_none(self): + tree = CacheTree() + self.assertIsNone(tree.in_cache(('a', 'b'))) + + def test_add_and_retrieve(self): + tree = CacheTree() + tree.add_to_cache(('a', 'b'), (1, 2)) + self.assertEqual(tree.in_cache(('a', 'b')), (1, 2)) + + def test_in_cache_returns_prefix_output(self): + tree = CacheTree() + tree.add_to_cache(('a', 'b', 'c'), (1, 2, 3)) + self.assertEqual(tree.in_cache(('a', 'b')), (1, 2)) + + def test_in_cache_missing_suffix_returns_none(self): + tree = CacheTree() + tree.add_to_cache(('a',), (1,)) + self.assertIsNone(tree.in_cache(('a', 'b'))) + + def test_in_cache_unknown_branch_returns_none(self): + tree = CacheTree() + tree.add_to_cache(('a',), (1,)) + self.assertIsNone(tree.in_cache(('b',))) + + def test_empty_sequence_is_cached(self): + tree = CacheTree() + self.assertEqual(tree.in_cache(()), ()) + + def test_reset_clears_current_position(self): + tree = CacheTree() + tree.reset() + tree.step_in_cache('a', 1) + self.assertEqual(tree.inputs, ('a',)) + tree.reset() + self.assertEqual(tree.inputs, ()) + self.assertIs(tree.curr_node, tree.root_node) + + def test_step_in_cache_none_sets_root_value(self): + tree = CacheTree() + tree.reset() + tree.step_in_cache(None, 'root_output') + self.assertEqual(tree.root_node.value, 'root_output') + + def test_consistent_repeated_insert_is_fine(self): + tree = CacheTree() + tree.add_to_cache(('a', 'b'), (1, 2)) + # inserting the exact same sequence again must not raise + tree.add_to_cache(('a', 'b'), (1, 2)) + self.assertEqual(tree.in_cache(('a', 'b')), (1, 2)) + + def test_non_determinism_raises_system_exit(self): + tree = CacheTree() + tree.add_to_cache(('a',), (1,)) + with self.assertRaises(SystemExit): + tree.add_to_cache(('a',), (2,)) + + def test_branching_inputs_are_independent(self): + tree = CacheTree() + tree.add_to_cache(('a',), (1,)) + tree.add_to_cache(('b',), (2,)) + self.assertEqual(tree.in_cache(('a',)), (1,)) + self.assertEqual(tree.in_cache(('b',)), (2,)) + + +class TestCacheDict(unittest.TestCase): + def test_in_cache_on_empty_dict_returns_none(self): + cache = CacheDict() + self.assertIsNone(cache.in_cache(('a', 'b'))) + + def test_add_and_retrieve(self): + cache = CacheDict() + cache.add_to_cache(('a', 'b'), (1, 2)) + self.assertEqual(cache.in_cache(('a', 'b')), (1, 2)) + + def test_in_cache_missing_returns_none(self): + cache = CacheDict() + cache.add_to_cache(('a',), (1,)) + self.assertIsNone(cache.in_cache(('a', 'b'))) + + def test_non_determinism_raises_system_exit(self): + cache = CacheDict() + cache.reset() + cache.step_in_cache('a', 1) + cache.reset() + with self.assertRaises(SystemExit): + cache.step_in_cache('a', 2) + + def test_consistent_repeated_step_is_fine(self): + cache = CacheDict() + cache.reset() + cache.step_in_cache('a', 1) + cache.reset() + # re-affirming the same input/output for the same accumulated path must not raise + cache.step_in_cache('a', 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/base/test_oracle.py b/tests/base/test_oracle.py new file mode 100644 index 00000000000..e86f6dd9b49 --- /dev/null +++ b/tests/base/test_oracle.py @@ -0,0 +1,70 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Dfa, DfaState +from aalpy.base import Oracle + + +def parity_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +class DummyOracle(Oracle): + """Minimal concrete Oracle used to exercise the shared base behaviour.""" + + def find_cex(self, hypothesis): + return None + + +class TestOracle(unittest.TestCase): + def test_constructor_sets_alphabet_and_sul(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + oracle = DummyOracle(['a', 'b'], sul) + self.assertEqual(oracle.alphabet, ['a', 'b']) + self.assertIs(oracle.sul, sul) + self.assertEqual(oracle.num_queries, 0) + self.assertEqual(oracle.num_steps, 0) + + def test_find_cex_is_abstract_and_must_be_implemented(self): + with self.assertRaises(TypeError): + Oracle(['a'], AutomatonSUL(parity_dfa())) + + def test_reset_hyp_and_sul_resets_hypothesis_to_initial(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + oracle = DummyOracle(dfa.get_input_alphabet(), sul) + + dfa.step('a') + self.assertIsNot(dfa.current_state, dfa.initial_state) + + oracle.reset_hyp_and_sul(dfa) + self.assertIs(dfa.current_state, dfa.initial_state) + + def test_reset_hyp_and_sul_resets_sul_automaton(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + oracle = DummyOracle(dfa.get_input_alphabet(), sul) + + sul.step('a') + self.assertIsNot(sul.automaton.current_state, sul.automaton.initial_state) + + oracle.reset_hyp_and_sul(dfa) + self.assertIs(sul.automaton.current_state, sul.automaton.initial_state) + + def test_reset_hyp_and_sul_increments_num_queries(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + oracle = DummyOracle(dfa.get_input_alphabet(), sul) + + oracle.reset_hyp_and_sul(dfa) + oracle.reset_hyp_and_sul(dfa) + self.assertEqual(oracle.num_queries, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/base/test_sul.py b/tests/base/test_sul.py new file mode 100644 index 00000000000..6c5d5572ee1 --- /dev/null +++ b/tests/base/test_sul.py @@ -0,0 +1,119 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Dfa, DfaState +from aalpy.base.SUL import CacheSUL + + +def parity_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +class TestAutomatonSUL(unittest.TestCase): + def test_step_delegates_to_automaton(self): + sul = AutomatonSUL(parity_dfa()) + self.assertFalse(sul.step('a')) + self.assertTrue(sul.step('a')) + + def test_pre_resets_automaton(self): + sul = AutomatonSUL(parity_dfa()) + sul.step('a') + self.assertIsNot(sul.automaton.current_state, sul.automaton.initial_state) + sul.pre() + self.assertIs(sul.automaton.current_state, sul.automaton.initial_state) + + def test_query_resets_before_and_after(self): + sul = AutomatonSUL(parity_dfa()) + sul.query(('a', 'a', 'a')) + # after query(), the automaton should be back to initial (pre() was called at query start + # and the automaton's current_state is left wherever the last step went, post() does not reset) + self.assertIsNot(sul.automaton.current_state, sul.automaton.initial_state) + sul.pre() + self.assertIs(sul.automaton.current_state, sul.automaton.initial_state) + + def test_query_empty_word_on_dfa(self): + sul = AutomatonSUL(parity_dfa()) + self.assertEqual(sul.query(()), [True]) + + def test_query_output_matches_manual_stepping(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + result = sul.query(('a', 'b', 'a')) + self.assertEqual(result, [False, False, True]) + + def test_io_query_pairs_inputs_with_outputs(self): + sul = AutomatonSUL(parity_dfa()) + result = sul.io_query(('a', 'a')) + self.assertEqual(result, [('a', False), ('a', True)]) + + def test_num_queries_and_steps_counters(self): + sul = AutomatonSUL(parity_dfa()) + sul.query(('a', 'b')) + sul.query(('a',)) + self.assertEqual(sul.num_queries, 2) + self.assertEqual(sul.num_steps, 3) + + def test_independent_suls_have_independent_state(self): + dfa = parity_dfa() + sul1 = AutomatonSUL(dfa) + sul2 = AutomatonSUL(dfa) + sul1.step('a') + # both wrap the same automaton instance, so state is shared -- this documents that behaviour + self.assertIs(sul1.automaton, sul2.automaton) + + +class TestCacheSUL(unittest.TestCase): + def test_cache_hit_avoids_wrapped_query(self): + wrapped = AutomatonSUL(parity_dfa()) + cache_sul = CacheSUL(wrapped) + + result1 = cache_sul.query(('a', 'b')) + self.assertEqual(wrapped.num_queries, 1) + + result2 = cache_sul.query(('a', 'b')) + self.assertEqual(result1, result2) + self.assertIsInstance(result2, list) # cache hits and misses must return the same type + self.assertEqual(wrapped.num_queries, 1) # not called again + self.assertEqual(cache_sul.num_cached_queries, 1) + + def test_cache_miss_for_new_query(self): + wrapped = AutomatonSUL(parity_dfa()) + cache_sul = CacheSUL(wrapped) + + cache_sul.query(('a',)) + cache_sul.query(('b',)) + self.assertEqual(wrapped.num_queries, 2) + self.assertEqual(cache_sul.num_cached_queries, 0) + + def test_prefix_of_cached_query_is_a_hit(self): + wrapped = AutomatonSUL(parity_dfa()) + cache_sul = CacheSUL(wrapped) + + cache_sul.query(('a', 'b', 'a')) + result = cache_sul.query(('a', 'b')) + self.assertEqual(result, [False, False]) + self.assertEqual(cache_sul.num_cached_queries, 1) + + def test_dict_cache_type(self): + wrapped = AutomatonSUL(parity_dfa()) + cache_sul = CacheSUL(wrapped, cache_type='dict') + + cache_sul.query(('a', 'b')) + result = cache_sul.query(('a', 'b')) + self.assertEqual(result, [False, False]) + self.assertEqual(cache_sul.num_cached_queries, 1) + + def test_step_updates_cache(self): + wrapped = AutomatonSUL(parity_dfa()) + cache_sul = CacheSUL(wrapped) + cache_sul.pre() + cache_sul.step('a') + self.assertEqual(cache_sul.cache.in_cache(('a',)), (False,)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_breadth_first_exploration_eq_oracle.py b/tests/oracles/test_breadth_first_exploration_eq_oracle.py new file mode 100644 index 00000000000..30467661965 --- /dev/null +++ b/tests/oracles/test_breadth_first_exploration_eq_oracle.py @@ -0,0 +1,76 @@ +import unittest + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import BreadthFirstExplorationEqOracle +from aalpy.SULs import AutomatonSUL + + +def chain_mealy(length, alphabet=('a',)): + """ + Chain of states over `alphabet`, deterministic on the first letter of `alphabet`. Repeatedly feeding the + first letter walks s0 -> s1 -> ... -> s_length. + """ + states = [MealyState(f's{i}') for i in range(length + 1)] + first = alphabet[0] + for i in range(length): + states[i].transitions = {a: states[i] for a in alphabet} + states[i].transitions[first] = states[i + 1] + states[i].output_fun = {a: 'o' for a in alphabet} + states[length].transitions = {a: states[length] for a in alphabet} + states[length].output_fun = {a: 'o' for a in alphabet} + mm = MealyMachine(states[0], states) + mm.compute_prefixes() + return mm + + +def with_diverging_transition(mm, diverge_at, alphabet=('a',)): + first = alphabet[0] + mm.states[diverge_at - 1].output_fun[first] = 'x' + return mm + + +class BreadthFirstExplorationEqOracleTests(unittest.TestCase): + + def test_finds_cex_within_depth(self): + reference = chain_mealy(5) + hypothesis = chain_mealy(5) + with_diverging_transition(hypothesis, 3) + + oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=5) + cex = oracle.find_cex(hypothesis) + + self.assertIsNotNone(cex) + reference.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [reference.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + + def test_returns_shortest_cex_not_full_depth(self): + reference = chain_mealy(5) + hypothesis = chain_mealy(5) + with_diverging_transition(hypothesis, 2) + + oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=5) + cex = oracle.find_cex(hypothesis) + + self.assertEqual(tuple(cex), ('a', 'a')) + + def test_difference_beyond_depth_bound_is_not_found(self): + reference = chain_mealy(5) + hypothesis = chain_mealy(5) + with_diverging_transition(hypothesis, 5) + + oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=3) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_no_cex_for_equivalent_hypothesis(self): + reference = chain_mealy(4) + hypothesis = chain_mealy(4) + + oracle = BreadthFirstExplorationEqOracle(['a'], AutomatonSUL(reference), depth=4) + self.assertIsNone(oracle.find_cex(hypothesis)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_cache_based_eq_oracle.py b/tests/oracles/test_cache_based_eq_oracle.py new file mode 100644 index 00000000000..83b964adeaf --- /dev/null +++ b/tests/oracles/test_cache_based_eq_oracle.py @@ -0,0 +1,78 @@ +import unittest + +from aalpy.automata import MealyMachine, MealyState +from aalpy.base.SUL import CacheSUL +from aalpy.oracles import CacheBasedEqOracle +from aalpy.SULs import AutomatonSUL + + +def chain_mealy(length, last_output='o'): + """ + Single-input-letter Mealy chain s0 -a/o-> s1 -a/o-> ... -a/o-> s_length, where the final + transition into s_length produces last_output instead of 'o'. + """ + states = [MealyState(f's{i}') for i in range(length + 1)] + for i in range(length): + out = last_output if i == length - 1 else 'o' + states[i].transitions = {'a': states[i + 1]} + states[i].output_fun = {'a': out} + states[length].transitions = {'a': states[length]} + states[length].output_fun = {'a': 'o'} + mm = MealyMachine(states[0], states) + mm.compute_prefixes() + return mm + + +class CacheBasedEqOracleTests(unittest.TestCase): + + def test_finds_cex_reachable_via_cached_prefix(self): + reference = chain_mealy(6, last_output='o') + hypothesis = chain_mealy(6, last_output='x') + + sul = CacheSUL(AutomatonSUL(reference)) + sul.query(('a',) * 5) + + oracle = CacheBasedEqOracle(['a'], sul, num_walks=10, depth_increase=1) + cex = oracle.find_cex(hypothesis) + + self.assertIsNotNone(cex) + reference.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [reference.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + self.assertEqual(sul_out[:-1], hyp_out[:-1]) + + def test_does_not_find_cex_when_difference_is_uncached_and_out_of_reach(self): + reference = chain_mealy(6, last_output='o') + hypothesis = chain_mealy(6, last_output='x') + + sul = CacheSUL(AutomatonSUL(reference)) + # nothing has been queried yet, so the only cached "leaf" is the empty prefix + oracle = CacheBasedEqOracle(['a'], sul, num_walks=10, depth_increase=1) + cex = oracle.find_cex(hypothesis) + + self.assertIsNone(cex) + + def test_no_cex_for_equivalent_hypothesis(self): + reference = chain_mealy(4, last_output='o') + hypothesis = chain_mealy(4, last_output='o') + + sul = CacheSUL(AutomatonSUL(reference)) + sul.query(('a', 'a', 'a')) + + oracle = CacheBasedEqOracle(['a'], sul, num_walks=20, depth_increase=3) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_get_paths_collects_all_leaves(self): + reference = chain_mealy(3) + sul = CacheSUL(AutomatonSUL(reference)) + sul.query(('a', 'a')) + + oracle = CacheBasedEqOracle(['a'], sul) + paths = oracle.get_paths(sul.cache.root_node) + self.assertEqual(paths, [['a', 'a']]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_k_way_state_coverage_eq_oracle.py b/tests/oracles/test_k_way_state_coverage_eq_oracle.py new file mode 100644 index 00000000000..2cbe81fcc46 --- /dev/null +++ b/tests/oracles/test_k_way_state_coverage_eq_oracle.py @@ -0,0 +1,75 @@ +import random +import unittest + +from aalpy.oracles import KWayStateCoverageEqOracle +from tests.oracles.test_baseOracle import BaseOracleTests + + +class KWayStateCoverageEqOracleTests(BaseOracleTests): + + def test_default(self): + learning_sul, validation_sul, alphabet = self.generate_dfa_suls() + + eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + + def test_k_3(self): + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 5, 3) + + eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul, k=3) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + + def test_method_combinations(self): + learning_sul, validation_sul, alphabet = self.generate_dfa_suls() + + eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul, method='combinations') + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + + def test_lower_and_upper_bounds(self): + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 4, 3) + + eq_oracle = KWayStateCoverageEqOracle(alphabet, learning_sul, num_test_lower_bound=20, + num_test_upper_bound=200) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + + def test_finds_transition_only_difference(self): + # a difference that is only reachable via a k-way state combination, not via the random walk tail + random.seed(0) + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(8, 4, 3) + + # no need to (re-)learn a reference model via run_Lstar here - learning_sul already wraps a + # correct ground-truth DFA (this used to run a full L* + WMethodEqOracle learning pass just to + # get back an equivalent copy of the model it already had, taking ~30s for no benefit) + reference_model = learning_sul.automaton + + # flip acceptance of a non-initial state to create a real, deterministic difference + target_state = next(s for s in reference_model.states if s is not reference_model.initial_state) + broken_model = reference_model.copy() + broken_state = next(s for s in broken_model.states if s.state_id == target_state.state_id) + broken_state.is_accepting = not broken_state.is_accepting + + oracle = KWayStateCoverageEqOracle(alphabet, validation_sul, k=2) + cex = oracle.find_cex(broken_model) + self.assertIsNotNone(cex) + + def test_k_larger_than_hypothesis_state_count_still_tests(self): + # Regression test: previously, when k exceeded the hypothesis' number of states, both + # itertools.combinations/permutations produced no k-wise tuples AND the num_test_lower_bound + # fallback (meant to cover this exact situation) only triggered for single-state hypotheses, + # so test_cases stayed empty and find_cex returned None without testing anything at all. + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 4, 3) + reference_dfa = learning_sul.automaton + + broken_model = reference_dfa.copy() + broken_model.states = broken_model.states[:2] + broken_model.initial_state = broken_model.states[0] + for state in broken_model.states: + state.transitions = {a: broken_model.states[0] for a in alphabet} + + oracle = KWayStateCoverageEqOracle(alphabet, validation_sul, k=3) + cex = oracle.find_cex(broken_model) + self.assertIsNotNone(cex) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_pac_oracle.py b/tests/oracles/test_pac_oracle.py new file mode 100644 index 00000000000..1370d5fcd57 --- /dev/null +++ b/tests/oracles/test_pac_oracle.py @@ -0,0 +1,95 @@ +import random +import unittest +from math import ceil, log + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import PacOracle +from aalpy.SULs import AutomatonSUL + + +def chain_mealy(length, alphabet=('a', 'b')): + """Chain of states over `alphabet`, s0 -> s1 -> ... -> s_length, driven by repeating the first letter.""" + states = [MealyState(f's{i}') for i in range(length + 1)] + first = alphabet[0] + for i in range(length): + states[i].transitions = {a: states[i] for a in alphabet} + states[i].transitions[first] = states[i + 1] + states[i].output_fun = {a: 'o' for a in alphabet} + states[length].transitions = {a: states[length] for a in alphabet} + states[length].output_fun = {a: 'o' for a in alphabet} + mm = MealyMachine(states[0], states) + mm.compute_prefixes() + return mm + + +def with_diverging_transition(mm, diverge_at, alphabet=('a', 'b')): + mm.states[diverge_at - 1].output_fun[alphabet[0]] = 'x' + return mm + + +class PacOracleTests(unittest.TestCase): + + def test_finds_cex_over_several_seeds(self): + successes = 0 + for seed in range(10): + random.seed(seed) + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + with_diverging_transition(hypothesis, 3) + + oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.05, delta=0.05, + min_walk_len=5, max_walk_len=10) + cex = oracle.find_cex(hypothesis) + if cex is not None: + successes += 1 + reference.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [reference.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + + self.assertGreaterEqual(successes, 9) + + def test_no_cex_for_equivalent_hypothesis(self): + # No amount of randomness can turn a truly equivalent hypothesis into a false positive: since every + # input sequence produces identical output on both models, out_sul == out_hyp always holds, regardless + # of which random sequences are sampled. This assertion is therefore exact, not merely "usually true". + random.seed(0) + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + + oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.05, delta=0.05) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_number_of_test_cases_grows_with_round_and_shrinks_with_epsilon_delta(self): + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + + loose_oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.5, delta=0.5) + loose_oracle.find_cex(hypothesis) + expected_loose = ceil(1 / 0.5 * (log(1 / 0.5) + 1 * log(2))) + self.assertEqual(loose_oracle.num_queries, expected_loose) + + strict_oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.02, delta=0.02) + strict_oracle.find_cex(hypothesis) + expected_strict = ceil(1 / 0.02 * (log(1 / 0.02) + 1 * log(2))) + self.assertEqual(strict_oracle.num_queries, expected_strict) + + self.assertGreater(strict_oracle.num_queries, loose_oracle.num_queries) + + def test_round_counter_increases_number_of_test_cases_across_calls(self): + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + + oracle = PacOracle(['a', 'b'], AutomatonSUL(reference), epsilon=0.1, delta=0.1) + oracle.find_cex(hypothesis) + first_round_queries = oracle.num_queries + + oracle.find_cex(hypothesis) + second_round_queries = oracle.num_queries - first_round_queries + + self.assertGreater(second_round_queries, first_round_queries) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_perfect_knowledge_eq_oracle.py b/tests/oracles/test_perfect_knowledge_eq_oracle.py new file mode 100644 index 00000000000..339570f146f --- /dev/null +++ b/tests/oracles/test_perfect_knowledge_eq_oracle.py @@ -0,0 +1,92 @@ +import unittest + +from aalpy.automata import Dfa, DfaState +from aalpy.oracles import PerfectKnowledgeEqOracle +from aalpy.SULs import AutomatonSUL + + +def parity_dfa(): + """2-state complete, minimal DFA accepting words with an even number of 'a's.""" + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + dfa = Dfa(q0, [q0, q1]) + dfa.compute_prefixes() + return dfa + + +def parity_dfa_relabeled(): + """Behaviorally identical to parity_dfa, but built from differently-named/ordered states.""" + r1 = DfaState('r1', is_accepting=False) + r0 = DfaState('r0', is_accepting=True) + r0.transitions = {'a': r1, 'b': r0} + r1.transitions = {'a': r0, 'b': r1} + dfa = Dfa(r0, [r1, r0]) + dfa.compute_prefixes() + return dfa + + +def parity_dfa_with_wrong_transition(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q1, 'b': q1} # 'a' from q1 should go back to q0, not self-loop + dfa = Dfa(q0, [q0, q1]) + dfa.compute_prefixes() + return dfa + + +class PerfectKnowledgeEqOracleTests(unittest.TestCase): + + def test_finds_cex_for_wrong_transition(self): + ground_truth = parity_dfa() + hypothesis = parity_dfa_with_wrong_transition() + + oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth) + cex = oracle.find_cex(hypothesis) + + self.assertIsNotNone(cex) + ground_truth.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [ground_truth.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + + def test_no_cex_for_behaviorally_equivalent_but_structurally_different_hypothesis(self): + ground_truth = parity_dfa() + hypothesis = parity_dfa_relabeled() + + oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_deterministically_finds_cex_on_first_try_no_randomness_involved(self): + # unlike the other oracles, this one has direct access to ground truth, so it must succeed every time + for _ in range(5): + ground_truth = parity_dfa() + hypothesis = parity_dfa_with_wrong_transition() + + oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth) + self.assertIsNotNone(oracle.find_cex(hypothesis)) + + def test_missing_state_in_hypothesis_is_detected(self): + ground_truth = parity_dfa() + + single_state_hyp = DfaState('only', is_accepting=True) + single_state_hyp.transitions = {'a': single_state_hyp, 'b': single_state_hyp} + hypothesis = Dfa(single_state_hyp, [single_state_hyp]) + hypothesis.compute_prefixes() + + oracle = PerfectKnowledgeEqOracle(['a', 'b'], AutomatonSUL(ground_truth), ground_truth) + cex = oracle.find_cex(hypothesis) + + self.assertIsNotNone(cex) + ground_truth.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [ground_truth.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_provided_sequences_oracle_wrapper.py b/tests/oracles/test_provided_sequences_oracle_wrapper.py new file mode 100644 index 00000000000..10bc4181384 --- /dev/null +++ b/tests/oracles/test_provided_sequences_oracle_wrapper.py @@ -0,0 +1,94 @@ +import unittest + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import ProvidedSequencesOracleWrapper +from aalpy.SULs import AutomatonSUL + + +def sample_mealy(): + """2-state Mealy machine over {x, y}, matching tests/automata/test_mealy_machine.py's fixture.""" + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'x': s1, 'y': s0} + s0.output_fun = {'x': 'o1', 'y': 'o2'} + s1.transitions = {'x': s0, 'y': s1} + s1.output_fun = {'x': 'o3', 'y': 'o1'} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm + + +def sample_mealy_with_wrong_output(): + mm = sample_mealy() + mm.states[0].output_fun['y'] = 'wrong' + return mm + + +class RecordingOracle: + """Never finds a counterexample, records whether it was ever invoked.""" + + def __init__(self): + self.called = False + self.num_queries = 0 + self.num_steps = 0 + + def find_cex(self, hypothesis): + self.called = True + return None + + +class ProvidedSequencesOracleWrapperTests(unittest.TestCase): + + def test_finds_cex_among_provided_sequences(self): + reference = sample_mealy() + hypothesis = sample_mealy_with_wrong_output() + + fallback = RecordingOracle() + oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback, + [['x', 'x'], ['y'], ['x', 'y', 'x']]) + cex = oracle.find_cex(hypothesis) + + self.assertEqual(tuple(cex), ('y',)) + self.assertFalse(fallback.called, "fallback oracle should not run once a provided sequence finds a cex") + + def test_no_cex_when_none_of_the_provided_sequences_reveal_it_and_delegates_to_wrapped_oracle(self): + reference = sample_mealy() + hypothesis = sample_mealy_with_wrong_output() + + # none of these sequences ever exercise s0's 'y' transition, so they can't reveal the difference + fallback = RecordingOracle() + oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback, + [['x'], ['x', 'x']]) + cex = oracle.find_cex(hypothesis) + + self.assertIsNone(cex) + self.assertTrue(fallback.called, "wrapped oracle should be used once provided sequences are exhausted") + + def test_exactly_the_provided_sequences_are_checked_and_only_once(self): + reference = sample_mealy() + hypothesis = sample_mealy() + + fallback = RecordingOracle() + provided = [['x'], ['y'], ['x', 'y']] + oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback, provided) + + self.assertIsNone(oracle.find_cex(hypothesis)) + self.assertEqual(provided, [], "all provided sequences should be consumed") + self.assertTrue(fallback.called) + + def test_non_revealing_sequences_are_removed_before_the_one_that_finds_the_cex(self): + # sequences are consumed in order; once one of them reveals the difference, find_cex returns + # immediately, so that sequence (and anything after it) is left untouched in the provided list. + reference = sample_mealy() + hypothesis = sample_mealy_with_wrong_output() + + fallback = RecordingOracle() + provided = [['x', 'x'], ['y'], ['x', 'y', 'x']] + oracle = ProvidedSequencesOracleWrapper(['x', 'y'], AutomatonSUL(reference), fallback, provided) + oracle.find_cex(hypothesis) + + self.assertEqual(provided, [['y'], ['x', 'y', 'x']]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_random_w_method_eq_oracle.py b/tests/oracles/test_random_w_method_eq_oracle.py new file mode 100644 index 00000000000..b9507f74cde --- /dev/null +++ b/tests/oracles/test_random_w_method_eq_oracle.py @@ -0,0 +1,62 @@ +import random +import unittest + +from aalpy.oracles import RandomWMethodEqOracle +from tests.oracles.test_baseOracle import BaseOracleTests + + +class RandomWMethodEqOracleTests(BaseOracleTests): + + def test_default(self): + learning_sul, validation_sul, alphabet = self.generate_dfa_suls() + + eq_oracle = RandomWMethodEqOracle(alphabet, learning_sul) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + + def test_small_alphabet_more_walks(self): + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 2) + + eq_oracle = RandomWMethodEqOracle(alphabet, learning_sul, walks_per_state=40, walk_len=8) + self.validate_eq_oracle(alphabet, eq_oracle, learning_sul, validation_sul) + + def test_finds_cex_over_several_seeds(self): + successes = 0 + for seed in range(10): + random.seed(seed) + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 3) + reference_dfa = learning_sul.automaton + + broken_model = reference_dfa.copy() + broken_model.states[-1].is_accepting = not broken_model.states[-1].is_accepting + + oracle = RandomWMethodEqOracle(alphabet, validation_sul, walks_per_state=25, walk_len=12) + cex = oracle.find_cex(broken_model) + if cex is not None: + successes += 1 + + self.assertGreaterEqual(successes, 9) + + def test_no_cex_for_equivalent_hypothesis(self): + random.seed(0) + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 3) + reference_dfa = learning_sul.automaton + + equivalent_model = reference_dfa.copy() + + oracle = RandomWMethodEqOracle(alphabet, validation_sul, walks_per_state=25, walk_len=12) + self.assertIsNone(oracle.find_cex(equivalent_model)) + + def test_walks_per_state_zero_never_tests_anything(self): + learning_sul, validation_sul, alphabet = self.generate_dfa_suls(6, 3, 3) + reference_dfa = learning_sul.automaton + + broken_model = reference_dfa.copy() + broken_model.states[-1].is_accepting = not broken_model.states[-1].is_accepting + + oracle = RandomWMethodEqOracle(alphabet, validation_sul, walks_per_state=0, walk_len=12) + self.assertIsNone(oracle.find_cex(broken_model)) + self.assertEqual(oracle.num_queries, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_random_walk_eq_oracle.py b/tests/oracles/test_random_walk_eq_oracle.py new file mode 100644 index 00000000000..5ea43e94168 --- /dev/null +++ b/tests/oracles/test_random_walk_eq_oracle.py @@ -0,0 +1,92 @@ +import random +import unittest + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import RandomWalkEqOracle +from aalpy.SULs import AutomatonSUL + + +def chain_mealy(length, alphabet=('a',)): + """Chain of states, s0 -> s1 -> ... -> s_length, driven by repeating the first letter of `alphabet`.""" + states = [MealyState(f's{i}') for i in range(length + 1)] + first = alphabet[0] + for i in range(length): + states[i].transitions = {a: states[i] for a in alphabet} + states[i].transitions[first] = states[i + 1] + states[i].output_fun = {a: 'o' for a in alphabet} + states[length].transitions = {a: states[length] for a in alphabet} + states[length].output_fun = {a: 'o' for a in alphabet} + mm = MealyMachine(states[0], states) + mm.compute_prefixes() + return mm + + +def with_diverging_transition(mm, diverge_at, alphabet=('a',)): + mm.states[diverge_at - 1].output_fun[alphabet[0]] = 'x' + return mm + + +class RandomWalkEqOracleTests(unittest.TestCase): + + def test_finds_cex_over_several_seeds(self): + successes = 0 + for seed in range(10): + random.seed(seed) + reference = chain_mealy(6, alphabet=('a', 'b')) + hypothesis = chain_mealy(6, alphabet=('a', 'b')) + with_diverging_transition(hypothesis, 6, alphabet=('a', 'b')) + + oracle = RandomWalkEqOracle(['a', 'b'], AutomatonSUL(reference), num_steps=2000, reset_prob=0.05) + cex = oracle.find_cex(hypothesis) + if cex is not None: + successes += 1 + reference.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [reference.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + self.assertEqual(sul_out[:-1], hyp_out[:-1]) + + self.assertGreaterEqual(successes, 9) + + def test_no_cex_for_equivalent_hypothesis(self): + random.seed(0) + reference = chain_mealy(4, alphabet=('a', 'b')) + hypothesis = chain_mealy(4, alphabet=('a', 'b')) + + oracle = RandomWalkEqOracle(['a', 'b'], AutomatonSUL(reference), num_steps=2000) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_reset_after_cex_false_does_not_replenish_step_budget(self): + reference = chain_mealy(5) + hypothesis = chain_mealy(5) + with_diverging_transition(hypothesis, 5) + + oracle = RandomWalkEqOracle(['a'], AutomatonSUL(reference), num_steps=5, reset_prob=0.0, + reset_after_cex=False) + + first_cex = oracle.find_cex(hypothesis) + self.assertIsNotNone(first_cex) + self.assertEqual(oracle.random_steps_done, oracle.step_limit) + + second_cex = oracle.find_cex(hypothesis) + self.assertIsNone(second_cex) + + def test_reset_after_cex_true_replenishes_step_budget(self): + reference = chain_mealy(5) + hypothesis = chain_mealy(5) + with_diverging_transition(hypothesis, 5) + + oracle = RandomWalkEqOracle(['a'], AutomatonSUL(reference), num_steps=5, reset_prob=0.0, + reset_after_cex=True) + + first_cex = oracle.find_cex(hypothesis) + self.assertIsNotNone(first_cex) + self.assertEqual(oracle.random_steps_done, 0) + + second_cex = oracle.find_cex(hypothesis) + self.assertIsNotNone(second_cex) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_random_word_eq_oracle.py b/tests/oracles/test_random_word_eq_oracle.py new file mode 100644 index 00000000000..033dc3b69dc --- /dev/null +++ b/tests/oracles/test_random_word_eq_oracle.py @@ -0,0 +1,95 @@ +import random +import unittest + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import RandomWordEqOracle +from aalpy.SULs import AutomatonSUL + + +def chain_mealy(length, alphabet=('a',)): + """Chain of states, s0 -> s1 -> ... -> s_length, driven by repeating the first letter of `alphabet`.""" + states = [MealyState(f's{i}') for i in range(length + 1)] + first = alphabet[0] + for i in range(length): + states[i].transitions = {a: states[i] for a in alphabet} + states[i].transitions[first] = states[i + 1] + states[i].output_fun = {a: 'o' for a in alphabet} + states[length].transitions = {a: states[length] for a in alphabet} + states[length].output_fun = {a: 'o' for a in alphabet} + mm = MealyMachine(states[0], states) + mm.compute_prefixes() + return mm + + +def with_diverging_transition(mm, diverge_at, alphabet=('a',)): + mm.states[diverge_at - 1].output_fun[alphabet[0]] = 'x' + return mm + + +class RandomWordEqOracleTests(unittest.TestCase): + + def test_finds_cex_over_several_seeds(self): + successes = 0 + for seed in range(10): + random.seed(seed) + reference = chain_mealy(4, alphabet=('a', 'b')) + hypothesis = chain_mealy(4, alphabet=('a', 'b')) + with_diverging_transition(hypothesis, 4, alphabet=('a', 'b')) + + oracle = RandomWordEqOracle(['a', 'b'], AutomatonSUL(reference), num_walks=200, + min_walk_len=5, max_walk_len=10) + cex = oracle.find_cex(hypothesis) + if cex is not None: + successes += 1 + reference.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [reference.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + + self.assertGreaterEqual(successes, 9) + + def test_no_cex_for_equivalent_hypothesis(self): + random.seed(0) + reference = chain_mealy(4, alphabet=('a', 'b')) + hypothesis = chain_mealy(4, alphabet=('a', 'b')) + + oracle = RandomWordEqOracle(['a', 'b'], AutomatonSUL(reference), num_walks=200) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_fixed_walk_length_bounds_reachable_difference(self): + # min_walk_len == max_walk_len == 5, alphabet of size 1, so every walk is exactly 'aaaaa' + reference = chain_mealy(6) + hypothesis = chain_mealy(6) + with_diverging_transition(hypothesis, 6) + + oracle = RandomWordEqOracle(['a'], AutomatonSUL(reference), num_walks=5, min_walk_len=5, max_walk_len=5) + self.assertIsNone(oracle.find_cex(hypothesis), "walk length is fixed below the divergence depth") + + def test_fixed_walk_length_finds_reachable_difference(self): + reference = chain_mealy(5) + hypothesis = chain_mealy(5) + with_diverging_transition(hypothesis, 5) + + oracle = RandomWordEqOracle(['a'], AutomatonSUL(reference), num_walks=5, min_walk_len=5, max_walk_len=5) + cex = oracle.find_cex(hypothesis) + self.assertEqual(tuple(cex), ('a',) * 5) + + def test_reset_after_cex_false_does_not_replenish_walk_budget(self): + reference = chain_mealy(5) + hypothesis = chain_mealy(5) + with_diverging_transition(hypothesis, 5) + + oracle = RandomWordEqOracle(['a'], AutomatonSUL(reference), num_walks=1, min_walk_len=5, max_walk_len=5, + reset_after_cex=False) + + first_cex = oracle.find_cex(hypothesis) + self.assertIsNotNone(first_cex) + self.assertEqual(oracle.num_walks_done, oracle.num_walks) + + second_cex = oracle.find_cex(hypothesis) + self.assertIsNone(second_cex) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_rwpmethod_oracle.py b/tests/oracles/test_rwpmethod_oracle.py new file mode 100644 index 00000000000..40c2c1077d6 --- /dev/null +++ b/tests/oracles/test_rwpmethod_oracle.py @@ -0,0 +1,114 @@ +import unittest + +try: + from aalpy.automata import MooreMachine, MooreState + from aalpy.learning_algs import run_Lstar + from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle + from aalpy.SULs import AutomatonSUL + from aalpy.utils import visualize_automaton +except ImportError: + import sys + from pathlib import Path + + # if you want to run the test directly from CLI + # either from root or from tests folder + p = Path(__file__).parent.resolve() + sys.path.append(str(p)) + sys.path.append(str(p.parent)) + sys.path.append(str(p.parent.parent)) + from aalpy.automata import MooreMachine, MooreState + from aalpy.learning_algs import run_Lstar + from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle + from aalpy.SULs import AutomatonSUL + from aalpy.utils import visualize_automaton + + +class TestRandomWpMethodOracle(unittest.TestCase): + @staticmethod + def gen_moore_from_state_setup(state_setup) -> MooreMachine: + # state_setup shoud map from state_id to tuple(output and transitions_dict) + + # build states with state_id and output + states = {key: MooreState(key, val[0]) for key, val in state_setup.items()} + + # add transitions to states + for state_id, state in states.items(): + for _input, target_state_id in state_setup[state_id][1].items(): + state.transitions[_input] = states[target_state_id] + + # states to list + states = [state for state in states.values()] + + # build moore machine with first state as starting state + mm = MooreMachine(states[0], states) + + for state in states: + state.prefix = mm.get_shortest_path(mm.initial_state, state) + + return mm + + def generate_real_automata(self) -> MooreMachine: + state_setup = { + "a": ("a", {"x": "b1", "y": "a"}), + "b1": ("b", {"x": "b2", "y": "a"}), + "b2": ("b", {"x": "b3", "y": "a"}), + "b3": ("b", {"x": "b4", "y": "a"}), + "b4": ("b", {"x": "c", "y": "a"}), + "c": ("c", {"x": "a", "y": "a"}), + } + + mm = self.gen_moore_from_state_setup(state_setup) + mm.characterization_set = mm.compute_characterization_set() + [tuple()] + return mm + + def generate_hypothesis(self) -> MooreMachine: + state_setup = { + "a": ("a", {"x": "b", "y": "a"}), + "b": ("b", {"x": "b", "y": "a"}), + } + + mm = self.gen_moore_from_state_setup(state_setup) + # ! computer_characterization_set does not work for Moore machines in general! + # mm.characterization_set = mm.compute_characterization_set() + [tuple()] + mm.characterization_set = [tuple(), ("x",), ("y",)] + return mm + + def test_rwpmethod_oracle(self): + real = self.generate_real_automata() + hyp = self.generate_hypothesis() + # visualize_automaton(real) + # visualize_automaton(hyp) + assert set(real.get_input_alphabet()) == {"x", "y"} + assert set(hyp.get_input_alphabet()) == {"x", "y"} + assert len(real.states) == 6 + assert len(hyp.states) == 2 + alphabet = real.get_input_alphabet() + oracle = RandomWpMethodEqOracle( + alphabet, AutomatonSUL(real) + ) + cex = oracle.find_cex(hyp) + assert cex is not None, "Expected a counterexample, but got None" + + def test_rwpmethod_oracle_with_lstar(self): + real = self.generate_real_automata() + hyp = self.generate_hypothesis() + # visualize_automaton(real) + # visualize_automaton(hyp) + assert real.get_input_alphabet() == ["x", "y"] + assert hyp.get_input_alphabet() == ["x", "y"] + assert len(real.states) == 6 + assert len(hyp.states) == 2 + alphabet = real.get_input_alphabet() + oracle = RandomWpMethodEqOracle( + alphabet, AutomatonSUL(real) + ) + lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore") + # print(lstar_hyp) + # visualize_automaton(lstar_hyp) + assert ( + len(lstar_hyp.states) == 6 + ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/oracles/test_state_prefix_eq_oracle.py b/tests/oracles/test_state_prefix_eq_oracle.py new file mode 100644 index 00000000000..5f09275a21e --- /dev/null +++ b/tests/oracles/test_state_prefix_eq_oracle.py @@ -0,0 +1,79 @@ +import random +import unittest + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import StatePrefixEqOracle +from aalpy.SULs import AutomatonSUL + + +def branching_mealy(): + """ + 4-state Mealy machine over {a, b}. From s0, 'a' reaches s1 and 'b' reaches s2; s1 and s2 both lead to s3 on 'a'. + s3's outgoing transitions are where the two variants below differ. + """ + s0, s1, s2, s3 = (MealyState(f's{i}') for i in range(4)) + s0.transitions = {'a': s1, 'b': s2} + s0.output_fun = {'a': 'o', 'b': 'o'} + s1.transitions = {'a': s3, 'b': s1} + s1.output_fun = {'a': 'o', 'b': 'o'} + s2.transitions = {'a': s3, 'b': s2} + s2.output_fun = {'a': 'o', 'b': 'o'} + s3.transitions = {'a': s3, 'b': s3} + s3.output_fun = {'a': 'o', 'b': 'o'} + mm = MealyMachine(s0, [s0, s1, s2, s3]) + mm.compute_prefixes() + return mm, (s0, s1, s2, s3) + + +class StatePrefixEqOracleTests(unittest.TestCase): + + def test_finds_difference_reachable_only_via_specific_state_suffix(self): + # difference is only observable from s2's own 'b' self-loop, unreachable from s0/s1's local suffixes alone + reference, (s0, s1, s2, s3) = branching_mealy() + hypothesis, (h0, h1, h2, h3) = branching_mealy() + h2.output_fun['b'] = 'x' + + random.seed(0) + oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=30, walk_len=3) + cex = oracle.find_cex(hypothesis) + + self.assertIsNotNone(cex) + reference.reset_to_initial() + hypothesis.reset_to_initial() + sul_out = [reference.step(i) for i in cex] + hyp_out = [hypothesis.step(i) for i in cex] + self.assertNotEqual(sul_out[-1], hyp_out[-1]) + self.assertEqual(sul_out[:-1], hyp_out[:-1]) + # the divergent suffix step must be taken from s2 (reached only via prefix 'b') + self.assertEqual(cex[0], 'b') + + def test_no_cex_for_equivalent_hypothesis(self): + random.seed(0) + reference, _ = branching_mealy() + hypothesis, _ = branching_mealy() + + oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=30, walk_len=5) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_max_tests_bounds_number_of_queries(self): + random.seed(0) + reference, _ = branching_mealy() + hypothesis, _ = branching_mealy() + + oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=30, walk_len=5, + max_tests=5) + oracle.find_cex(hypothesis) + self.assertLessEqual(oracle.num_queries, 5) + + def test_zero_walks_per_state_never_tests_anything(self): + reference, _ = branching_mealy() + hypothesis, (h0, h1, h2, h3) = branching_mealy() + h2.output_fun['b'] = 'x' + + oracle = StatePrefixEqOracle(['a', 'b'], AutomatonSUL(reference), walks_per_state=0, walk_len=5) + self.assertIsNone(oracle.find_cex(hypothesis)) + self.assertEqual(oracle.num_queries, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_transition_focus_oracle.py b/tests/oracles/test_transition_focus_oracle.py new file mode 100644 index 00000000000..a382cbf286b --- /dev/null +++ b/tests/oracles/test_transition_focus_oracle.py @@ -0,0 +1,72 @@ +import random +import unittest + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import TransitionFocusOracle +from aalpy.SULs import AutomatonSUL + + +def self_loop_vs_transition_mealy(): + """ + 2-state Mealy machine. s0 --a/o1--> s0 (self loop) and s0 --b/o2--> s1 (different-state transition). + s1 mirrors s0's transitions so the walk never gets stuck. + """ + s0, s1 = MealyState('s0'), MealyState('s1') + s0.transitions = {'a': s0, 'b': s1} + s0.output_fun = {'a': 'o1', 'b': 'o2'} + s1.transitions = {'a': s1, 'b': s0} + s1.output_fun = {'a': 'o1', 'b': 'o2'} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm, s0, s1 + + +class TransitionFocusOracleTests(unittest.TestCase): + + def test_same_state_prob_1_finds_self_loop_only_difference(self): + reference, s0, s1 = self_loop_vs_transition_mealy() + hypothesis, h0, h1 = self_loop_vs_transition_mealy() + h0.output_fun['a'] = 'x' # only the self-loop on s0 differs + + oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=1, walk_len=1, + same_state_prob=1.0) + cex = oracle.find_cex(hypothesis) + + self.assertEqual(tuple(cex), ('a',)) + + def test_same_state_prob_0_never_probes_the_self_loop(self): + reference, s0, s1 = self_loop_vs_transition_mealy() + hypothesis, h0, h1 = self_loop_vs_transition_mealy() + h0.output_fun['a'] = 'x' # only the self-loop on s0 differs, 'b' transitions remain correct everywhere + + oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=20, walk_len=10, + same_state_prob=0.0) + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_finds_difference_on_diff_state_transition_over_several_seeds(self): + successes = 0 + for seed in range(10): + random.seed(seed) + reference, s0, s1 = self_loop_vs_transition_mealy() + hypothesis, h0, h1 = self_loop_vs_transition_mealy() + h0.output_fun['b'] = 'x' # only the s0 -> s1 transition differs + + oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=30, walk_len=10, + same_state_prob=0.2) + cex = oracle.find_cex(hypothesis) + if cex is not None: + successes += 1 + + self.assertGreaterEqual(successes, 9) + + def test_no_cex_for_equivalent_hypothesis(self): + random.seed(0) + reference, _, _ = self_loop_vs_transition_mealy() + hypothesis, _, _ = self_loop_vs_transition_mealy() + + oracle = TransitionFocusOracle(['a', 'b'], AutomatonSUL(reference), num_random_walks=50, walk_len=10) + self.assertIsNone(oracle.find_cex(hypothesis)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_user_input_eq_oracle.py b/tests/oracles/test_user_input_eq_oracle.py new file mode 100644 index 00000000000..4185fe23856 --- /dev/null +++ b/tests/oracles/test_user_input_eq_oracle.py @@ -0,0 +1,81 @@ +import unittest +from unittest.mock import patch + +from aalpy.automata import MealyMachine, MealyState +from aalpy.oracles import UserInputEqOracle +from aalpy.SULs import AutomatonSUL + + +def sample_mealy(): + """2-state Mealy machine over {x, y}.""" + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'x': s1, 'y': s0} + s0.output_fun = {'x': 'o1', 'y': 'o2'} + s1.transitions = {'x': s0, 'y': s1} + s1.output_fun = {'x': 'o3', 'y': 'o1'} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm + + +class UserInputEqOracleTests(unittest.TestCase): + + def setUp(self): + self.visualize_patcher = patch('aalpy.oracles.UserInputEqOracle.visualize_automaton') + self.visualize_patcher.start() + + def tearDown(self): + self.visualize_patcher.stop() + + def test_user_enters_inputs_then_requests_cex(self): + mm = sample_mealy() + oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm)) + + with patch('builtins.input', side_effect=['x', 'y', 'cex']): + cex = oracle.find_cex(mm) + + self.assertEqual(cex, ['x', 'y']) + + def test_user_ends_session_without_a_counterexample(self): + mm = sample_mealy() + oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm)) + + with patch('builtins.input', side_effect=['x', 'end']): + cex = oracle.find_cex(mm) + + self.assertIsNone(cex) + + def test_cex_command_with_no_inputs_yet_is_ignored_and_prompts_again(self): + mm = sample_mealy() + oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm)) + + # 'cex' before any input is entered is a no-op (inputs is empty and falsy), so the loop must continue + with patch('builtins.input', side_effect=['cex', 'x', 'cex']): + cex = oracle.find_cex(mm) + + self.assertEqual(cex, ['x']) + + def test_reset_clears_inputs_entered_so_far(self): + mm = sample_mealy() + oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm)) + + with patch('builtins.input', side_effect=['x', 'x', 'reset', 'y', 'cex']): + cex = oracle.find_cex(mm) + + self.assertEqual(cex, ['y']) + + def test_unknown_command_and_letter_not_in_alphabet_are_rejected(self): + mm = sample_mealy() + oracle = UserInputEqOracle(['x', 'y'], AutomatonSUL(mm)) + + with patch('builtins.input', side_effect=['help', 'print alphabet', 'current inputs', + 'not_a_valid_letter', 'x', 'cex']) as mocked_input: + cex = oracle.find_cex(mm) + + self.assertEqual(cex, ['x']) + self.assertEqual(mocked_input.call_count, 6) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/oracles/test_wmethod_oracle.py b/tests/oracles/test_wmethod_oracle.py new file mode 100644 index 00000000000..30e9030824e --- /dev/null +++ b/tests/oracles/test_wmethod_oracle.py @@ -0,0 +1,110 @@ +import unittest + +try: + from aalpy.automata import MooreMachine, MooreState + from aalpy.learning_algs import run_Lstar + from aalpy.oracles.WMethodEqOracle import WMethodEqOracle + from aalpy.SULs import AutomatonSUL + from aalpy.utils import visualize_automaton +except ImportError: + import sys + from pathlib import Path + + # if you want to run the test directly from CLI + # either from root or from tests folder + p = Path(__file__).parent.resolve() + sys.path.append(str(p)) + sys.path.append(str(p.parent)) + sys.path.append(str(p.parent.parent)) + from aalpy.automata import MooreMachine, MooreState + from aalpy.learning_algs import run_Lstar + from aalpy.oracles.WMethodEqOracle import WMethodEqOracle + from aalpy.SULs import AutomatonSUL + from aalpy.utils import visualize_automaton + + +class TestWMethodOracle(unittest.TestCase): + @staticmethod + def gen_moore_from_state_setup(state_setup) -> MooreMachine: + # state_setup shoud map from state_id to tuple(output and transitions_dict) + + # build states with state_id and output + states = {key: MooreState(key, val[0]) for key, val in state_setup.items()} + + # add transitions to states + for state_id, state in states.items(): + for _input, target_state_id in state_setup[state_id][1].items(): + state.transitions[_input] = states[target_state_id] + + # states to list + states = [state for state in states.values()] + + # build moore machine with first state as starting state + mm = MooreMachine(states[0], states) + + for state in states: + state.prefix = mm.get_shortest_path(mm.initial_state, state) + + return mm + + def generate_real_automata(self) -> MooreMachine: + state_setup = { + "a": ("a", {"x": "b1", "y": "a"}), + "b1": ("b", {"x": "b2", "y": "a"}), + "b2": ("b", {"x": "b3", "y": "a"}), + "b3": ("b", {"x": "b4", "y": "a"}), + "b4": ("b", {"x": "c", "y": "a"}), + "c": ("c", {"x": "a", "y": "a"}), + } + + mm = self.gen_moore_from_state_setup(state_setup) + mm.characterization_set = mm.compute_characterization_set() + [tuple()] + return mm + + def generate_hypothesis(self) -> MooreMachine: + state_setup = { + "a": ("a", {"x": "b", "y": "a"}), + "b": ("b", {"x": "b", "y": "a"}), + } + + mm = self.gen_moore_from_state_setup(state_setup) + # ! computer_characterization_set does not work for Moore machines in general! + # mm.characterization_set = mm.compute_characterization_set() + [tuple()] + mm.characterization_set = [tuple(), ("x",), ("y",)] + return mm + + def test_wmethod_oracle(self): + real = self.generate_real_automata() + hyp = self.generate_hypothesis() + # visualize_automaton(real) + # visualize_automaton(hyp) + assert set(real.get_input_alphabet()) == {"x", "y"} + assert set(hyp.get_input_alphabet()) == {"x", "y"} + assert len(real.states) == 6 + assert len(hyp.states) == 2 + alphabet = real.get_input_alphabet() + oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1) + cex = oracle.find_cex(hyp) + assert cex is not None, "Expected a counterexample, but got None" + + def test_wmethod_oracle_with_lstar(self): + real = self.generate_real_automata() + hyp = self.generate_hypothesis() + # visualize_automaton(real) + # visualize_automaton(hyp) + assert real.get_input_alphabet() == ["x", "y"] + assert hyp.get_input_alphabet() == ["x", "y"] + assert len(real.states) == 6 + assert len(hyp.states) == 2 + alphabet = real.get_input_alphabet() + oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1) + lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore") + # print(lstar_hyp) + # visualize_automaton(lstar_hyp) + assert ( + len(lstar_hyp.states) == 6 + ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/oracles/test_wpmethod_oracle.py b/tests/oracles/test_wpmethod_oracle.py new file mode 100644 index 00000000000..8cbd89625a4 --- /dev/null +++ b/tests/oracles/test_wpmethod_oracle.py @@ -0,0 +1,114 @@ +import unittest + +try: + from aalpy.automata import MooreMachine, MooreState + from aalpy.learning_algs import run_Lstar + from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle + from aalpy.SULs import AutomatonSUL + from aalpy.utils import visualize_automaton +except ImportError: + import sys + from pathlib import Path + + # if you want to run the test directly from CLI + # either from root or from tests folder + p = Path(__file__).parent.resolve() + sys.path.append(str(p)) + sys.path.append(str(p.parent)) + sys.path.append(str(p.parent.parent)) + from aalpy.automata import MooreMachine, MooreState + from aalpy.learning_algs import run_Lstar + from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle + from aalpy.SULs import AutomatonSUL + from aalpy.utils import visualize_automaton + + +class TestWpMethodOracle(unittest.TestCase): + @staticmethod + def gen_moore_from_state_setup(state_setup) -> MooreMachine: + # state_setup shoud map from state_id to tuple(output and transitions_dict) + + # build states with state_id and output + states = {key: MooreState(key, val[0]) for key, val in state_setup.items()} + + # add transitions to states + for state_id, state in states.items(): + for _input, target_state_id in state_setup[state_id][1].items(): + state.transitions[_input] = states[target_state_id] + + # states to list + states = [state for state in states.values()] + + # build moore machine with first state as starting state + mm = MooreMachine(states[0], states) + + for state in states: + state.prefix = mm.get_shortest_path(mm.initial_state, state) + + return mm + + def generate_real_automata(self) -> MooreMachine: + state_setup = { + "a": ("a", {"x": "b1", "y": "a"}), + "b1": ("b", {"x": "b2", "y": "a"}), + "b2": ("b", {"x": "b3", "y": "a"}), + "b3": ("b", {"x": "b4", "y": "a"}), + "b4": ("b", {"x": "c", "y": "a"}), + "c": ("c", {"x": "a", "y": "a"}), + } + + mm = self.gen_moore_from_state_setup(state_setup) + mm.characterization_set = mm.compute_characterization_set() + [tuple()] + return mm + + def generate_hypothesis(self) -> MooreMachine: + state_setup = { + "a": ("a", {"x": "b", "y": "a"}), + "b": ("b", {"x": "b", "y": "a"}), + } + + mm = self.gen_moore_from_state_setup(state_setup) + # ! computer_characterization_set does not work for Moore machines in general! + # mm.characterization_set = mm.compute_characterization_set() + [tuple()] + mm.characterization_set = [tuple(), ("x",), ("y",)] + return mm + + def test_wpmethod_oracle(self): + real = self.generate_real_automata() + hyp = self.generate_hypothesis() + # visualize_automaton(real) + # visualize_automaton(hyp) + assert set(real.get_input_alphabet()) == {"x", "y"} + assert set(hyp.get_input_alphabet()) == {"x", "y"} + assert len(real.states) == 6 + assert len(hyp.states) == 2 + alphabet = real.get_input_alphabet() + oracle = WpMethodEqOracle( + alphabet, AutomatonSUL(real), len(real.states) + 1 + ) + cex = oracle.find_cex(hyp) + assert cex is not None, "Expected a counterexample, but got None" + + def test_wpmethod_oracle_with_lstar(self): + real = self.generate_real_automata() + hyp = self.generate_hypothesis() + # visualize_automaton(real) + # visualize_automaton(hyp) + assert real.get_input_alphabet() == ["x", "y"] + assert hyp.get_input_alphabet() == ["x", "y"] + assert len(real.states) == 6 + assert len(hyp.states) == 2 + alphabet = real.get_input_alphabet() + oracle = WpMethodEqOracle( + alphabet, AutomatonSUL(real), len(real.states) + 1 + ) + lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore") + # print(lstar_hyp) + # visualize_automaton(lstar_hyp) + assert ( + len(lstar_hyp.states) == 6 + ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis" + + +if __name__ == "__main__": + unittest.main() From 7de29ed77d5d1bc03811e7addd896ada8112027e Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:15:53 +0200 Subject: [PATCH 14/25] Add SULs and utils tests --- tests/SULs/test_automata_sul.py | 210 ++++++++++++++ tests/SULs/test_py_method_sul.py | 81 ++++++ tests/SULs/test_regex_sul.py | 56 ++++ tests/SULs/test_tomita_sul.py | 78 +++++ tests/utils/test_automaton_generators.py | 254 ++++++++++++++++ tests/utils/test_char_set.py | 73 +++++ tests/utils/test_data_handler.py | 83 ++++++ tests/utils/test_file_handler_extra.py | 154 ++++++++++ tests/utils/test_file_operations.py | 86 ++++++ tests/utils/test_helper_functions.py | 353 +++++++++++++++++++++++ tests/utils/test_model_checking.py | 160 ++++++++++ tests/utils/test_sampling.py | 157 ++++++++++ 12 files changed, 1745 insertions(+) create mode 100644 tests/SULs/test_automata_sul.py create mode 100644 tests/SULs/test_py_method_sul.py create mode 100644 tests/SULs/test_regex_sul.py create mode 100644 tests/SULs/test_tomita_sul.py create mode 100644 tests/utils/test_automaton_generators.py create mode 100644 tests/utils/test_char_set.py create mode 100644 tests/utils/test_data_handler.py create mode 100644 tests/utils/test_file_handler_extra.py create mode 100644 tests/utils/test_file_operations.py create mode 100644 tests/utils/test_helper_functions.py create mode 100644 tests/utils/test_model_checking.py create mode 100644 tests/utils/test_sampling.py diff --git a/tests/SULs/test_automata_sul.py b/tests/SULs/test_automata_sul.py new file mode 100644 index 00000000000..78802030dd9 --- /dev/null +++ b/tests/SULs/test_automata_sul.py @@ -0,0 +1,210 @@ +import unittest + +from aalpy.automata import (Dfa, DfaState, MealyMachine, MealyState, Mdp, MdpState, MooreMachine, MooreState, + Onfsm, OnfsmState) +from aalpy.SULs import AutomatonSUL, DfaSUL, MealySUL, MdpSUL, MooreSUL, OnfsmSUL + + +def parity_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +def sample_mealy(): + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'x': s1, 'y': s0} + s0.output_fun = {'x': 'o1', 'y': 'o2'} + s1.transitions = {'x': s0, 'y': s1} + s1.output_fun = {'x': 'o3', 'y': 'o1'} + return MealyMachine(s0, [s0, s1]) + + +def sample_moore(): + s0 = MooreState('s0', output='A') + s1 = MooreState('s1', output='B') + s0.transitions = {'x': s1, 'y': s0} + s1.transitions = {'x': s0, 'y': s1} + return MooreMachine(s0, [s0, s1]) + + +def deterministic_mdp(): + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s0.transitions['a'].append((s1, 1.0)) + s1.transitions['a'].append((s0, 1.0)) + return Mdp(s0, [s0, s1]) + + +def sample_onfsm(): + s0 = OnfsmState('s0') + s1 = OnfsmState('s1') + s0.transitions['a'].append(('out1', s1)) + s1.transitions['a'].append(('out2', s0)) + return Onfsm(s0, [s0, s1]) + + +class TestAutomatonSULAliases(unittest.TestCase): + """DfaSUL, MealySUL, MooreSUL, MdpSUL, OnfsmSUL are all just AutomatonSUL under a different name.""" + + def test_aliases_are_automaton_sul(self): + self.assertIs(DfaSUL, AutomatonSUL) + self.assertIs(MealySUL, AutomatonSUL) + self.assertIs(MooreSUL, AutomatonSUL) + self.assertIs(MdpSUL, AutomatonSUL) + self.assertIs(OnfsmSUL, AutomatonSUL) + + +class TestAutomatonSULConstruction(unittest.TestCase): + def test_wraps_given_automaton(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + self.assertIs(sul.automaton, dfa) + + def test_counters_start_at_zero(self): + sul = AutomatonSUL(parity_dfa()) + self.assertEqual(sul.num_queries, 0) + self.assertEqual(sul.num_steps, 0) + self.assertEqual(sul.num_cached_queries, 0) + + +class TestAutomatonSULWithDfa(unittest.TestCase): + def test_step_delegates_to_automaton_and_moves_state(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + self.assertFalse(sul.step('a')) + self.assertIs(dfa.current_state, dfa.get_state_by_id('q1')) + self.assertTrue(sul.step('a')) + self.assertIs(dfa.current_state, dfa.initial_state) + + def test_pre_resets_wrapped_automaton_to_initial_state(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + sul.step('a') + self.assertIsNot(dfa.current_state, dfa.initial_state) + sul.pre() + self.assertIs(dfa.current_state, dfa.initial_state) + + def test_post_is_a_no_op(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + sul.step('a') + state_before = dfa.current_state + sul.post() + self.assertIs(dfa.current_state, state_before) + + def test_query_matches_manual_stepping(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + self.assertEqual(sul.query(('a', 'b', 'a')), [False, False, True]) + + def test_query_empty_word_returns_initial_state_output(self): + sul = AutomatonSUL(parity_dfa()) + self.assertEqual(sul.query(()), [True]) + + def test_query_resets_before_running(self): + dfa = parity_dfa() + sul = AutomatonSUL(dfa) + sul.step('a') # leave the automaton in a non-initial state + self.assertEqual(sul.query(('a',)), [False]) # query() calls pre() first, so this is from q0 again + + def test_io_query_pairs_inputs_with_outputs(self): + sul = AutomatonSUL(parity_dfa()) + self.assertEqual(sul.io_query(('a', 'a')), [('a', False), ('a', True)]) + + def test_num_queries_and_steps_accumulate_across_calls(self): + sul = AutomatonSUL(parity_dfa()) + sul.query(('a', 'b')) + sul.query(('a',)) + sul.query(()) + self.assertEqual(sul.num_queries, 3) + self.assertEqual(sul.num_steps, 3) # the empty query contributes 0 steps + + def test_multiple_suls_wrapping_the_same_automaton_share_state(self): + dfa = parity_dfa() + sul1 = AutomatonSUL(dfa) + sul2 = AutomatonSUL(dfa) + sul1.step('a') + # both wrap the very same automaton object, so state is not per-SUL + self.assertIs(sul1.automaton.current_state, sul2.automaton.current_state) + + +class TestAutomatonSULWithMealy(unittest.TestCase): + def test_step_returns_output_and_moves_state(self): + mm = sample_mealy() + sul = AutomatonSUL(mm) + self.assertEqual(sul.step('x'), 'o1') + self.assertIs(mm.current_state, mm.get_state_by_id('s1')) + + def test_query_matches_manual_stepping(self): + sul = AutomatonSUL(sample_mealy()) + self.assertEqual(sul.query(('x', 'x', 'y')), ['o1', 'o3', 'o2']) + + def test_query_empty_word_raises_key_error(self): + # Mealy machines have no state-based output, so there is no well-defined answer for the + # empty word; SUL.query(()) calls step(None), and Mealy's output_fun has no None entry. + sul = AutomatonSUL(sample_mealy()) + with self.assertRaises(KeyError): + sul.query(()) + + def test_io_query_pairs_inputs_with_outputs(self): + sul = AutomatonSUL(sample_mealy()) + self.assertEqual(sul.io_query(('x', 'y')), [('x', 'o1'), ('y', 'o1')]) + + +class TestAutomatonSULWithMoore(unittest.TestCase): + def test_step_returns_output_of_reached_state(self): + mm = sample_moore() + sul = AutomatonSUL(mm) + self.assertEqual(sul.step('x'), 'B') + self.assertIs(mm.current_state, mm.get_state_by_id('s1')) + + def test_query_matches_manual_stepping(self): + sul = AutomatonSUL(sample_moore()) + self.assertEqual(sul.query(('x', 'y', 'x')), ['B', 'B', 'A']) + + def test_query_empty_word_returns_initial_state_output(self): + sul = AutomatonSUL(sample_moore()) + self.assertEqual(sul.query(()), ['A']) + + def test_io_query_pairs_inputs_with_outputs(self): + sul = AutomatonSUL(sample_moore()) + self.assertEqual(sul.io_query(('x',)), [('x', 'B')]) + + +class TestAutomatonSULWithMdp(unittest.TestCase): + def test_step_delegates_and_moves_state(self): + mdp = deterministic_mdp() + sul = AutomatonSUL(mdp) + self.assertEqual(sul.step('a'), 'B') + self.assertIs(mdp.current_state, mdp.get_state_by_id('s1')) + + def test_query_matches_manual_stepping(self): + sul = AutomatonSUL(deterministic_mdp()) + self.assertEqual(sul.query(('a', 'a', 'a')), ['B', 'A', 'B']) + + def test_query_empty_word_returns_initial_state_output_without_moving(self): + mdp = deterministic_mdp() + sul = AutomatonSUL(mdp) + self.assertEqual(sul.query(()), ['A']) + self.assertIs(mdp.current_state, mdp.initial_state) + + +class TestAutomatonSULWithOnfsm(unittest.TestCase): + def test_step_returns_one_of_the_possible_outputs(self): + onfsm = sample_onfsm() + sul = AutomatonSUL(onfsm) + output = sul.step('a') + self.assertIn(output, ('out1',)) + self.assertIs(onfsm.current_state, onfsm.get_state_by_id('s1')) + + def test_query_matches_manual_stepping(self): + sul = AutomatonSUL(sample_onfsm()) + self.assertEqual(sul.query(('a', 'a')), ['out1', 'out2']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/SULs/test_py_method_sul.py b/tests/SULs/test_py_method_sul.py new file mode 100644 index 00000000000..d2c9a112e1f --- /dev/null +++ b/tests/SULs/test_py_method_sul.py @@ -0,0 +1,81 @@ +import unittest + +from aalpy.SULs.PyMethodSUL import FunctionDecorator, PyClassSUL + + +class Counter: + """Tiny stateful class used as a Python-class SUL target.""" + + def __init__(self): + self.value = 0 + + def increment(self): + self.value += 1 + return self.value + + def add(self, amount): + self.value += amount + return self.value + + def get(self): + return self.value + + +class TestFunctionDecorator(unittest.TestCase): + def test_repr_without_args(self): + decorator = FunctionDecorator(Counter.increment) + self.assertEqual(repr(decorator), 'increment') + + def test_repr_with_single_arg(self): + decorator = FunctionDecorator(Counter.add, args=5) + self.assertEqual(repr(decorator), 'add[5]') + + def test_repr_with_multiple_args(self): + decorator = FunctionDecorator(Counter.add, args=[1, 2]) + self.assertEqual(repr(decorator), 'add[1, 2]') + + def test_no_args_when_none_given(self): + decorator = FunctionDecorator(Counter.increment, args=None) + self.assertIsNone(decorator.args) + + +class TestPyClassSUL(unittest.TestCase): + def test_pre_creates_a_fresh_instance(self): + sul = PyClassSUL(Counter) + sul.pre() + first_instance = sul.sul + sul.step(FunctionDecorator(Counter.increment)) + sul.pre() + second_instance = sul.sul + + self.assertIsNot(first_instance, second_instance) + self.assertEqual(second_instance.value, 0) + + def test_step_calls_method_without_args(self): + sul = PyClassSUL(Counter) + sul.pre() + result = sul.step(FunctionDecorator(Counter.increment)) + self.assertEqual(result, 1) + + def test_step_calls_method_with_args(self): + sul = PyClassSUL(Counter) + sul.pre() + result = sul.step(FunctionDecorator(Counter.add, args=5)) + self.assertEqual(result, 5) + + def test_state_persists_across_steps_within_one_pre(self): + sul = PyClassSUL(Counter) + sul.pre() + sul.step(FunctionDecorator(Counter.increment)) + sul.step(FunctionDecorator(Counter.increment)) + result = sul.step(FunctionDecorator(Counter.get)) + self.assertEqual(result, 2) + + def test_query_runs_sequence_of_function_calls(self): + sul = PyClassSUL(Counter) + result = sul.query((FunctionDecorator(Counter.increment), FunctionDecorator(Counter.add, args=3))) + self.assertEqual(result, [1, 4]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/SULs/test_regex_sul.py b/tests/SULs/test_regex_sul.py new file mode 100644 index 00000000000..28631c2b59a --- /dev/null +++ b/tests/SULs/test_regex_sul.py @@ -0,0 +1,56 @@ +import unittest + +from aalpy.SULs import RegexSUL + + +class TestRegexSUL(unittest.TestCase): + def test_appends_trailing_dollar_if_missing(self): + sul = RegexSUL('ab*') + self.assertEqual(sul.regex, 'ab*$') + + def test_does_not_duplicate_trailing_dollar(self): + sul = RegexSUL('ab*$') + self.assertEqual(sul.regex, 'ab*$') + + def test_pre_resets_accumulated_string(self): + sul = RegexSUL('a$') + sul.pre() + sul.step('a') + self.assertEqual(sul.string, 'a') + sul.pre() + self.assertEqual(sul.string, '') + + def test_post_resets_accumulated_string(self): + sul = RegexSUL('a$') + sul.pre() + sul.step('a') + sul.post() + self.assertEqual(sul.string, '') + + def test_step_none_does_not_change_string(self): + sul = RegexSUL('a$') + sul.pre() + sul.step(None) + self.assertEqual(sul.string, '') + + def test_exact_match_required(self): + sul = RegexSUL('ab') + self.assertEqual(sul.query(('a', 'b')), [False, True]) + + def test_query_empty_word(self): + sul = RegexSUL('a*') + # empty string matches 'a*$' + self.assertEqual(sul.query(()), [True]) + + def test_rejects_once_pattern_cannot_match(self): + sul = RegexSUL('ab$') + self.assertEqual(sul.query(('b',)), [False]) + + def test_accepts_intermediate_and_final_prefixes(self): + sul = RegexSUL('a+b') + result = sul.query(('a', 'a', 'b')) + self.assertEqual(result, [False, False, True]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/SULs/test_tomita_sul.py b/tests/SULs/test_tomita_sul.py new file mode 100644 index 00000000000..44f234cdcbd --- /dev/null +++ b/tests/SULs/test_tomita_sul.py @@ -0,0 +1,78 @@ +import unittest + +from aalpy.SULs import TomitaSUL + + +class TestTomitaSUL(unittest.TestCase): + def test_invalid_level_raises(self): + with self.assertRaises(AssertionError): + TomitaSUL(0) + + def test_pre_resets_accumulated_string(self): + sul = TomitaSUL(1) + sul.pre() + sul.step('0') + self.assertEqual(sul.string, '0') + sul.pre() + self.assertEqual(sul.string, '') + + def test_post_resets_accumulated_string(self): + sul = TomitaSUL(1) + sul.pre() + sul.step('1') + sul.post() + self.assertEqual(sul.string, '') + + def test_step_none_does_not_corrupt_accumulated_string(self): + # regression test: step() used to check `if input` (the builtin, always truthy) instead of + # `if letter is not None`, so step(None) appended the literal text "None" to the string + sul = TomitaSUL(1) + sul.pre() + result = sul.step(None) + self.assertEqual(sul.string, '') + self.assertTrue(result) + + def test_query_empty_word_on_tomita_1(self): + # tomita_1 accepts the empty word (no '0's in it) + sul = TomitaSUL(1) + self.assertEqual(sul.query(()), [True]) + + def test_tomita_1_accepts_only_words_without_zero(self): + sul = TomitaSUL(1) + self.assertEqual(sul.query(('1', '1', '1')), [True, True, True]) + self.assertEqual(sul.query(('1', '0', '1')), [True, False, False]) + + def test_tomita_2_accepts_only_repeated_10(self): + sul = TomitaSUL(2) + self.assertEqual(sul.query(('1', '0', '1', '0')), [False, True, False, True]) + + def test_tomita_3_and_its_negation_are_complementary(self): + sul3 = TomitaSUL(3) + sul_not3 = TomitaSUL(-3) + word = ('1', '0', '0', '1', '1', '0') + result3 = sul3.query(word) + result_not3 = sul_not3.query(word) + self.assertEqual(result3, [not r for r in result_not3]) + + def test_tomita_4_rejects_three_consecutive_zeros(self): + sul = TomitaSUL(4) + self.assertEqual(sul.query(('0', '0', '0')), [True, True, False]) + + def test_tomita_5_even_counts_of_both_symbols(self): + sul = TomitaSUL(5) + self.assertEqual(sul.query(()), [True]) + self.assertEqual(sul.query(('0', '0', '1', '1')), [False, True, False, True]) + + def test_tomita_6_difference_divisible_by_three(self): + sul = TomitaSUL(6) + self.assertEqual(sul.query(()), [True]) + self.assertEqual(sul.query(('0', '0', '0')), [False, False, True]) + + def test_tomita_7_at_most_one_descent(self): + sul = TomitaSUL(7) + self.assertEqual(sul.query(('1', '1', '0', '0', '1', '1')), [True, True, True, True, True, True]) + self.assertEqual(sul.query(('1', '0', '1', '0')), [True, True, True, False]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/test_automaton_generators.py b/tests/utils/test_automaton_generators.py new file mode 100644 index 00000000000..c0e376b2f87 --- /dev/null +++ b/tests/utils/test_automaton_generators.py @@ -0,0 +1,254 @@ +import random +import unittest + +from aalpy.automata import Dfa, MealyMachine, MooreMachine, Mdp, Onfsm, MarkovChain, Sevpa, StochasticMealyMachine +from aalpy.utils import ( + generate_random_deterministic_automata, + generate_random_dfa, + generate_random_mealy_machine, + generate_random_moore_machine, + generate_random_mdp, + generate_random_smm, + generate_random_ONFSM, + generate_random_markov_chain, + generate_random_sevpa, +) + + +def all_states_reachable(automaton): + reached = set() + to_visit = [automaton.initial_state] + while to_visit: + state = to_visit.pop() + if state.state_id in reached: + continue + reached.add(state.state_id) + for target in _successors(state): + to_visit.append(target) + return reached == {s.state_id for s in automaton.states} + + +def _successors(state): + transitions = getattr(state, 'transitions', None) + if transitions is None: + return [] + if isinstance(transitions, list): + return [target for target, _ in transitions] + successors = [] + for value in transitions.values(): + if value is None: + continue + if isinstance(value, list): + for entry in value: + if hasattr(entry, 'target_state'): + successors.append(entry.target_state) + elif isinstance(entry, tuple): + successors.append(entry[0] if not isinstance(entry[0], str) else entry[1]) + else: + successors.append(value) + return successors + + +class TestGenerateRandomDeterministicAutomata(unittest.TestCase): + def test_dfa_structure(self): + random.seed(1) + dfa = generate_random_deterministic_automata('dfa', num_states=5, input_alphabet_size=3) + self.assertIsInstance(dfa, Dfa) + self.assertEqual(len(dfa.states), 5) + self.assertEqual(len(dfa.get_input_alphabet()), 3) + self.assertTrue(all_states_reachable(dfa)) + for state in dfa.states: + self.assertEqual(set(state.transitions.keys()), set(dfa.get_input_alphabet())) + + def test_mealy_structure(self): + random.seed(2) + mealy = generate_random_deterministic_automata('mealy', num_states=4, input_alphabet_size=2, + output_alphabet_size=3) + self.assertIsInstance(mealy, MealyMachine) + self.assertEqual(len(mealy.states), 4) + for state in mealy.states: + for i in mealy.get_input_alphabet(): + self.assertIn(i, state.output_fun) + + def test_moore_structure(self): + random.seed(3) + moore = generate_random_deterministic_automata('moore', num_states=4, input_alphabet_size=2, + output_alphabet_size=3) + self.assertIsInstance(moore, MooreMachine) + for state in moore.states: + self.assertIsNotNone(state.output) + + def test_invalid_automaton_type_raises(self): + with self.assertRaises(AssertionError): + generate_random_deterministic_automata('not_a_type', num_states=3, input_alphabet_size=2) + + def test_custom_input_alphabet_wrong_length_raises(self): + with self.assertRaises(AssertionError): + generate_random_deterministic_automata('dfa', num_states=3, input_alphabet_size=2, + custom_input_alphabet=['a', 'b', 'c']) + + def test_custom_output_alphabet_wrong_length_raises(self): + with self.assertRaises(AssertionError): + generate_random_deterministic_automata('mealy', num_states=3, input_alphabet_size=2, + output_alphabet_size=2, custom_output_alphabet=['a']) + + def test_num_accepting_states_respected(self): + random.seed(4) + dfa = generate_random_deterministic_automata('dfa', num_states=6, input_alphabet_size=2, + ensure_minimality=False, num_accepting_states=2) + num_accepting = sum(1 for s in dfa.states if s.is_accepting) + self.assertEqual(num_accepting, 2) + + def test_ensure_minimality_false_still_valid_automaton(self): + random.seed(5) + dfa = generate_random_deterministic_automata('dfa', num_states=4, input_alphabet_size=2, + ensure_minimality=False) + self.assertEqual(len(dfa.states), 4) + self.assertTrue(all_states_reachable(dfa)) + + def test_ensure_minimality_true_produces_minimal_automaton(self): + random.seed(6) + for _ in range(5): + dfa = generate_random_deterministic_automata('dfa', num_states=4, input_alphabet_size=2) + self.assertTrue(dfa.is_minimal()) + self.assertEqual(dfa.size, 4) + + +class TestGenerateRandomDfaMealyMoore(unittest.TestCase): + def test_generate_random_dfa(self): + random.seed(7) + dfa = generate_random_dfa(num_states=5, alphabet=['a', 'b'], num_accepting_states=2) + self.assertEqual(len(dfa.states), 5) + self.assertEqual(set(dfa.get_input_alphabet()), {'a', 'b'}) + + def test_generate_random_dfa_too_many_accepting_states_is_corrected(self): + random.seed(8) + dfa = generate_random_dfa(num_states=4, alphabet=['a', 'b'], num_accepting_states=10, + ensure_minimality=False) + num_accepting = sum(1 for s in dfa.states if s.is_accepting) + self.assertLessEqual(num_accepting, 4) + + def test_generate_random_mealy_machine(self): + random.seed(9) + mealy = generate_random_mealy_machine(num_states=4, input_alphabet=['a', 'b'], + output_alphabet=['x', 'y', 'z']) + self.assertIsInstance(mealy, MealyMachine) + self.assertEqual(set(mealy.get_input_alphabet()), {'a', 'b'}) + + def test_generate_random_moore_machine(self): + random.seed(10) + moore = generate_random_moore_machine(num_states=4, input_alphabet=['a', 'b'], + output_alphabet=['x', 'y', 'z']) + self.assertIsInstance(moore, MooreMachine) + self.assertEqual(set(moore.get_input_alphabet()), {'a', 'b'}) + + def test_compute_prefixes_flag(self): + random.seed(11) + mealy = generate_random_mealy_machine(num_states=3, input_alphabet=['a', 'b'], + output_alphabet=['x', 'y'], compute_prefixes=True) + for state in mealy.states: + self.assertIsNotNone(state.prefix) + + +class TestGenerateRandomMdpSmm(unittest.TestCase): + def test_generate_random_mdp_structure(self): + random.seed(12) + mdp = generate_random_mdp(num_states=5, input_size=2, output_size=3) + self.assertIsInstance(mdp, Mdp) + self.assertEqual(len(mdp.states), 5) + for state in mdp.states: + for i in mdp.get_input_alphabet(): + probs = [p for _, p in state.transitions[i]] + self.assertAlmostEqual(sum(probs), 1.0, places=5) + + def test_generate_random_mdp_deterministic_labeling(self): + random.seed(13) + mdp = generate_random_mdp(num_states=6, input_size=2, output_size=4) + for state in mdp.states: + for i in mdp.get_input_alphabet(): + outputs = [s.output for s, _ in state.transitions[i]] + self.assertEqual(len(outputs), len(set(outputs))) + + def test_generate_random_smm_structure(self): + random.seed(14) + smm = generate_random_smm(num_states=5, input_size=2, output_size=3) + self.assertIsInstance(smm, StochasticMealyMachine) + for state in smm.states: + for i in smm.get_input_alphabet(): + probs = [p for _, _, p in state.transitions[i]] + self.assertAlmostEqual(sum(probs), 1.0, places=5) + + +class TestGenerateRandomOnfsm(unittest.TestCase): + def test_structure(self): + random.seed(15) + onfsm = generate_random_ONFSM(num_states=5, num_inputs=3, num_outputs=3) + self.assertIsInstance(onfsm, Onfsm) + self.assertEqual(len(onfsm.states), 5) + for state in onfsm.states: + self.assertEqual(set(state.transitions.keys()), set(onfsm.get_input_alphabet())) + + def test_multiple_out_prob_zero_gives_single_output_per_transition(self): + random.seed(16) + onfsm = generate_random_ONFSM(num_states=4, num_inputs=2, num_outputs=3, multiple_out_prob=0.0) + for state in onfsm.states: + for i in state.transitions: + self.assertEqual(len(state.transitions[i]), 1) + + +class TestGenerateRandomMarkovChain(unittest.TestCase): + def test_structure(self): + random.seed(17) + mc = generate_random_markov_chain(num_states=5) + self.assertIsInstance(mc, MarkovChain) + self.assertEqual(len(mc.states), 5) + + def test_too_few_states_raises(self): + with self.assertRaises(AssertionError): + generate_random_markov_chain(num_states=2) + + def test_transition_probabilities_sum_to_one(self): + random.seed(18) + mc = generate_random_markov_chain(num_states=6) + for state in mc.states[:-1]: + probs = [p for _, p in state.transitions] + self.assertAlmostEqual(sum(probs), 1.0, places=5) + + def test_last_state_has_no_outgoing_transitions(self): + random.seed(19) + mc = generate_random_markov_chain(num_states=5) + self.assertEqual(mc.states[-1].transitions, []) + + +class TestGenerateRandomSevpa(unittest.TestCase): + def test_structure(self): + random.seed(20) + sevpa = generate_random_sevpa(num_states=5, internal_alphabet_size=2, call_alphabet_size=2, + return_alphabet_size=2, acceptance_prob=0.5, return_transition_prob=0.5) + self.assertIsInstance(sevpa, Sevpa) + self.assertEqual(len(sevpa.states), 5) + + def test_all_internal_letters_defined_for_all_states(self): + # regression test: the completeness pass used to check `transitions[letter] is None`, but + # transitions is a defaultdict(list), so missing letters resolve to `[]`, never `None` - + # meaning some states could be left without a transition on some internal letters. + random.seed(21) + sevpa = generate_random_sevpa(num_states=4, internal_alphabet_size=2, call_alphabet_size=2, + return_alphabet_size=2, acceptance_prob=0.3, return_transition_prob=0.3) + for state in sevpa.states: + for internal_letter in sevpa.input_alphabet.internal_alphabet: + self.assertIsNotNone(state.transitions[internal_letter]) + self.assertGreater(len(state.transitions[internal_letter]), 0) + + def test_return_transitions_defined_for_all_stack_states(self): + random.seed(22) + sevpa = generate_random_sevpa(num_states=3, internal_alphabet_size=1, call_alphabet_size=2, + return_alphabet_size=2, acceptance_prob=0.5, return_transition_prob=0.7) + for state in sevpa.states: + for return_letter in sevpa.input_alphabet.return_alphabet: + self.assertIsNotNone(state.transitions[return_letter]) + self.assertGreaterEqual(len(state.transitions[return_letter]), len(sevpa.states) * len(sevpa.input_alphabet.call_alphabet)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/test_char_set.py b/tests/utils/test_char_set.py new file mode 100644 index 00000000000..91faa8db563 --- /dev/null +++ b/tests/utils/test_char_set.py @@ -0,0 +1,73 @@ +import unittest +from pathlib import Path + +from aalpy.utils import get_Angluin_dfa, load_automaton_from_file +from aalpy.utils.HelperFunctions import all_suffixes + +DOT_MODELS_DIR = Path(__file__).resolve().parent.parent.parent / 'DotModels' + + +class TestCharSet(unittest.TestCase): + + def get_test_automata(self): + return {"angluin_dfa": get_Angluin_dfa(), + "angluin_mealy": load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Mealy.dot', + automaton_type='mealy'), + "angluin_moore": load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Moore.dot', + automaton_type='moore'), + "mqtt": load_automaton_from_file(DOT_MODELS_DIR / 'MQTT/emqtt__two_client_will_retain.dot', + automaton_type='mealy'), + "openssl": load_automaton_from_file(DOT_MODELS_DIR / 'TLS/OpenSSL_1.0.2_server_regular.dot', + automaton_type='mealy'), + "tcp_server": load_automaton_from_file(DOT_MODELS_DIR / 'TCP/TCP_Linux_Client.dot', + automaton_type='mealy')} + + def test_can_differentiate(self): + automata = self.get_test_automata() + for init_with_alphabet in [True, False]: + for (online_suffix_closure, split_all_blocks) in [(False, False), (False, True), (True, False), + (True, True)]: + for test_aut_name in automata: + print(f"Testing with {test_aut_name}") + test_aut = automata[test_aut_name] + char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \ + if init_with_alphabet else None + if "dfa" in test_aut_name or "moore" in test_aut_name: + char_set_init = [] if char_set_init is None else char_set_init + char_set_init.append(()) + char_set = test_aut.compute_characterization_set(char_set_init=char_set_init, + online_suffix_closure=online_suffix_closure, + split_all_blocks=split_all_blocks) + print(f"Char. set {char_set}") + all_responses = set() + for s in test_aut.states: + responses_from_s = [] + for c in char_set: + responses_from_s.append(tuple(test_aut.compute_output_seq(s, c))) + all_responses.add(tuple(responses_from_s)) + + # every state must have a unique response to the whole characterization set + assert len(all_responses) == len(test_aut.states) + + def test_suffix_closed(self): + automata = self.get_test_automata() + for init_with_alphabet in [True, False]: + online_suffix_closure = True + for split_all_blocks in [True, False]: + for test_aut_name in automata: + print(f"Testing with {test_aut_name}") + test_aut = automata[test_aut_name] + char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \ + if init_with_alphabet else None + if "dfa" in test_aut_name or "moore" in test_aut_name: + char_set_init = [] if char_set_init is None else char_set_init + char_set_init.append(()) + char_set = test_aut.compute_characterization_set(char_set_init=char_set_init, + online_suffix_closure=online_suffix_closure, + split_all_blocks=split_all_blocks) + print(f"Char. set {char_set}") + for s in char_set: + for suffix in all_suffixes(s): + if suffix not in char_set: + print(suffix) + assert suffix in char_set diff --git a/tests/utils/test_data_handler.py b/tests/utils/test_data_handler.py new file mode 100644 index 00000000000..0a15bf942b1 --- /dev/null +++ b/tests/utils/test_data_handler.py @@ -0,0 +1,83 @@ +import tempfile +import unittest +from pathlib import Path + +from aalpy.utils.DataHandler import CharacterTokenizer, DelimiterTokenizer, IODelimiterTokenizer, try_int + + +class TestTryInt(unittest.TestCase): + def test_digit_string_converted(self): + self.assertEqual(try_int('42'), 42) + self.assertIsInstance(try_int('42'), int) + + def test_non_digit_string_unchanged(self): + self.assertEqual(try_int('abc'), 'abc') + + def test_negative_number_not_converted(self): + self.assertEqual(try_int('-1'), '-1') + + +class TestCharacterTokenizer(unittest.TestCase): + def test_tokenizes_each_line_into_characters(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('abc\nde\n') + data = CharacterTokenizer().tokenize_data(str(path)) + self.assertEqual(data, [['a', 'b', 'c'], ['d', 'e']]) + + def test_empty_file_produces_empty_list(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('') + data = CharacterTokenizer().tokenize_data(str(path)) + self.assertEqual(data, []) + + +class TestDelimiterTokenizer(unittest.TestCase): + def test_default_comma_delimiter(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('a,b,c\nx,y\n') + data = DelimiterTokenizer().tokenize_data(str(path)) + self.assertEqual(data, [['a', 'b', 'c'], ['x', 'y']]) + + def test_custom_delimiter(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('a;b;c\n') + data = DelimiterTokenizer().tokenize_data(str(path), delimiter=';') + self.assertEqual(data, [['a', 'b', 'c']]) + + +class TestIODelimiterTokenizer(unittest.TestCase): + def test_tokenizes_initial_output_and_io_pairs(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('out0,i1/o1,i2/o2\n') + data = IODelimiterTokenizer().tokenize_data(str(path)) + self.assertEqual(data, [['out0', ('i1', 'o1'), ('i2', 'o2')]]) + + def test_digit_inputs_outputs_converted_to_int(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('0,1/2,3/4\n') + data = IODelimiterTokenizer().tokenize_data(str(path)) + self.assertEqual(data, [['0', (1, 2), (3, 4)]]) + + def test_custom_delimiters(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('out0|i1:o1|i2:o2\n') + data = IODelimiterTokenizer().tokenize_data(str(path), io_delimiter=':', word_delimiter='|') + self.assertEqual(data, [['out0', ('i1', 'o1'), ('i2', 'o2')]]) + + def test_malformed_io_word_exits(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'data.txt' + path.write_text('out0,badword\n') + with self.assertRaises(SystemExit): + IODelimiterTokenizer().tokenize_data(str(path)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/test_file_handler_extra.py b/tests/utils/test_file_handler_extra.py new file mode 100644 index 00000000000..b8a5ef7aaa0 --- /dev/null +++ b/tests/utils/test_file_handler_extra.py @@ -0,0 +1,154 @@ +import tempfile +import time +import unittest +from pathlib import Path + +from aalpy.automata import NDMooreMachine, NDMooreState +from aalpy.utils import save_automaton_to_file, load_automaton_from_file, visualize_automaton +from aalpy.utils.BenchmarkSULs import get_Angluin_dfa, get_benchmark_ONFSM, get_faulty_coffee_machine_SMM +from aalpy.utils.BenchmarkSevpaModels import sevpa_for_L1 +from aalpy.utils.BenchmarkVpaModels import vpa_L1 + + +def ndmoore_machine(): + q0 = NDMooreState('q0', output='x') + q1 = NDMooreState('q1', output='y') + q0.transitions['a'].append(q0) + q0.transitions['a'].append(q1) + q1.transitions['a'].append(q1) + return NDMooreMachine(q0, [q0, q1]) + + +class TestSaveAutomatonToFileEdgeCases(unittest.TestCase): + def test_unsupported_file_type_raises(self): + dfa = get_Angluin_dfa() + with self.assertRaises(AssertionError): + save_automaton_to_file(dfa, path='irrelevant', file_type='bogus') + + def test_string_file_type_returns_dot_string_without_writing_file(self): + dfa = get_Angluin_dfa() + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'model' + result = save_automaton_to_file(dfa, path=str(path), file_type='string') + self.assertIsInstance(result, str) + self.assertIn('digraph', result) + self.assertFalse(path.with_suffix('.string').exists()) + + def test_string_output_contains_all_state_ids(self): + dfa = get_Angluin_dfa() + result = save_automaton_to_file(dfa, file_type='string') + for state in dfa.states: + self.assertIn(state.state_id, result) + + +class TestSaveLoadAdditionalAutomatonTypes(unittest.TestCase): + def test_ndmoore_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'ndmoore' + model = ndmoore_machine() + model.save(str(path)) + loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='ndmoore') + self.assertEqual(loaded.size, model.size) + self.assertEqual(set(loaded.get_input_alphabet()), set(model.get_input_alphabet())) + for state in loaded.states: + self.assertIsNotNone(state.output) + + def test_sevpa_roundtrip_preserves_alphabet_and_acceptance(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'sevpa' + model = sevpa_for_L1() + model.save(str(path)) + loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='sevpa') + self.assertEqual(loaded.size, model.size) + self.assertEqual(set(model.get_input_alphabet().get_merged_alphabet()), + set(loaded.get_input_alphabet().get_merged_alphabet())) + self.assertEqual({s.state_id for s in model.states if s.is_accepting}, + {s.state_id for s in loaded.states if s.is_accepting}) + + def test_vpa_roundtrip_skips_error_sink_state(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'vpa' + model = vpa_L1() + non_sink_states = [s for s in model.states if s.state_id != 'ErrorSinkState'] + model.save(str(path)) + loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='vpa') + self.assertEqual(loaded.size, len(non_sink_states)) + self.assertEqual(set(model.get_input_alphabet().get_merged_alphabet()), + set(loaded.get_input_alphabet().get_merged_alphabet())) + + def test_onfsm_roundtrip_preserves_transitions_shape(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'onfsm' + model = get_benchmark_ONFSM() + model.save(str(path)) + loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='onfsm') + self.assertEqual(loaded.size, model.size) + for state in loaded.states: + for i in loaded.get_input_alphabet(): + self.assertGreater(len(state.transitions[i]), 0) + + def test_smm_roundtrip_preserves_probabilities(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'smm' + model = get_faulty_coffee_machine_SMM() + model.save(str(path)) + loaded = load_automaton_from_file(path.with_suffix('.dot'), automaton_type='smm') + for orig_state, loaded_state in zip(model.states, loaded.states): + for i in model.get_input_alphabet(): + orig_probs = sorted(p for _, _, p in orig_state.transitions[i]) + loaded_probs = sorted(p for _, _, p in loaded_state.transitions[i]) + self.assertEqual(orig_probs, loaded_probs) + + +class TestLoadMalformedDotFile(unittest.TestCase): + def test_missing_start_state_raises(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'bad.dot' + path.write_text('digraph g {\nq0 [label="q0"];\nq0 -> q0 [label="a"];\n}\n') + with self.assertRaises(AssertionError): + load_automaton_from_file(path, automaton_type='dfa') + + def test_start_state_pointing_to_undefined_state_raises(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'bad.dot' + path.write_text( + 'digraph g {\n' + '__start0 [label="", shape=none];\n' + '__start0 -> qX;\n' + 'q0 [label="q0"];\n' + 'q0 -> q0 [label="a"];\n' + '}\n' + ) + with self.assertRaises(AssertionError): + load_automaton_from_file(path, automaton_type='dfa') + + +class TestVisualizeAutomaton(unittest.TestCase): + def test_visualize_writes_file_in_background_thread(self): + dfa = get_Angluin_dfa() + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'viz' + visualize_automaton(dfa, path=str(path), file_type='dot') + deadline = time.time() + 5 + while time.time() < deadline and not path.with_suffix('.dot').exists(): + time.sleep(0.05) + self.assertTrue(path.with_suffix('.dot').exists()) + + def test_visualize_large_automaton_prints_warning(self): + # automaton_types with >= 25 states trigger an extra warning print; just make sure it does + # not raise and still produces the file. + from aalpy.utils import generate_random_dfa + import random + random.seed(42) + dfa = generate_random_dfa(num_states=26, alphabet=['a', 'b'], ensure_minimality=False) + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / 'viz_large' + visualize_automaton(dfa, path=str(path), file_type='dot') + deadline = time.time() + 5 + while time.time() < deadline and not path.with_suffix('.dot').exists(): + time.sleep(0.05) + self.assertTrue(path.with_suffix('.dot').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/test_file_operations.py b/tests/utils/test_file_operations.py new file mode 100644 index 00000000000..1e753de8077 --- /dev/null +++ b/tests/utils/test_file_operations.py @@ -0,0 +1,86 @@ +import tempfile +import unittest +from pathlib import Path + +from aalpy.utils import generate_random_markov_chain, load_automaton_from_file +from aalpy.utils.BenchmarkSULs import get_Angluin_dfa, get_benchmark_ONFSM, get_faulty_coffee_machine_SMM, \ + get_small_pomdp +from aalpy.utils.ModelChecking import bisimilar + +DOT_MODELS_DIR = Path(__file__).resolve().parent.parent.parent / 'DotModels' + + +def dump_and_load(model, automaton_type, directory): + path = directory / 'model' + model.save(str(path)) + return load_automaton_from_file(path.with_suffix('.dot'), automaton_type=automaton_type) + + +class TestFileHandler(unittest.TestCase): + + def test_saving_loading_roundtrip_preserves_alphabet_and_char_set(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = Path(tmp_dir) + type_model_pairs = [ + ("dfa", get_Angluin_dfa()), + ("mealy", load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Mealy.dot', automaton_type='mealy')), + ("moore", load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Moore.dot', automaton_type='moore')), + ("onfsm", get_benchmark_ONFSM()), + ("mdp", get_small_pomdp()), + ("mdp", load_automaton_from_file(DOT_MODELS_DIR / 'MDPs/first_grid.dot', automaton_type='mdp')), + ("smm", get_faulty_coffee_machine_SMM()), + ("mc", generate_random_markov_chain(num_states=10)), + ] + + for automaton_type, model in type_model_pairs: + loaded_model = dump_and_load(model, automaton_type, tmp_dir) + loaded_model_twice = dump_and_load(loaded_model, automaton_type, tmp_dir) + + if automaton_type != 'mc': + self.assertEqual(set(model.get_input_alphabet()), set(loaded_model.get_input_alphabet()), + msg=f'{automaton_type}: alphabet changed after one dump/load cycle') + self.assertEqual(set(model.get_input_alphabet()), set(loaded_model_twice.get_input_alphabet()), + msg=f'{automaton_type}: alphabet changed after two dump/load cycles') + else: + self.assertEqual(model.size, loaded_model.size) + self.assertEqual(model.size, loaded_model_twice.size) + + if automaton_type in {'dfa', 'moore', 'mealy'}: + self.assertEqual(model.compute_characterization_set(), + loaded_model_twice.compute_characterization_set(), + msg=f'{automaton_type}: characterization set changed after dump/load cycles') + + def test_dfa_dump_load_dump_load_is_bisimilar_to_original(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = Path(tmp_dir) + model = get_Angluin_dfa() + loaded_once = dump_and_load(model, 'dfa', tmp_dir) + loaded_twice = dump_and_load(loaded_once, 'dfa', tmp_dir) + + self.assertTrue(bisimilar(model, loaded_once)) + self.assertTrue(bisimilar(model, loaded_twice)) + self.assertTrue(bisimilar(loaded_once, loaded_twice)) + + def test_mealy_dump_load_dump_load_is_bisimilar_to_original(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = Path(tmp_dir) + model = load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Mealy.dot', automaton_type='mealy') + loaded_once = dump_and_load(model, 'mealy', tmp_dir) + loaded_twice = dump_and_load(loaded_once, 'mealy', tmp_dir) + + self.assertTrue(bisimilar(model, loaded_once)) + self.assertTrue(bisimilar(model, loaded_twice)) + + def test_moore_dump_load_dump_load_is_bisimilar_to_original(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = Path(tmp_dir) + model = load_automaton_from_file(DOT_MODELS_DIR / 'Angluin_Moore.dot', automaton_type='moore') + loaded_once = dump_and_load(model, 'moore', tmp_dir) + loaded_twice = dump_and_load(loaded_once, 'moore', tmp_dir) + + self.assertTrue(bisimilar(model, loaded_once)) + self.assertTrue(bisimilar(model, loaded_twice)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/test_helper_functions.py b/tests/utils/test_helper_functions.py new file mode 100644 index 00000000000..d34971c3571 --- /dev/null +++ b/tests/utils/test_helper_functions.py @@ -0,0 +1,353 @@ +import random +import unittest + +from aalpy.automata import (Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState, Mdp, MdpState, + MarkovChain, Vpa, VpaState, VpaAlphabet, VpaTransition) +from aalpy.utils.HelperFunctions import ( + extend_set, + all_prefixes, + all_suffixes, + random_string_generator, + is_suffix_of, + get_cex_prefixes, + make_input_complete, + convert_i_o_traces_for_RPNI, + is_balanced, + product_with_possible_empty_iterable, + dfa_from_moore, + mc_from_mdp, + mc_format_to_mdp, + generate_input_output_data_from_automata, + generate_input_output_data_from_vpa, +) + + +def parity_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +def incomplete_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0} + return Dfa(q0, [q0, q1]) + + +def incomplete_moore(): + q0 = MooreState('q0', output='x') + q1 = MooreState('q1', output='y') + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0} + return MooreMachine(q0, [q0, q1]) + + +def incomplete_mealy(): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'o1', 'b': 'o2'} + q1.transitions = {'a': q0} + q1.output_fun = {'a': 'o2'} + return MealyMachine(q0, [q0, q1]) + + +class TestExtendSet(unittest.TestCase): + def test_adds_only_new_elements(self): + base = [1, 2, 3] + added = extend_set(base, [2, 3, 4, 5]) + self.assertEqual(added, [4, 5]) + self.assertEqual(base, [1, 2, 3, 4, 5]) + + def test_no_new_elements(self): + base = [1, 2] + added = extend_set(base, [1, 2]) + self.assertEqual(added, []) + self.assertEqual(base, [1, 2]) + + +class TestAllPrefixesSuffixes(unittest.TestCase): + def test_all_prefixes(self): + self.assertEqual(all_prefixes(['a', 'b', 'c']), [('a',), ('a', 'b'), ('a', 'b', 'c')]) + + def test_all_prefixes_empty(self): + self.assertEqual(all_prefixes([]), []) + + def test_all_suffixes(self): + self.assertEqual(all_suffixes(['a', 'b', 'c']), [('c',), ('b', 'c'), ('a', 'b', 'c')]) + + def test_all_suffixes_empty(self): + self.assertEqual(all_suffixes([]), []) + + +class TestRandomStringGenerator(unittest.TestCase): + def test_default_length(self): + s = random_string_generator() + self.assertEqual(len(s), 10) + + def test_custom_length_and_chars(self): + s = random_string_generator(size=5, chars='x') + self.assertEqual(s, 'xxxxx') + + def test_zero_length(self): + self.assertEqual(random_string_generator(size=0), '') + + +class TestIsSuffixOf(unittest.TestCase): + def test_true_case(self): + self.assertTrue(is_suffix_of(('b', 'c'), ('a', 'b', 'c'))) + + def test_false_case(self): + self.assertFalse(is_suffix_of(('a', 'c'), ('a', 'b', 'c'))) + + def test_suffix_longer_than_trace(self): + self.assertFalse(is_suffix_of(('a', 'b', 'c'), ('b', 'c'))) + + def test_empty_suffix_always_matches(self): + # regression test: trace[-len(suffix):] with len(suffix) == 0 slices with `-0`, which Python + # treats the same as `0` (the whole trace) rather than an empty slice - so an empty suffix + # used to compare the entire trace against () instead of always matching. + self.assertTrue(is_suffix_of((), ('a', 'b'))) + + def test_empty_suffix_of_empty_trace(self): + self.assertTrue(is_suffix_of((), ())) + + +class TestGetCexPrefixes(unittest.TestCase): + def test_mdp_prefixes(self): + cex = ('i1', 'o1', 'i2', 'o2') + prefixes = get_cex_prefixes(cex, 'mdp') + self.assertEqual(prefixes, [('i1',), ('i1', 'o1', 'i2')]) + + def test_smm_prefixes(self): + cex = ('i1', 'o1', 'i2', 'o2') + prefixes = get_cex_prefixes(cex, 'smm') + self.assertEqual(prefixes, [(), ('i1', 'o1'), ('i1', 'o1', 'i2', 'o2')]) + + +class TestMakeInputComplete(unittest.TestCase): + def test_already_complete_dfa_returned_unchanged(self): + dfa = parity_dfa() + result = make_input_complete(dfa) + self.assertIs(result, dfa) + + def test_dfa_self_loop(self): + dfa = incomplete_dfa() + make_input_complete(dfa, missing_transition_go_to='self_loop') + for state in dfa.states: + self.assertEqual(set(state.transitions.keys()), {'a', 'b'}) + self.assertIs(dfa.states[1].transitions['b'], dfa.states[1]) + + def test_dfa_sink_state(self): + dfa = incomplete_dfa() + make_input_complete(dfa, missing_transition_go_to='sink_state') + sink_states = [s for s in dfa.states if s.state_id == 'sink'] + self.assertEqual(len(sink_states), 1) + sink = sink_states[0] + self.assertFalse(sink.is_accepting) + q1 = next(s for s in dfa.states if s.state_id == 'q1') + self.assertIs(q1.transitions['b'], sink) + + def test_moore_self_loop(self): + moore = incomplete_moore() + make_input_complete(moore, missing_transition_go_to='self_loop') + for state in moore.states: + self.assertEqual(set(state.transitions.keys()), {'a', 'b'}) + + def test_mealy_self_loop_epsilon_output(self): + mealy = incomplete_mealy() + make_input_complete(mealy, missing_transition_go_to='self_loop') + self.assertIs(mealy.states[1].transitions['b'], mealy.states[1]) + self.assertEqual(mealy.states[1].output_fun['b'], 'epsilon') + + def test_invalid_missing_transition_strategy_raises(self): + dfa = incomplete_dfa() + with self.assertRaises(AssertionError): + make_input_complete(dfa, missing_transition_go_to='not_a_strategy') + + +class TestConvertIOTracesForRPNI(unittest.TestCase): + def test_mealy_conversion(self): + sequences = [[(1, 'a'), (2, 'b'), (3, 'c')], [(6, 'e'), (4, 'e'), (3, 'c')]] + result = convert_i_o_traces_for_RPNI(sequences, automaton_type='mealy') + self.assertEqual(result, [ + ((1,), 'a'), ((1, 2), 'b'), ((1, 2, 3), 'c'), + ((6,), 'e'), ((6, 4), 'e'), ((6, 4, 3), 'c'), + ]) + + def test_dfa_conversion_includes_initial_output(self): + sequences = [[True, (1, False)]] + result = convert_i_o_traces_for_RPNI(sequences, automaton_type='dfa') + self.assertEqual(result, [((), True), ((1,), False)]) + + def test_invalid_automaton_type_raises(self): + with self.assertRaises(ValueError): + convert_i_o_traces_for_RPNI([[(1, 'a')]], automaton_type='bogus') + + def test_deduplicates_repeated_prefixes(self): + sequences = [[(1, 'a')], [(1, 'a')]] + result = convert_i_o_traces_for_RPNI(sequences, automaton_type='mealy') + self.assertEqual(result, [((1,), 'a')]) + + +class TestIsBalanced(unittest.TestCase): + def alphabet(self): + return VpaAlphabet(internal_alphabet=['i'], call_alphabet=['c'], return_alphabet=['r']) + + def test_balanced_sequence(self): + self.assertTrue(is_balanced(['c', 'i', 'r'], self.alphabet())) + + def test_empty_sequence_is_balanced(self): + self.assertTrue(is_balanced([], self.alphabet())) + + def test_unbalanced_more_returns_than_calls(self): + self.assertFalse(is_balanced(['r'], self.alphabet())) + + def test_unbalanced_unclosed_call(self): + self.assertFalse(is_balanced(['c', 'c', 'r'], self.alphabet())) + + +class TestProductWithPossibleEmptyIterable(unittest.TestCase): + def test_all_nonempty_behaves_like_normal_product(self): + result = list(product_with_possible_empty_iterable([1, 2], ['a'])) + self.assertEqual(result, [(1, 'a'), (2, 'a')]) + + def test_one_empty_iterable_ignored(self): + result = list(product_with_possible_empty_iterable([1, 2], [])) + self.assertEqual(result, [(1,), (2,)]) + + def test_all_empty_iterables_gives_empty_tuple(self): + result = list(product_with_possible_empty_iterable([], [])) + self.assertEqual(result, [()]) + + +class TestDfaFromMoore(unittest.TestCase): + def test_boolean_output_moore_converts(self): + q0 = MooreState('q0', output=True) + q1 = MooreState('q1', output=False) + q0.transitions = {'a': q1} + q1.transitions = {'a': q0} + moore = MooreMachine(q0, [q0, q1]) + + dfa = dfa_from_moore(moore) + self.assertIsInstance(dfa, Dfa) + self.assertTrue(dfa.initial_state.is_accepting) + self.assertFalse(dfa.initial_state.transitions['a'].is_accepting) + + def test_none_output_treated_as_non_accepting(self): + q0 = MooreState('q0', output=None) + q0.transitions = {'a': q0} + moore = MooreMachine(q0, [q0]) + dfa = dfa_from_moore(moore) + self.assertFalse(dfa.initial_state.is_accepting) + + def test_non_boolean_output_raises(self): + q0 = MooreState('q0', output='not_boolean') + q0.transitions = {'a': q0} + moore = MooreMachine(q0, [q0]) + with self.assertRaises(ValueError): + dfa_from_moore(moore) + + +class TestMcFromMdp(unittest.TestCase): + def make_mdp(self): + s0 = MdpState('s0', output='o0') + s1 = MdpState('s1', output='o1') + s0.transitions['i'].append((s1, 1.0)) + s1.transitions['i'].append((s0, 1.0)) + return Mdp(s0, [s0, s1]) + + def test_single_input_conversion(self): + mdp = self.make_mdp() + mc = mc_from_mdp(mdp) + self.assertIsInstance(mc, MarkovChain) + self.assertEqual(mc.initial_state.state_id, 's0') + self.assertEqual(mc.initial_state.transitions, [(mc.states[1] if mc.states[1].state_id == 's1' else mc.states[0], 1.0)]) + + def test_explicit_input_symbol(self): + mdp = self.make_mdp() + mc = mc_from_mdp(mdp, input_symbol='i') + self.assertIsInstance(mc, MarkovChain) + + def test_multiple_inputs_without_symbol_raises(self): + s0 = MdpState('s0', output='o0') + s1 = MdpState('s1', output='o1') + s0.transitions['i1'].append((s1, 1.0)) + s0.transitions['i2'].append((s1, 1.0)) + s1.transitions['i1'].append((s0, 1.0)) + s1.transitions['i2'].append((s0, 1.0)) + mdp = Mdp(s0, [s0, s1]) + with self.assertRaises(ValueError): + mc_from_mdp(mdp) + + +class TestMcFormatToMdp(unittest.TestCase): + def test_wraps_non_initial_elements_with_input_label(self): + data = [['out0', 'a', 'b']] + result = mc_format_to_mdp(data) + self.assertEqual(result, [['out0', ('Input', 'a'), ('Input', 'b')]]) + + def test_empty_data(self): + self.assertEqual(mc_format_to_mdp([]), []) + + +class TestGenerateInputOutputDataFromAutomata(unittest.TestCase): + def test_io_traces_format(self): + random.seed(1) + dfa = parity_dfa() + data = generate_input_output_data_from_automata(dfa, num_sequences=5, min_seq_len=1, max_seq_len=3) + self.assertEqual(len(data), 5) + for trace in data: + for i, o in trace: + self.assertIn(i, {'a', 'b'}) + self.assertIn(o, {True, False}) + + def test_labeled_sequences_format(self): + random.seed(2) + dfa = parity_dfa() + data = generate_input_output_data_from_automata(dfa, num_sequences=5, min_seq_len=1, max_seq_len=3, + sequance_type='labeled_sequences') + self.assertEqual(len(data), 5) + for seq, label in data: + self.assertIsInstance(seq, list) + self.assertIn(label, {True, False}) + + def test_invalid_sequence_type_raises(self): + dfa = parity_dfa() + with self.assertRaises(AssertionError): + generate_input_output_data_from_automata(dfa, num_sequences=1, sequance_type='bogus') + + +class TestGenerateInputOutputDataFromVpa(unittest.TestCase): + def make_simple_vpa(self): + q0 = VpaState('q0', is_accepting=True) + q1 = VpaState('q1', is_accepting=False) + q0.transitions['i'].append(VpaTransition(q0, q0, 'i', None, None)) + q0.transitions['c'].append(VpaTransition(q0, q1, 'c', 'push', 'c')) + q1.transitions['r'].append(VpaTransition(q1, q0, 'r', 'pop', 'c')) + return Vpa(q0, [q0, q1]) + + def test_generates_at_least_requested_number_of_sequences(self): + # the generation loop only re-checks its stopping condition after a full inner sequence of + # length max_seq_len is generated, so it can slightly overshoot num_sequences. + random.seed(3) + vpa = self.make_simple_vpa() + data = generate_input_output_data_from_vpa(vpa, num_sequences=10, max_seq_len=4) + self.assertGreaterEqual(len(data), 10) + self.assertLessEqual(len(data), 10 + 4) + for seq, output in data: + self.assertIsInstance(seq, tuple) + + def test_respects_max_attempts(self): + random.seed(4) + vpa = self.make_simple_vpa() + data = generate_input_output_data_from_vpa(vpa, num_sequences=1000, max_seq_len=2, max_attempts=5) + self.assertLessEqual(len(data), 1000) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/test_model_checking.py b/tests/utils/test_model_checking.py new file mode 100644 index 00000000000..7f68f8793d5 --- /dev/null +++ b/tests/utils/test_model_checking.py @@ -0,0 +1,160 @@ +import unittest + +from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState, Onfsm, OnfsmState +from aalpy.utils.BenchmarkSULs import get_Angluin_dfa +from aalpy.utils.ModelChecking import bisimilar, compare_automata + + +def parity_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +def non_bisimilar_dfa(): + # accepts everything (always accepting), differs from parity_dfa on 'a' + q0 = DfaState('q0', is_accepting=True) + q0.transitions = {'a': q0, 'b': q0} + return Dfa(q0, [q0]) + + +def renamed_parity_dfa(): + r0 = DfaState('r0', is_accepting=True) + r1 = DfaState('r1', is_accepting=False) + r0.transitions = {'a': r1, 'b': r0} + r1.transitions = {'a': r0, 'b': r1} + return Dfa(r0, [r0, r1]) + + +def parity_moore(): + q0 = MooreState('q0', output=True) + q1 = MooreState('q1', output=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return MooreMachine(q0, [q0, q1]) + + +def parity_mealy(): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'x', 'b': 'y'} + q1.transitions = {'a': q0, 'b': q1} + q1.output_fun = {'a': 'y', 'b': 'x'} + return MealyMachine(q0, [q0, q1]) + + +class TestBisimilar(unittest.TestCase): + def test_identical_automaton_is_bisimilar_to_itself_copy(self): + dfa = parity_dfa() + self.assertTrue(bisimilar(dfa, dfa)) + + def test_isomorphic_automata_with_different_state_ids_are_bisimilar(self): + dfa1 = parity_dfa() + dfa2 = renamed_parity_dfa() + self.assertTrue(bisimilar(dfa1, dfa2)) + + def test_non_bisimilar_dfa_returns_false(self): + dfa1 = parity_dfa() + dfa2 = non_bisimilar_dfa() + self.assertFalse(bisimilar(dfa1, dfa2)) + + def test_non_bisimilar_dfa_counterexample(self): + dfa1 = parity_dfa() + dfa2 = non_bisimilar_dfa() + cex = bisimilar(dfa1, dfa2, return_cex=True) + self.assertIsNotNone(cex) + dfa1.reset_to_initial() + dfa2.reset_to_initial() + out1 = [dfa1.step(i) for i in cex] + out2 = [dfa2.step(i) for i in cex] + self.assertNotEqual(out1[-1], out2[-1]) + + def test_bisimilar_automata_return_cex_gives_none(self): + dfa1 = parity_dfa() + dfa2 = renamed_parity_dfa() + self.assertIsNone(bisimilar(dfa1, dfa2, return_cex=True)) + + def test_moore_bisimilarity(self): + moore1 = parity_moore() + moore2 = parity_moore() + self.assertTrue(bisimilar(moore1, moore2)) + + def test_mealy_bisimilarity(self): + mealy1 = parity_mealy() + mealy2 = parity_mealy() + self.assertTrue(bisimilar(mealy1, mealy2)) + + def test_mealy_output_mismatch_detected(self): + mealy1 = parity_mealy() + mealy2 = parity_mealy() + mealy2.initial_state.output_fun['a'] = 'different' + self.assertFalse(bisimilar(mealy1, mealy2)) + + def test_different_automaton_types_raises(self): + dfa = parity_dfa() + moore = parity_moore() + with self.assertRaises(ValueError): + bisimilar(dfa, moore) + + def test_unsupported_automaton_type_raises(self): + onfsm = Onfsm(OnfsmState('q0'), [OnfsmState('q0')]) + with self.assertRaises(NotImplementedError): + bisimilar(onfsm, onfsm) + + def test_different_enabled_inputs_not_bisimilar(self): + q0 = DfaState('q0', is_accepting=True) + q0.transitions = {'a': q0} + dfa1 = Dfa(q0, [q0]) + + r0 = DfaState('r0', is_accepting=True) + r0.transitions = {'a': r0, 'b': r0} + dfa2 = Dfa(r0, [r0]) + + self.assertFalse(bisimilar(dfa1, dfa2)) + + +class TestCompareAutomata(unittest.TestCase): + def test_identical_automata_no_counterexamples(self): + dfa1 = get_Angluin_dfa() + dfa2 = get_Angluin_dfa() + cexs = compare_automata(dfa1, dfa2) + self.assertEqual(cexs, []) + + def test_same_object_no_counterexamples(self): + dfa = get_Angluin_dfa() + cexs = compare_automata(dfa, dfa) + self.assertEqual(cexs, []) + + def test_different_alphabets_raises(self): + dfa1 = get_Angluin_dfa() + q0 = DfaState('q0', is_accepting=True) + q0.transitions = {'x': q0, 'y': q0} + dfa2 = Dfa(q0, [q0]) + with self.assertRaises(AssertionError): + compare_automata(dfa1, dfa2) + + def test_finds_counterexample_for_differing_automata(self): + dfa1 = parity_dfa() + dfa2 = non_bisimilar_dfa() + cexs = compare_automata(dfa1, dfa2, num_cex=5) + self.assertGreater(len(cexs), 0) + for cex in cexs: + dfa1.reset_to_initial() + dfa2.reset_to_initial() + out1 = [dfa1.step(i) for i in cex][-1] + out2 = [dfa2.step(i) for i in cex][-1] + self.assertNotEqual(out1, out2) + + def test_counterexamples_sorted_by_length(self): + dfa1 = parity_dfa() + dfa2 = non_bisimilar_dfa() + cexs = compare_automata(dfa1, dfa2, num_cex=8) + lengths = [len(c) for c in cexs] + self.assertEqual(lengths, sorted(lengths)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/test_sampling.py b/tests/utils/test_sampling.py new file mode 100644 index 00000000000..5659eed3953 --- /dev/null +++ b/tests/utils/test_sampling.py @@ -0,0 +1,157 @@ +import random +import unittest + +from aalpy.automata import Dfa, DfaState, MealyMachine, MealyState +from aalpy.utils.Sampling import ( + get_io_traces, + get_labeled_sequences, + get_data_from_input_sequence, + sample_with_length_limits, + sample_with_term_prob, + get_complete_sample, +) + + +def parity_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + dfa = Dfa(q0, [q0, q1]) + dfa.compute_prefixes() + return dfa + + +def parity_mealy(): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'x', 'b': 'y'} + q1.transitions = {'a': q0, 'b': q1} + q1.output_fun = {'a': 'y', 'b': 'x'} + mealy = MealyMachine(q0, [q0, q1]) + mealy.compute_prefixes() + return mealy + + +class TestGetIoTraces(unittest.TestCase): + def test_dfa_traces_prefixed_with_initial_output(self): + dfa = parity_dfa() + traces = get_io_traces(dfa, [['a', 'a'], ['b']]) + self.assertEqual(traces[0][0], True) + self.assertEqual(traces[0][1:], [('a', False), ('a', True)]) + self.assertEqual(traces[1], [True, ('b', True)]) + + def test_mealy_traces_not_prefixed(self): + mealy = parity_mealy() + traces = get_io_traces(mealy, [['a', 'b']]) + self.assertEqual(traces[0], [('a', 'x'), ('b', 'x')]) + + def test_empty_input_trace(self): + # regression test: Dfa.execute_sequence (like MooreMachine's) returns a bare output value + # rather than a list for an empty sequence, which used to make zip(input_trace, output_trace) + # crash with "'bool' object is not iterable". + dfa = parity_dfa() + traces = get_io_traces(dfa, [[]]) + self.assertEqual(traces[0], [True]) + + +class TestGetLabeledSequences(unittest.TestCase): + def test_dfa_labels(self): + dfa = parity_dfa() + data = get_labeled_sequences(dfa, [['a'], ['a', 'a']]) + self.assertEqual(data, [(['a'], False), (['a', 'a'], True)]) + + def test_dfa_empty_sequence_returns_initial_output(self): + dfa = parity_dfa() + data = get_labeled_sequences(dfa, [[]]) + self.assertEqual(data, [([], True)]) + + def test_mealy_empty_sequence_raises(self): + mealy = parity_mealy() + with self.assertRaises(ValueError): + get_labeled_sequences(mealy, [[]]) + + +class TestGetDataFromInputSequence(unittest.TestCase): + def test_io_sequences_format(self): + dfa = parity_dfa() + data = get_data_from_input_sequence(dfa, [['a']], data_format='io_sequences') + self.assertEqual(data, get_io_traces(dfa, [['a']])) + + def test_labeled_sequences_format(self): + dfa = parity_dfa() + data = get_data_from_input_sequence(dfa, [['a']], data_format='labeled_sequences') + self.assertEqual(data, get_labeled_sequences(dfa, [['a']])) + + def test_invalid_format_raises(self): + dfa = parity_dfa() + with self.assertRaises(ValueError): + get_data_from_input_sequence(dfa, [['a']], data_format='bogus') + + +class TestSampleWithLengthLimits(unittest.TestCase): + def test_alphabet_argument(self): + random.seed(1) + samples = sample_with_length_limits(['a', 'b'], nr_samples=10, min_len=2, max_len=5) + self.assertEqual(len(samples), 10) + for s in samples: + self.assertTrue(2 <= len(s) <= 5) + self.assertTrue(all(x in {'a', 'b'} for x in s)) + + def test_automaton_argument_uses_input_alphabet(self): + random.seed(2) + dfa = parity_dfa() + samples = sample_with_length_limits(dfa, nr_samples=5, min_len=1, max_len=3) + for s in samples: + self.assertTrue(all(x in {'a', 'b'} for x in s)) + + def test_include_outputs_requires_automaton(self): + with self.assertRaises(ValueError): + sample_with_length_limits(['a', 'b'], nr_samples=1, min_len=1, max_len=1, include_outputs=True) + + def test_include_outputs_with_automaton(self): + random.seed(3) + dfa = parity_dfa() + samples = sample_with_length_limits(dfa, nr_samples=3, min_len=1, max_len=2, include_outputs=True) + for trace in samples: + self.assertEqual(trace[0], True) + for i, o in trace[1:]: + self.assertIn(i, {'a', 'b'}) + self.assertIn(o, {True, False}) + + +class TestSampleWithTermProb(unittest.TestCase): + def test_generates_requested_number_of_samples(self): + random.seed(4) + samples = sample_with_term_prob(['a', 'b'], nr_samples=8, term_prob=0.5) + self.assertEqual(len(samples), 8) + for s in samples: + self.assertTrue(all(x in {'a', 'b'} for x in s)) + + def test_term_prob_one_gives_empty_sequences(self): + random.seed(5) + samples = sample_with_term_prob(['a', 'b'], nr_samples=5, term_prob=1.0) + for s in samples: + self.assertEqual(s, []) + + +class TestGetCompleteSample(unittest.TestCase): + def test_complete_sample_covers_alphabet_states_and_char_set(self): + dfa = parity_dfa() + sample = get_complete_sample(dfa) + self.assertTrue(len(sample) > 0) + char_set = dfa.compute_characterization_set() + for seq in sample: + self.assertIsInstance(seq, tuple) + self.assertEqual(len(sample), len(dfa.states) * len(char_set) * (len(dfa.get_input_alphabet()) + 1)) + + def test_accepts_automaton_arg_via_decorator(self): + random.seed(6) + dfa = parity_dfa() + sample = get_complete_sample(dfa) + self.assertTrue(all(isinstance(s, tuple) for s in sample)) + + +if __name__ == '__main__': + unittest.main() From 9c56f7fb201cd88c3b7f17676fc01d81dab443f7 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:17:04 +0200 Subject: [PATCH 15/25] Add tests for learning_algs package --- .../adaptive/test_adaptive_lsharp.py | 134 ++++++++ .../test_adaptive_observation_tree.py | 232 +++++++++++++ .../adaptive/test_state_matching.py | 157 +++++++++ tests/learning_algs/deterministic/test_ads.py | 141 ++++++++ .../deterministic/test_apartness.py | 156 +++++++++ .../deterministic/test_classification_tree.py | 169 ++++++++++ .../test_counter_example_processing.py | 133 ++++++++ .../deterministic/test_learning_algorithms.py | 143 ++++++++ .../deterministic/test_observation_table.py | 315 ++++++++++++++++++ .../deterministic/test_observation_tree.py | 172 ++++++++++ .../test_random_learning_runs.py | 59 ++++ .../test_random_learning_runs_exhaustive.py | 76 +++++ .../deterministic_passive/test_active_rpni.py | 113 +++++++ .../test_classic_rpni.py | 85 +++++ .../deterministic_passive/test_gsm_rpni.py | 116 +++++++ .../deterministic_passive/test_papni.py | 75 +++++ .../test_rpni_helper_functions.py | 198 +++++++++++ .../test_run_rpni_integration.py | 105 ++++++ .../test_generalized_state_merging.py | 192 +++++++++++ .../general_passive/test_gsm_algorithms.py | 196 +++++++++++ .../general_passive/test_gsm_node.py | 294 ++++++++++++++++ .../general_passive/test_instrumentation.py | 79 +++++ .../test_score_functions_gsm.py | 260 +++++++++++++++ .../test_abstracted_onfsm_lstar.py | 67 ++++ ...test_abstracted_onfsm_observation_table.py | 149 +++++++++ .../test_non_deterministic_sul_wrapper.py | 121 +++++++ .../non_deterministic/test_onfsm_lstar.py | 64 ++++ .../test_onfsm_lstar_exhaustive.py | 37 ++ .../test_onfsm_observation_table.py | 174 ++++++++++ .../non_deterministic/test_trace_tree.py | 205 ++++++++++++ tests/learning_algs/resetless/test_hW.py | 146 ++++++++ .../resetless/test_hW_exhaustive.py | 160 +++++++++ .../resetless/test_hw_datastructures.py | 130 ++++++++ .../resetless/test_resetless_oracles.py | 279 ++++++++++++++++ .../stochastic/test_difference_checker.py | 140 ++++++++ .../test_sampling_based_observation_table.py | 233 +++++++++++++ .../test_stochastic_cex_processing.py | 93 ++++++ .../test_stochastic_lstar_integration.py | 104 ++++++ ...stochastic_lstar_integration_exhaustive.py | 59 ++++ .../stochastic/test_stochastic_teacher.py | 181 ++++++++++ .../test_alergia_integration.py | 206 ++++++++++++ .../test_compatibility_checker.py | 93 ++++++ .../stochastic_passive/test_fpta.py | 125 +++++++ 43 files changed, 6366 insertions(+) create mode 100644 tests/learning_algs/adaptive/test_adaptive_lsharp.py create mode 100644 tests/learning_algs/adaptive/test_adaptive_observation_tree.py create mode 100644 tests/learning_algs/adaptive/test_state_matching.py create mode 100644 tests/learning_algs/deterministic/test_ads.py create mode 100644 tests/learning_algs/deterministic/test_apartness.py create mode 100644 tests/learning_algs/deterministic/test_classification_tree.py create mode 100644 tests/learning_algs/deterministic/test_counter_example_processing.py create mode 100644 tests/learning_algs/deterministic/test_learning_algorithms.py create mode 100644 tests/learning_algs/deterministic/test_observation_table.py create mode 100644 tests/learning_algs/deterministic/test_observation_tree.py create mode 100644 tests/learning_algs/deterministic/test_random_learning_runs.py create mode 100644 tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py create mode 100644 tests/learning_algs/deterministic_passive/test_active_rpni.py create mode 100644 tests/learning_algs/deterministic_passive/test_classic_rpni.py create mode 100644 tests/learning_algs/deterministic_passive/test_gsm_rpni.py create mode 100644 tests/learning_algs/deterministic_passive/test_papni.py create mode 100644 tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py create mode 100644 tests/learning_algs/deterministic_passive/test_run_rpni_integration.py create mode 100644 tests/learning_algs/general_passive/test_generalized_state_merging.py create mode 100644 tests/learning_algs/general_passive/test_gsm_algorithms.py create mode 100644 tests/learning_algs/general_passive/test_gsm_node.py create mode 100644 tests/learning_algs/general_passive/test_instrumentation.py create mode 100644 tests/learning_algs/general_passive/test_score_functions_gsm.py create mode 100644 tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py create mode 100644 tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py create mode 100644 tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py create mode 100644 tests/learning_algs/non_deterministic/test_onfsm_lstar.py create mode 100644 tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py create mode 100644 tests/learning_algs/non_deterministic/test_onfsm_observation_table.py create mode 100644 tests/learning_algs/non_deterministic/test_trace_tree.py create mode 100644 tests/learning_algs/resetless/test_hW.py create mode 100644 tests/learning_algs/resetless/test_hW_exhaustive.py create mode 100644 tests/learning_algs/resetless/test_hw_datastructures.py create mode 100644 tests/learning_algs/resetless/test_resetless_oracles.py create mode 100644 tests/learning_algs/stochastic/test_difference_checker.py create mode 100644 tests/learning_algs/stochastic/test_sampling_based_observation_table.py create mode 100644 tests/learning_algs/stochastic/test_stochastic_cex_processing.py create mode 100644 tests/learning_algs/stochastic/test_stochastic_lstar_integration.py create mode 100644 tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py create mode 100644 tests/learning_algs/stochastic/test_stochastic_teacher.py create mode 100644 tests/learning_algs/stochastic_passive/test_alergia_integration.py create mode 100644 tests/learning_algs/stochastic_passive/test_compatibility_checker.py create mode 100644 tests/learning_algs/stochastic_passive/test_fpta.py diff --git a/tests/learning_algs/adaptive/test_adaptive_lsharp.py b/tests/learning_algs/adaptive/test_adaptive_lsharp.py new file mode 100644 index 00000000000..29a8543f299 --- /dev/null +++ b/tests/learning_algs/adaptive/test_adaptive_lsharp.py @@ -0,0 +1,134 @@ +import random +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import MealyMachine, MealyState +from aalpy.learning_algs import run_adaptive_Lsharp +from aalpy.oracles import WpMethodEqOracle +from aalpy.utils import generate_random_deterministic_automata +from aalpy.utils.ModelChecking import bisimilar + + +def two_state_mealy(): + """ + 2-state Mealy machine over {a, b}. + s0 --a/x--> s1 s0 --b/y--> s0 + s1 --a/y--> s0 s1 --b/x--> s1 + """ + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'a': s1, 'b': s0} + s0.output_fun = {'a': 'x', 'b': 'y'} + s1.transitions = {'a': s0, 'b': s1} + s1.output_fun = {'a': 'y', 'b': 'x'} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm + + +def three_state_mealy_variant(): + """ + 3-state variant of two_state_mealy: s1's 'b' output changed and a new state s2 introduced, + simulating a slightly changed system that reuses most of the original behaviour. + s0 --a/x--> s1 s0 --b/y--> s0 + s1 --a/y--> s2 s1 --b/z--> s1 + s2 --a/y--> s0 s2 --b/x--> s2 + """ + s0 = MealyState('s0') + s1 = MealyState('s1') + s2 = MealyState('s2') + s0.transitions = {'a': s1, 'b': s0} + s0.output_fun = {'a': 'x', 'b': 'y'} + s1.transitions = {'a': s2, 'b': s1} + s1.output_fun = {'a': 'y', 'b': 'z'} + s2.transitions = {'a': s0, 'b': s2} + s2.output_fun = {'a': 'y', 'b': 'x'} + mm = MealyMachine(s0, [s0, s1, s2]) + mm.compute_prefixes() + return mm + + +def learn_with_adaptive(target, references, state_matching='Approximate', rebuilding=True): + alphabet = target.get_input_alphabet() + sul = AutomatonSUL(target) + eq_oracle = WpMethodEqOracle(alphabet, sul, max_number_of_states=len(target.states) + 1) + return run_adaptive_Lsharp(alphabet, sul, references, eq_oracle, automaton_type='mealy', + extension_rule=None, separation_rule='SepSeq', + rebuilding=rebuilding, state_matching=state_matching, + print_level=0, return_data=True) + + +class TestColdStartFallback(unittest.TestCase): + def test_empty_references_falls_back_to_plain_lsharp(self): + target = two_state_mealy() + learned, info = learn_with_adaptive(target, [], state_matching=None, rebuilding=True) + + self.assertTrue(bisimilar(learned, target)) + + +class TestLearnsWithPerfectReference(unittest.TestCase): + def test_learns_minimal_bisimilar_model_with_self_as_reference(self): + for matching in (None, 'Total', 'Approximate'): + with self.subTest(matching=matching): + target = two_state_mealy() + reference = two_state_mealy() + learned, info = learn_with_adaptive(target, [reference], state_matching=matching) + + self.assertTrue(bisimilar(learned, target)) + self.assertEqual(learned.size, len(target.states)) + + def test_random_automata_learned_correctly_with_perfect_reference(self): + random.seed(42) + for i in range(3): + num_states = random.randint(3, 6) + target = generate_random_deterministic_automata( + 'mealy', num_states=num_states, input_alphabet_size=3, output_alphabet_size=3) + reference = target.copy() + + learned, info = learn_with_adaptive(target, [reference], state_matching='Approximate') + self.assertTrue(bisimilar(learned, target), f'run {i} with {num_states} states failed') + + +class TestAdaptiveReuseAcrossRelatedSystems(unittest.TestCase): + def test_reference_from_one_system_helps_learn_a_related_system(self): + # A represents a previously learned/known model; B is a related system whose behaviour has + # diverged (state added, an output changed). Adaptive learning should still converge correctly. + model_a = two_state_mealy() + model_b = three_state_mealy_variant() + + learned_b, info = learn_with_adaptive(model_b, [model_a], state_matching='Approximate') + + self.assertTrue(bisimilar(learned_b, model_b)) + self.assertFalse(bisimilar(learned_b, model_a)) + + def test_reused_reference_reduces_or_matches_plain_learning_effort(self): + model_a = two_state_mealy() + model_b = three_state_mealy_variant() + + _, info_with_reference = learn_with_adaptive(model_b, [model_a], state_matching='Approximate') + _, info_without_reference = learn_with_adaptive(model_b, [], state_matching=None, rebuilding=True) + + self.assertLessEqual(info_with_reference['queries_learning'], + info_without_reference['queries_learning'] + 5) + + def test_random_pairs_of_related_automata(self): + random.seed(7) + for i in range(3): + num_states = random.randint(3, 5) + base = generate_random_deterministic_automata( + 'mealy', num_states=num_states, input_alphabet_size=3, output_alphabet_size=3) + + # simulate a slightly changed system: mutate a single transition's output + mutated = base.copy() + mutated_state = mutated.states[0] + an_input = mutated.get_input_alphabet()[0] + current_output = mutated_state.output_fun[an_input] + other_outputs = [o for o in {'o1', 'o2', 'o3'} if o != current_output] + mutated_state.output_fun[an_input] = other_outputs[0] + + learned, info = learn_with_adaptive(mutated, [base], state_matching='Approximate') + self.assertTrue(bisimilar(learned, mutated), f'run {i} with {num_states} states failed') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/adaptive/test_adaptive_observation_tree.py b/tests/learning_algs/adaptive/test_adaptive_observation_tree.py new file mode 100644 index 00000000000..436986cffa7 --- /dev/null +++ b/tests/learning_algs/adaptive/test_adaptive_observation_tree.py @@ -0,0 +1,232 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import MealyMachine, MealyState +from aalpy.learning_algs.adaptive.AdaptiveObservationTree import AdaptiveObservationTree + + +def two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x'): + """ + 2-state Mealy machine over {a, b}. + s0 --a/out_a1--> s1 s0 --b/out_b1--> s0 + s1 --a/out_a2--> s0 s1 --b/out_b2--> s1 + """ + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'a': s1, 'b': s0} + s0.output_fun = {'a': out_a1, 'b': out_b1} + s1.transitions = {'a': s0, 'b': s1} + s1.output_fun = {'a': out_a2, 'b': out_b2} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm + + +def three_state_mealy_extra_letter(): + """ + 3-state Mealy machine over {a, b, c}, used as a reference with only partial input overlap tests. + s0 --a/x--> s1 s0 --b/y--> s0 s0 --c/z--> s2 + s1 --a/y--> s0 s1 --b/x--> s1 s1 --c/z--> s2 + s2 --a/z--> s2 s2 --b/z--> s2 s2 --c/z--> s2 + """ + s0 = MealyState('s0') + s1 = MealyState('s1') + s2 = MealyState('s2') + s0.transitions = {'a': s1, 'b': s0, 'c': s2} + s0.output_fun = {'a': 'x', 'b': 'y', 'c': 'z'} + s1.transitions = {'a': s0, 'b': s1, 'c': s2} + s1.output_fun = {'a': 'y', 'b': 'x', 'c': 'z'} + s2.transitions = {'a': s2, 'b': s2, 'c': s2} + s2.output_fun = {'a': 'z', 'b': 'z', 'c': 'z'} + mm = MealyMachine(s0, [s0, s1, s2]) + mm.compute_prefixes() + return mm + + +def make_tree(alphabet, sul_automaton, references, rebuilding=True, state_matching='Approximate'): + sul = AutomatonSUL(sul_automaton) + return AdaptiveObservationTree(alphabet, sul, references, 'mealy', None, 'SepSeq', + rebuilding=rebuilding, state_matching=state_matching) + + +class TestNoReferences(unittest.TestCase): + def test_empty_references_disables_state_matching(self): + target = two_state_mealy() + tree = make_tree(['a', 'b'], target, [], rebuilding=True, state_matching='Approximate') + self.assertIsNone(tree.state_matching) + self.assertEqual(tree.rebuild_states, 0) + self.assertEqual(tree.matching_states, 0) + self.assertEqual(tree.basis, [tree.root]) + + def test_reference_with_no_overlapping_inputs_is_dropped(self): + target = two_state_mealy() + reference = two_state_mealy() + # reference only defined over 'c', no overlap with tree alphabet {'a', 'b'} + s0 = MealyState('r0') + s0.transitions = {'c': s0} + s0.output_fun = {'c': 'z'} + reference_no_overlap = MealyMachine(s0, [s0]) + reference_no_overlap.compute_prefixes() + + tree = make_tree(['a', 'b'], target, [reference_no_overlap], rebuilding=False, state_matching=None) + self.assertIsNone(tree.state_matching) + self.assertEqual(tree.references, []) + + +class TestCombinedModel(unittest.TestCase): + def test_combined_model_contains_states_of_all_references(self): + target = two_state_mealy() + ref1 = two_state_mealy() + ref2 = two_state_mealy() + tree = make_tree(['a', 'b'], target, [ref1, ref2], rebuilding=False, state_matching=None) + + self.assertEqual(len(tree.combined_model.states), 4) + state_ids = {s.state_id for s in tree.combined_model.states} + self.assertEqual(state_ids, {'s(0,0)', 's(0,1)', 's(1,0)', 's(1,1)'}) + + def test_combined_model_preserves_output_function(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None) + + combined_initial = tree.combined_model.states[0] + self.assertEqual(combined_initial.output_fun['a'], 'x') + self.assertEqual(combined_initial.output_fun['b'], 'y') + + def test_combined_model_restricted_to_shared_alphabet(self): + target = two_state_mealy() + reference = three_state_mealy_extra_letter() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None) + + for state in tree.combined_model.states: + self.assertEqual(set(state.transitions.keys()), {'a', 'b'}) + + def test_prefix_map_contains_shortest_access_sequences(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None) + + prefixes = tree.prefixes_map[0] + self.assertIn((), prefixes) + self.assertEqual(len(prefixes), 2) + + def test_characterization_map_has_entry_per_state(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None) + + self.assertEqual(len(tree.characterization_map), len(tree.combined_model.states)) + for identifiers in tree.characterization_map.values(): + self.assertTrue(len(identifiers) >= 1) + + def test_all_references_dropped_yields_no_combined_model(self): + target = two_state_mealy() + s0 = MealyState('r0') + s0.transitions = {'c': s0} + s0.output_fun = {'c': 'z'} + reference_no_overlap = MealyMachine(s0, [s0]) + reference_no_overlap.compute_prefixes() + + sul = AutomatonSUL(target) + tree = AdaptiveObservationTree(['a', 'b'], sul, [reference_no_overlap], 'mealy', None, 'SepSeq', + rebuilding=False, state_matching='Approximate') + self.assertIsNone(tree.combined_model) + self.assertIsNone(tree.state_matching) + + +class TestFindDistinguishingSeqPartial(unittest.TestCase): + def test_finds_witness_between_distinct_states(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None) + + combined = tree.combined_model + s0, s1 = combined.states[0], combined.states[1] + witness = tree.find_distinguishing_seq_partial(combined, s0, s1, ['a', 'b']) + self.assertIsNotNone(witness) + + out_from_s0 = combined.execute_sequence(s0, witness) + out_from_s1 = combined.execute_sequence(s1, witness) + self.assertNotEqual(out_from_s0, out_from_s1) + + def test_no_witness_for_identical_states(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None) + + combined = tree.combined_model + s0 = combined.states[0] + witness = tree.find_distinguishing_seq_partial(combined, s0, s0, ['a', 'b']) + self.assertIsNone(witness) + + +class TestRebuildObsTree(unittest.TestCase): + def test_perfect_reference_match_rebuilds_states(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=True, state_matching=None) + + self.assertGreaterEqual(tree.rebuild_states, 1) + self.assertGreater(len(tree.basis), 1) + + def test_no_rebuilding_keeps_root_only_basis(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching=None) + + self.assertEqual(tree.rebuild_states, 0) + self.assertEqual(tree.basis, [tree.root]) + + def test_unrelated_reference_does_not_force_bogus_rebuild(self): + target = two_state_mealy(out_a1='p', out_b1='q', out_a2='q', out_b2='p') + reference = two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x') + tree = make_tree(['a', 'b'], target, [reference], rebuilding=True, state_matching=None) + + # basis states found via rebuilding must still be pairwise apart in the real SUL + for i, s1 in enumerate(tree.basis): + for s2 in tree.basis[i + 1:]: + from aalpy.learning_algs.deterministic.Apartness import Apartness + self.assertTrue(Apartness.states_are_apart(s1, s2, tree)) + + +class TestInsertObservationAndMatching(unittest.TestCase): + def test_insert_observation_without_matching_extends_tree(self): + target = two_state_mealy() + tree = make_tree(['a', 'b'], target, [], rebuilding=False, state_matching=None) + tree.insert_observation(['a', 'b'], ['x', 'x']) + self.assertEqual(tree.get_observation(['a', 'b']), ['x', 'x']) + + def test_insert_observation_mismatched_lengths_raises(self): + target = two_state_mealy() + tree = make_tree(['a', 'b'], target, [], rebuilding=False, state_matching=None) + with self.assertRaises(ValueError): + tree.insert_observation(['a', 'b'], ['x']) + + def test_insert_observation_with_matching_updates_best_match(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching='Approximate') + + self.assertIn(tree.root, tree.state_matcher.matchings) + self.assertNotIn(tree.root, tree.state_matcher.best_match) + + tree.insert_observation(['a', 'b'], ['x', 'x']) + self.assertIn(tree.root, tree.state_matcher.best_match) + best = tree.state_matcher.best_match[tree.root] + self.assertEqual(len(best), 1) + self.assertEqual(best[0].state_id, 's(0,0)') + + def test_promote_frontier_state_updates_matching_for_new_basis(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(['a', 'b'], target, [reference], rebuilding=False, state_matching='Approximate') + + tree.insert_observation(['a', 'a'], ['x', 'y']) + tree.update_frontier_and_basis() + + for basis_state in tree.basis: + self.assertIn(basis_state, tree.state_matcher.best_match) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/adaptive/test_state_matching.py b/tests/learning_algs/adaptive/test_state_matching.py new file mode 100644 index 00000000000..7febf999811 --- /dev/null +++ b/tests/learning_algs/adaptive/test_state_matching.py @@ -0,0 +1,157 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import MealyMachine, MealyState +from aalpy.learning_algs.adaptive.AdaptiveObservationTree import AdaptiveObservationTree +from aalpy.learning_algs.adaptive.StateMatching import ApproximateStateMatching, TotalStateMatching + + +def two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x'): + """ + 2-state Mealy machine over {a, b}. + s0 --a/out_a1--> s1 s0 --b/out_b1--> s0 + s1 --a/out_a2--> s0 s1 --b/out_b2--> s1 + """ + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'a': s1, 'b': s0} + s0.output_fun = {'a': out_a1, 'b': out_b1} + s1.transitions = {'a': s0, 'b': s1} + s1.output_fun = {'a': out_a2, 'b': out_b2} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm + + +def make_tree(target, reference, matching_type): + sul = AutomatonSUL(target) + return AdaptiveObservationTree(['a', 'b'], sul, [reference], 'mealy', None, 'SepSeq', + rebuilding=False, state_matching=matching_type) + + +class TestPureHelpers(unittest.TestCase): + def setUp(self): + target = two_state_mealy() + reference = two_state_mealy() + self.tree = make_tree(target, reference, 'Approximate') + self.matcher = self.tree.state_matcher + + def test_is_prefix_of_true(self): + self.assertTrue(self.matcher.is_prefix_of(('a',), ('a', 'b'))) + self.assertTrue(self.matcher.is_prefix_of((), ('a', 'b'))) + self.assertTrue(self.matcher.is_prefix_of(('a', 'b'), ('a', 'b'))) + + def test_is_prefix_of_false(self): + self.assertFalse(self.matcher.is_prefix_of(('a', 'b'), ('a',))) + self.assertFalse(self.matcher.is_prefix_of(('b',), ('a', 'b'))) + + def test_find_longest_common_part_full_match(self): + common, rest = self.matcher.find_longest_common_part(('a', 'b'), ('a', 'b')) + self.assertEqual(common, ('a', 'b')) + self.assertEqual(rest, ()) + + def test_find_longest_common_part_partial_match(self): + common, rest = self.matcher.find_longest_common_part(('a', 'a'), ('a', 'b', 'a')) + self.assertEqual(common, ('a',)) + self.assertEqual(rest, ('b', 'a')) + + def test_validate_reference_input(self): + ref_state = self.tree.combined_model.states[0] + self.assertTrue(self.matcher.validate_reference_input((), ref_state)) + self.assertTrue(self.matcher.validate_reference_input(('a', 'b'), ref_state)) + self.assertFalse(self.matcher.validate_reference_input(('z',), ref_state)) + + +class TestApproximateStateMatchingScoring(unittest.TestCase): + def test_perfect_match_scores_one_for_matching_reference_state(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(target, reference, 'Approximate') + + tree.insert_observation(['a', 'b'], ['x', 'x']) + + matcher = tree.state_matcher + best = matcher.best_match[tree.root] + self.assertEqual(matcher.best_score[tree.root], 1.0) + self.assertEqual([s.state_id for s in best], ['s(0,0)']) + + def test_worse_match_still_ranks_below_perfect_one(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(target, reference, 'Approximate') + + tree.insert_observation(['a', 'b'], ['x', 'x']) + matcher = tree.state_matcher + + r0, r1 = tree.combined_model.states + self.assertEqual(matcher.get_score(tree.root, r0), 1.0) + self.assertEqual(matcher.get_score(tree.root, r1), 0.0) + + def test_completely_unrelated_sul_yields_empty_best_match(self): + target = two_state_mealy(out_a1='p', out_b1='q', out_a2='q', out_b2='p') + reference = two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x') + tree = make_tree(target, reference, 'Approximate') + + tree.insert_observation(['a', 'b'], ['p', 'p']) + matcher = tree.state_matcher + + self.assertEqual(matcher.best_score[tree.root], 0) + self.assertEqual(matcher.best_match[tree.root], []) + + def test_get_score_is_zero_when_no_observations_made(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(target, reference, 'Approximate') + matcher = tree.state_matcher + + for ref_state in tree.combined_model.states: + self.assertEqual(matcher.get_score(tree.root, ref_state), 0) + + +class TestTotalStateMatchingScoring(unittest.TestCase): + def test_perfect_match_scores_one_and_excludes_mismatching_state(self): + target = two_state_mealy() + reference = two_state_mealy() + tree = make_tree(target, reference, 'Total') + + tree.insert_observation(['a', 'b'], ['x', 'x']) + matcher = tree.state_matcher + + best = matcher.best_match[tree.root] + self.assertEqual([s.state_id for s in best], ['s(0,0)']) + + def test_single_mismatch_zeroes_out_total_match_permanently(self): + target = two_state_mealy(out_a1='p', out_b1='q', out_a2='q', out_b2='p') + reference = two_state_mealy(out_a1='x', out_b1='y', out_a2='y', out_b2='x') + tree = make_tree(target, reference, 'Total') + + tree.insert_observation(['a'], ['p']) + matcher = tree.state_matcher + + for ref_state in tree.combined_model.states: + self.assertEqual(matcher.matchings[tree.root][ref_state], 0) + self.assertEqual(matcher.best_match[tree.root], []) + + def test_add_entry_basis_dfa_style_output_matching(self): + # Total matching for dfa/moore compares the state's own output at initialization time. + from aalpy.automata import DfaState, Dfa + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1} + q1.transitions = {'a': q0} + dfa = Dfa(q0, [q0, q1]) + dfa.compute_prefixes() + + combined_accept = DfaState('ref_accept', is_accepting=True) + combined_reject = DfaState('ref_reject', is_accepting=False) + matcher = TotalStateMatching(['a'], None) + matcher.combined_model = type('C', (), {'states': [combined_accept, combined_reject]})() + + basis_state = type('B', (), {'output': True})() + matcher.add_entry_basis(basis_state, 'dfa') + self.assertEqual(matcher.matchings[basis_state][combined_accept], 1) + self.assertEqual(matcher.matchings[basis_state][combined_reject], 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_ads.py b/tests/learning_algs/deterministic/test_ads.py new file mode 100644 index 00000000000..03473d4c81e --- /dev/null +++ b/tests/learning_algs/deterministic/test_ads.py @@ -0,0 +1,141 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs.deterministic.ADS import Ads +from aalpy.learning_algs.deterministic.ObservationTree import ObservationTree +from aalpy.utils import get_Angluin_dfa + + +def dfa_tree(): + sul = AutomatonSUL(get_Angluin_dfa()) + return ObservationTree(['a', 'b'], sul, 'dfa', 'ADS', 'ADS') + + +def mealy_tree(): + sul = AutomatonSUL(get_Angluin_dfa()) + return ObservationTree(['a', 'b'], sul, 'mealy', 'ADS', 'ADS') + + +class TestAdsSingleNodeBlock(unittest.TestCase): + def test_single_node_block_is_a_leaf_with_zero_score(self): + tree = dfa_tree() + tree.insert_observation(['a'], [True]) + node_a = tree.get_successor(['a']) + ads = Ads(tree, [node_a]) + self.assertEqual(ads.get_score(), 0) + self.assertIsNone(ads.next_input(None)) + + +class TestAdsMooreDfa(unittest.TestCase): + def test_immediate_own_output_difference_gets_maximal_score(self): + tree = dfa_tree() + tree.insert_observation(['a'], [True]) + tree.insert_observation(['b'], [False]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + + ads = Ads(tree, [node_a, node_b]) + self.assertEqual(ads.get_score(), 1.0) + # The block splits purely on the states' own (already-known) output; the root of the ADS + # uses the tuple() sentinel to represent this (no real input is sent). + self.assertEqual(ads.next_input(None), tuple()) + + def test_ads_correctly_separates_states_needing_one_step_lookahead(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.insert_observation(['b'], [False]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + tree.insert_observation(['a', 'a'], [False, True]) + tree.insert_observation(['b', 'a'], [False, False]) + + ads = Ads(tree, [node_a, node_b]) + self.assertEqual(ads.get_score(), 1.0) + + for node, expected_final_output in [(node_a, True), (node_b, False)]: + ads.reset_to_root() + first_input = ads.next_input(None) + self.assertEqual(first_input, tuple()) + second_input = ads.next_input(node.output) + self.assertEqual(second_input, 'a') + successor_output = node.get_successor(second_input).output + self.assertEqual(successor_output, expected_final_output) + self.assertIsNone(ads.next_input(successor_output)) + + def test_no_information_available_falls_back_to_a_zero_score_guess(self): + """ + construct_ads_rec's "no successor anywhere in the block" guard (see its docstring/comment) + is meant to raise when it truly cannot pick a next input. In practice it never fires: it + checks `node.successors is not None`, but successors is always a dict (defaulting to {}, + never None), so the condition is always true regardless of whether any real successor was + recorded. Confirmed here: when node_a/node_b share the same own output and neither has any + recorded successor, construction does not raise -- it falls back to picking the first + alphabet symbol with score 0 and an empty (non-distinguishing) child map. This fallback + actually matters in practice (e.g. for run_Lsharp(..., 'moore', extension_rule='ADS', + separation_rule='ADS')): it lets the algorithm still send a real input to the SUL to gather + more information instead of hard failing when the tree does not yet have enough data, so + this is intentionally left as-is rather than "fixed". + """ + tree = dfa_tree() + tree.insert_observation(['a'], [True]) + tree.insert_observation(['b'], [True]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + + ads = Ads(tree, [node_a, node_b]) + self.assertEqual(ads.get_score(), 0.0) + # First step just splits on the (identical) own output via the tuple() sentinel; the actual + # zero-score fallback input is one level down, in the subtree for that shared output. + self.assertEqual(ads.next_input(None), tuple()) + self.assertEqual(ads.next_input(True), tree.alphabet[0]) + + +class TestAdsMealy(unittest.TestCase): + def test_single_input_perfectly_separates_three_state_block(self): + tree = mealy_tree() + tree.insert_observation(['a'], [1]) + tree.insert_observation(['b'], [2]) + tree.insert_observation(['a', 'b'], [1, 9]) + node_1 = tree.get_successor(['a']) + node_2 = tree.get_successor(['b']) + node_3 = tree.get_successor(['a', 'b']) + + tree.insert_observation(['a', 'a'], [1, 5]) + tree.insert_observation(['b', 'a'], [2, 6]) + tree.insert_observation(['a', 'b', 'a'], [1, 9, 7]) + + ads = Ads(tree, [node_1, node_2, node_3]) + # 3 states perfectly separated by one input reaches the maximal possible score, len(block)-1. + self.assertEqual(ads.get_score(), 2.0) + + outputs_seen = set() + for node in [node_1, node_2, node_3]: + ads.reset_to_root() + first_input = ads.next_input(None) + self.assertEqual(first_input, 'a') + output = node.get_output(first_input) + outputs_seen.add(output) + self.assertIsNone(ads.next_input(output)) + # each state produces a distinct output on the chosen input, so the ADS fully identifies them + self.assertEqual(len(outputs_seen), 3) + + def test_maximal_base_input_prefers_the_more_discriminating_input(self): + tree = mealy_tree() + tree.insert_observation(['a'], [1]) + tree.insert_observation(['b'], [2]) + node_1 = tree.get_successor(['a']) + node_2 = tree.get_successor(['b']) + # 'a' does not distinguish the two nodes (same output on 'a'), 'b' does. + tree.insert_observation(['a', 'a'], [1, 0]) + tree.insert_observation(['b', 'a'], [2, 0]) + tree.insert_observation(['a', 'b'], [1, 3]) + tree.insert_observation(['b', 'b'], [2, 4]) + + ads = Ads(tree, [node_1, node_2]) + best_input, best_score = ads.maximal_base_input(['a', 'b'], [node_1, node_2], 'mealy') + self.assertEqual(best_input, 'b') + self.assertEqual(best_score, 1.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_apartness.py b/tests/learning_algs/deterministic/test_apartness.py new file mode 100644 index 00000000000..57477abb463 --- /dev/null +++ b/tests/learning_algs/deterministic/test_apartness.py @@ -0,0 +1,156 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Dfa, DfaState, MealyState +from aalpy.learning_algs.deterministic.Apartness import Apartness +from aalpy.learning_algs.deterministic.ObservationTree import ObservationTree +from aalpy.utils import get_Angluin_dfa + + +def dfa_tree(): + sul = AutomatonSUL(get_Angluin_dfa()) + return ObservationTree(['a', 'b'], sul, 'dfa', None, 'SepSeq') + + +def mealy_tree(): + sul = AutomatonSUL(get_Angluin_dfa()) + return ObservationTree(['a', 'b'], sul, 'mealy', None, 'SepSeq') + + +class TestStatesAreApartMoore(unittest.TestCase): + def test_identical_node_is_never_apart_from_itself(self): + tree = dfa_tree() + self.assertFalse(Apartness.states_are_apart(tree.root, tree.root, tree)) + + def test_nodes_with_same_output_and_no_observed_difference_are_not_apart(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.insert_observation(['b'], [False]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree)) + + def test_nodes_apart_via_immediate_output_difference(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.insert_observation(['b'], [True]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree)) + self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), []) + + def test_nodes_apart_via_one_step_successor_difference(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.insert_observation(['b'], [False]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree)) + + # Both have output False, but diverge one step further via input 'a'. + tree.insert_observation(['a', 'a'], [False, True]) + tree.insert_observation(['b', 'a'], [False, False]) + self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree)) + self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), ['a']) + + def test_nodes_apart_only_via_a_longer_suffix(self): + tree = dfa_tree() + tree.insert_observation(['a'], [True]) + tree.insert_observation(['b'], [True]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + + tree.insert_observation(['a', 'a'], [True, True]) + tree.insert_observation(['b', 'a'], [True, True]) + self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree)) + + # Only diverge two steps deep, via 'a', 'a'. + tree.insert_observation(['a', 'a', 'a'], [True, True, True]) + tree.insert_observation(['b', 'a', 'a'], [True, True, False]) + self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree)) + self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), ['a', 'a']) + + def test_no_witness_when_not_apart(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.insert_observation(['b'], [False]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + self.assertIsNone(Apartness.compute_witness(node_a, node_b, tree)) + + +class TestStatesAreApartMealy(unittest.TestCase): + def test_apart_via_differing_output_on_same_input(self): + tree = mealy_tree() + tree.insert_observation(['a'], [1]) + tree.insert_observation(['b'], [0]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + # node_a/node_b themselves have no outgoing observations yet, so they only become apart once + # their own successors disagree on some input. + tree.insert_observation(['a', 'a'], [1, 5]) + tree.insert_observation(['b', 'a'], [0, 7]) + self.assertTrue(Apartness.states_are_apart(node_a, node_b, tree)) + self.assertEqual(Apartness.compute_witness(node_a, node_b, tree), ['a']) + + def test_not_apart_when_undetermined_inputs_produce_no_conflict(self): + tree = mealy_tree() + tree.insert_observation(['a'], [1]) + tree.insert_observation(['b'], [1]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + # Neither node has an observed 'a'/'b'-successor yet, so no output pair can conflict. + self.assertFalse(Apartness.states_are_apart(node_a, node_b, tree)) + + +class TestComputeWitnessInTreeAndHypothesisStates(unittest.TestCase): + def test_dfa_root_matching_hypothesis_state_returns_none(self): + tree = dfa_tree() + h0 = DfaState('h0', is_accepting=True) + h0.transitions = {'a': h0, 'b': h0} + self.assertIsNone(Apartness.compute_witness_in_tree_and_hypothesis_states(tree, tree.root, h0)) + + def test_dfa_immediate_output_mismatch_returns_empty_witness(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + node_a = tree.get_successor(['a']) + h0 = DfaState('h0', is_accepting=True) + h0.transitions = {'a': h0, 'b': h0} + # node_a.output is False but h0.is_accepting is True: they already differ at this node. + witness = Apartness.compute_witness_in_tree_and_hypothesis_states(tree, node_a, h0) + self.assertEqual(witness, []) + + def test_dfa_mismatch_found_one_step_deeper(self): + tree = dfa_tree() + tree.insert_observation(['a'], [True]) + tree.insert_observation(['a', 'b'], [True, False]) + node_a = tree.get_successor(['a']) + h0 = DfaState('h0', is_accepting=True) + h0.transitions = {'a': h0, 'b': h0} + # node_a agrees with h0 (both True); its 'b'-successor (False) disagrees with h0 (True). + witness = Apartness.compute_witness_in_tree_and_hypothesis_states(tree, node_a, h0) + self.assertEqual(witness, ['b']) + + def test_mealy_output_mismatch_detected(self): + tree = mealy_tree() + m0 = MealyState('m0') + m0.transitions = {'a': m0, 'b': m0} + m0.output_fun = {'a': 1, 'b': 0} + # root's observed 'a'-output (0) disagrees with m0.output_fun['a'] (1); the witness is the + # single input that exposes the mismatch, i.e. the transfer sequence to root's 'a'-successor. + tree.insert_observation(['a'], [0]) + witness = Apartness.compute_witness_in_tree_and_hypothesis_states(tree, tree.root, m0) + self.assertEqual(witness, ['a']) + + def test_mealy_no_mismatch_returns_none(self): + tree = mealy_tree() + m0 = MealyState('m0') + m0.transitions = {'a': m0, 'b': m0} + m0.output_fun = {'a': 1, 'b': 0} + tree.insert_observation(['a'], [1]) + tree.insert_observation(['b'], [0]) + self.assertIsNone(Apartness.compute_witness_in_tree_and_hypothesis_states(tree, tree.root, m0)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_classification_tree.py b/tests/learning_algs/deterministic/test_classification_tree.py new file mode 100644 index 00000000000..68b33f18a86 --- /dev/null +++ b/tests/learning_algs/deterministic/test_classification_tree.py @@ -0,0 +1,169 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs.deterministic.ClassificationTree import ClassificationTree, CTInternalNode, CTLeafNode +from aalpy.utils import get_Angluin_dfa +from aalpy.utils.ModelChecking import bisimilar + + +def find_counterexample(sul, hypothesis, max_length=6): + import itertools + alphabet = ['a', 'b'] + for length in range(1, max_length + 1): + for word in itertools.product(alphabet, repeat=length): + hypothesis.reset_to_initial() + hyp_out = hypothesis.execute_sequence(hypothesis.initial_state, list(word)) + sul_out = sul.query(word) + if hyp_out[-1] != sul_out[-1]: + return tuple(word) + raise AssertionError('no counterexample found within max_length') + + +class TestClassificationTreeInitDfa(unittest.TestCase): + def test_root_has_two_leaves_for_initial_state_and_cex(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + self.assertIsInstance(ct.root, CTInternalNode) + self.assertEqual(ct.root.distinguishing_string, tuple()) + self.assertIn(tuple(), ct.leaf_nodes) + self.assertIn(('a',), ct.leaf_nodes) + self.assertEqual(len(ct.root.children), 2) + + def test_initial_hypothesis_has_two_states(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + hyp = ct.update_hypothesis() + self.assertEqual(len(hyp.states), 2) + prefixes = {s.prefix for s in hyp.states} + self.assertEqual(prefixes, {tuple(), ('a',)}) + + +class TestClassificationTreeInitMealy(unittest.TestCase): + def test_root_distinguishing_string_is_last_symbol_of_cex(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'mealy', cex=('a', 'a')) + self.assertEqual(ct.root.distinguishing_string, ('a',)) + + def test_initial_hypothesis_output_fun_matches_sul(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'mealy', cex=('a', 'a')) + hyp = ct.update_hypothesis() + self.assertEqual(len(hyp.states), 2) + initial = next(s for s in hyp.states if s.prefix == tuple()) + self.assertEqual(initial.output_fun['a'], sul.query(('a',))[-1]) + self.assertEqual(initial.output_fun['b'], sul.query(('b',))[-1]) + + +class TestSift(unittest.TestCase): + def test_sift_routes_known_access_strings_to_themselves(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + ct.update_hypothesis() + self.assertEqual(ct._sift(tuple()).access_string, tuple()) + self.assertEqual(ct._sift(('a',)).access_string, ('a',)) + + def test_sift_routes_equivalent_word_to_existing_leaf(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + ct.update_hypothesis() + # 'b' (-> q2, non-accepting) is indistinguishable from ('a',) (-> q1, non-accepting) under + # the root's current distinguishing string (the empty word), so it sifts to the same leaf. + self.assertEqual(ct._sift(('b',)).access_string, ('a',)) + + def test_sift_of_equivalent_word_does_not_grow_the_tree(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + ct.update_hypothesis() + leaves_before = set(ct.leaf_nodes) + # ('b', 'b') reaches q0 (accepting) again via a different path than any known access string, + # but since the tree currently only distinguishes accepting/non-accepting, sifting it does + # not introduce a new leaf -- it just routes to the existing empty-word-labelled leaf. + leaf = ct._sift(('b', 'b')) + self.assertIsInstance(leaf, CTLeafNode) + self.assertEqual(leaf.access_string, tuple()) + self.assertEqual(set(ct.leaf_nodes), leaves_before) + + def test_sift_creates_a_new_leaf_the_first_time_a_branch_is_taken(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + ct.update_hypothesis() + + # Replace the tree with a hand-built root discriminated by suffix ('a',) that only has the + # True branch populated, to directly control and observe _sift's leaf-creation branch. + new_root = CTInternalNode(distinguishing_string=('a',), parent=None, path_to_node=None) + leaf_true = CTLeafNode(access_string=tuple(), parent=new_root, path_to_node=True) + new_root.children[True] = leaf_true + ct.root = new_root + ct.leaf_nodes = {tuple(): leaf_true} + + # query(('b',) + ('a',)) = query(('b', 'a')): q0 -b-> q2 -a-> q3, output False -- a key not + # yet present among new_root's children, so sifting 'b' must create and register a new leaf. + leaf = ct._sift(('b',)) + self.assertEqual(leaf.access_string, ('b',)) + self.assertEqual(leaf.path_to_node, False) + self.assertIn(('b',), ct.leaf_nodes) + self.assertIs(new_root.children[False], leaf) + + +class TestProcessCounterexampleRs(unittest.TestCase): + def test_process_counterexample_grows_the_tree_and_hypothesis(self): + dfa = get_Angluin_dfa() + sul = AutomatonSUL(dfa) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + hyp = ct.update_hypothesis() + self.assertEqual(len(hyp.states), 2) + + cex = find_counterexample(sul, hyp) + ct.process_counterexample(cex, hyp, 'rs') + hyp = ct.update_hypothesis() + self.assertEqual(len(hyp.states), 3) + + def test_learning_converges_to_ground_truth_after_enough_counterexamples(self): + dfa = get_Angluin_dfa() + sul = AutomatonSUL(dfa) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + hyp = ct.update_hypothesis() + + for _ in range(10): + try: + cex = find_counterexample(sul, hyp) + except AssertionError: + break + ct.process_counterexample(cex, hyp, 'rs') + hyp = ct.update_hypothesis() + + self.assertEqual(len(hyp.states), len(dfa.states)) + self.assertTrue(bisimilar(dfa, hyp)) + + +class TestLegacyUpdateMethod(unittest.TestCase): + """ + ClassificationTree.update() (distinct from process_counterexample()) implements a second, + unparametrized counterexample-processing strategy. It is not called anywhere in KV.py (which + always uses process_counterexample), but it is still part of the class's public surface, so it + is exercised directly here. + """ + + def test_update_grows_the_tree_and_hypothesis(self): + dfa = get_Angluin_dfa() + sul = AutomatonSUL(dfa) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + hyp = ct.update_hypothesis() + self.assertEqual(len(hyp.states), 2) + + cex = find_counterexample(sul, hyp) + ct.update(cex, hyp) + hyp = ct.update_hypothesis() + self.assertEqual(len(hyp.states), 3) + + +class TestLeastCommonAncestor(unittest.TestCase): + def test_lca_of_siblings_is_the_root(self): + sul = AutomatonSUL(get_Angluin_dfa()) + ct = ClassificationTree(['a', 'b'], sul, 'dfa', cex=('a',)) + ct.update_hypothesis() + self.assertEqual(ct._least_common_ancestor(tuple(), ('a',)), ct.root.distinguishing_string) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_counter_example_processing.py b/tests/learning_algs/deterministic/test_counter_example_processing.py new file mode 100644 index 00000000000..40ddd4af674 --- /dev/null +++ b/tests/learning_algs/deterministic/test_counter_example_processing.py @@ -0,0 +1,133 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Dfa, DfaState +from aalpy.learning_algs.deterministic.CounterExampleProcessing import ( + counterexample_successfully_processed, exponential_cex_processing, linear_cex_processing, + longest_prefix_cex_processing, rs_cex_processing) +from aalpy.utils import get_Angluin_dfa + + +def wrong_two_state_hypothesis(): + """ + A deliberately wrong 2-state hypothesis for get_Angluin_dfa() that conflates the ground truth's + q2 and q3 states with q1 (both non-accepting), used to produce genuine counterexamples for the + cex-processing strategies below. + h0 (accepting, prefix=()) --a--> h1 --b--> h0 + h1 (non-accepting, prefix=('a',)) --a--> h0 --b--> h1 + """ + h0 = DfaState('h0', is_accepting=True) + h1 = DfaState('h1', is_accepting=False) + h0.transitions = {'a': h1, 'b': h0} + h1.transitions = {'a': h0, 'b': h1} + h0.prefix = tuple() + h1.prefix = ('a',) + return Dfa(h0, [h0, h1]) + + +class TestCounterexampleSuccessfullyProcessed(unittest.TestCase): + def test_returns_false_while_still_a_counterexample(self): + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + self.assertFalse(counterexample_successfully_processed(sul, ('b', 'b', 'b'), hyp)) + + def test_returns_true_once_outputs_agree(self): + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + # 'a' alone: dfa q0 --a--> q1 (False); hyp h0 --a--> h1 (False). Outputs already agree. + self.assertTrue(counterexample_successfully_processed(sul, ('a',), hyp)) + + +class TestLongestPrefixCexProcessing(unittest.TestCase): + def test_trims_longest_matching_prefix_and_returns_suffixes(self): + prefixes = [tuple(), ('a',), ('b',)] + cex = ('a', 'b', 'a') + # Longest matching prefix of cex among `prefixes` is ('a',); remaining trimmed suffix is + # ('b', 'a'), whose own (reversed) suffixes are [('b', 'a'), ('a',)]. + result = longest_prefix_cex_processing(list(prefixes), cex, closedness='suffix') + self.assertEqual(result, [('b', 'a'), ('a',)]) + + def test_prefix_closedness_returns_prefixes_of_trimmed_suffix(self): + prefixes = [tuple(), ('a',), ('b',)] + cex = ('a', 'b', 'a') + result = longest_prefix_cex_processing(list(prefixes), cex, closedness='prefix') + self.assertEqual(result, [('b', 'a'), ('b',)]) + + def test_no_matching_prefix_uses_whole_counterexample(self): + prefixes = [('c',)] + cex = ('a', 'b') + result = longest_prefix_cex_processing(list(prefixes), cex, closedness='suffix') + self.assertEqual(result, [('a', 'b'), ('b',)]) + + +class TestRsCexProcessing(unittest.TestCase): + def test_finds_single_distinguishing_suffix(self): + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + cex = ('b', 'b', 'b') + suffix = rs_cex_processing(sul, cex, hyp, suffix_closedness=False) + self.assertEqual(suffix, [('b', 'b')]) + + def test_suffix_closedness_adds_all_suffixes(self): + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + cex = ('b', 'b', 'b') + suffixes = rs_cex_processing(sul, cex, hyp, suffix_closedness=True) + self.assertEqual(suffixes, [('b', 'b'), ('b',)]) + + def test_result_is_a_genuine_suffix_of_the_counterexample(self): + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + cex = ('a', 'b', 'a') + suffix = rs_cex_processing(sul, cex, hyp, suffix_closedness=False)[0] + self.assertEqual(cex[len(cex) - len(suffix):], suffix) + + +class TestLinearCexProcessing(unittest.TestCase): + def test_forward_and_backward_scans_can_find_different_witnesses(self): + """ + Regression test: linear_cex_processing used to unconditionally overwrite its `direction` + parameter with 'fwd' right after validating it (a leftover from development, visible in git + history but never cleaned up for this function even though the analogous line was removed + from exponential_cex_processing). This silently made cex_processing='linear_bwd' behave + exactly like 'linear_fwd'. Fixed by deleting the stray override in CounterExampleProcessing.py. + """ + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + cex = ('b', 'b', 'b') + + forward = linear_cex_processing(sul, cex, hyp, direction='fwd', suffix_closedness=False) + backward = linear_cex_processing(sul, cex, hyp, direction='bwd', suffix_closedness=False) + + self.assertEqual(forward, [('b', 'b')]) + self.assertEqual(backward, [('b',)]) + self.assertNotEqual(forward, backward) + + def test_invalid_direction_raises(self): + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + with self.assertRaises(AssertionError): + linear_cex_processing(sul, ('a', 'b'), hyp, direction='sideways') + + +class TestExponentialCexProcessing(unittest.TestCase): + def test_forward_scan_finds_a_valid_suffix(self): + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + cex = ('b', 'b', 'b') + suffix = exponential_cex_processing(sul, cex, hyp, direction='fwd', suffix_closedness=False) + self.assertEqual(suffix, [('b', 'b')]) + + def test_forward_result_agrees_with_rs_processing(self): + # Exponential search falls back to Rivest-Schapire binary search once it has bracketed the + # divergence point, so on a fixed cex/hypothesis pair both should settle on the same suffix. + sul = AutomatonSUL(get_Angluin_dfa()) + hyp = wrong_two_state_hypothesis() + cex = ('a', 'b', 'a') + rs_suffix = rs_cex_processing(sul, cex, hyp, suffix_closedness=False) + exp_suffix = exponential_cex_processing(sul, cex, hyp, direction='fwd', suffix_closedness=False) + self.assertEqual(rs_suffix, exp_suffix) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_learning_algorithms.py b/tests/learning_algs/deterministic/test_learning_algorithms.py new file mode 100644 index 00000000000..c7869037623 --- /dev/null +++ b/tests/learning_algs/deterministic/test_learning_algorithms.py @@ -0,0 +1,143 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Dfa, MealyMachine, MooreMachine +from aalpy.learning_algs import run_KV, run_Lsharp, run_Lstar +from aalpy.oracles import RandomWalkEqOracle, RandomWMethodEqOracle, WMethodEqOracle +from aalpy.utils import get_Angluin_dfa, load_automaton_from_file +from aalpy.utils.ModelChecking import bisimilar +from pathlib import Path + +DOT_MODELS_DIR = Path(__file__).resolve().parents[3] / 'DotModels' + + +def reference_automaton(automaton_type): + if automaton_type == 'dfa': + return get_Angluin_dfa() + if automaton_type == 'mealy': + return load_automaton_from_file(str(DOT_MODELS_DIR / 'Angluin_Mealy.dot'), automaton_type='mealy') + if automaton_type == 'moore': + return load_automaton_from_file(str(DOT_MODELS_DIR / 'Angluin_Moore.dot'), automaton_type='moore') + raise ValueError(automaton_type) + + +def learns_correctly(learning_alg, automaton_type, **kwargs): + """ + Builds a fresh SUL/oracle around the reference Angluin automaton of the requested type, runs the + given learning algorithm with the given config, and asserts the learned hypothesis is minimal and + bisimilar to the ground truth (which itself is already known to be minimal for all three types). + """ + ground_truth = reference_automaton(automaton_type) + alphabet = ground_truth.get_input_alphabet() + sul = AutomatonSUL(ground_truth) + eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=50, walk_len=20) + + learned_model = learning_alg(alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level=0, **kwargs) + + assert learned_model.is_minimal(), f'{learning_alg.__name__}/{automaton_type}: learned model is not minimal' + assert bisimilar(ground_truth, learned_model), \ + f'{learning_alg.__name__}/{automaton_type}: learned model is not bisimilar to ground truth' + return learned_model + + +class TestRunLstarConfigurations(unittest.TestCase): + AUTOMATON_TYPES = ['dfa', 'mealy', 'moore'] + + def test_default_configuration(self): + for automaton_type in self.AUTOMATON_TYPES: + learns_correctly(run_Lstar, automaton_type) + + def test_closing_strategies(self): + for closing in ['shortest_first', 'longest_first', 'single']: + learns_correctly(run_Lstar, 'dfa', closing_strategy=closing) + + def test_cex_processing_strategies(self): + for cex_processing in [None, 'rs', 'longest_prefix', 'linear_fwd', 'linear_bwd', + 'exponential_fwd', 'exponential_bwd']: + learns_correctly(run_Lstar, 'mealy', cex_processing=cex_processing) + + def test_suffix_closedness_options(self): + for suffix_closed in [True, False]: + learns_correctly(run_Lstar, 'moore', all_prefixes_in_obs_table=True, e_set_suffix_closed=suffix_closed) + + def test_without_caching_and_non_det_check(self): + learns_correctly(run_Lstar, 'dfa', cache_and_non_det_check=False) + + def test_with_a_different_equivalence_oracle(self): + ground_truth = reference_automaton('dfa') + alphabet = ground_truth.get_input_alphabet() + sul = AutomatonSUL(ground_truth) + eq_oracle = WMethodEqOracle(alphabet, sul, max_number_of_states=len(ground_truth.states) + 1) + learned_model = run_Lstar(alphabet, sul, eq_oracle, automaton_type='dfa', print_level=0) + self.assertTrue(learned_model.is_minimal()) + self.assertTrue(bisimilar(ground_truth, learned_model)) + + def test_return_data_reports_consistent_learning_rounds(self): + ground_truth = reference_automaton('dfa') + alphabet = ground_truth.get_input_alphabet() + sul = AutomatonSUL(ground_truth) + eq_oracle = RandomWalkEqOracle(alphabet, sul, 1000) + learned_model, info = run_Lstar(alphabet, sul, eq_oracle, automaton_type='dfa', print_level=0, + return_data=True) + self.assertTrue(bisimilar(ground_truth, learned_model)) + self.assertGreaterEqual(info['learning_rounds'], 1) + self.assertEqual(info['automaton_size'], len(learned_model.states)) + + +class TestRunKvConfigurations(unittest.TestCase): + AUTOMATON_TYPES = ['dfa', 'mealy', 'moore'] + + def test_default_configuration(self): + for automaton_type in self.AUTOMATON_TYPES: + learns_correctly(run_KV, automaton_type) + + def test_cex_processing_strategies(self): + for cex_processing in ['rs', 'linear_fwd', 'linear_bwd', 'exponential_fwd', 'exponential_bwd']: + learns_correctly(run_KV, 'mealy', cex_processing=cex_processing) + + def test_without_caching_and_non_det_check(self): + learns_correctly(run_KV, 'dfa', cache_and_non_det_check=False) + + def test_return_data_reports_consistent_learning_rounds(self): + ground_truth = reference_automaton('moore') + alphabet = ground_truth.get_input_alphabet() + sul = AutomatonSUL(ground_truth) + eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=50, walk_len=20) + learned_model, info = run_KV(alphabet, sul, eq_oracle, automaton_type='moore', print_level=0, + return_data=True) + self.assertTrue(bisimilar(ground_truth, learned_model)) + self.assertEqual(info['automaton_size'], len(learned_model.states)) + + +class TestRunLsharpConfigurations(unittest.TestCase): + AUTOMATON_TYPES = ['dfa', 'mealy', 'moore'] + + def test_default_configuration(self): + for automaton_type in self.AUTOMATON_TYPES: + learns_correctly(run_Lsharp, automaton_type) + + def test_extension_and_separation_rule_combinations(self): + for extension_rule in [None, 'SepSeq', 'ADS']: + for separation_rule in ['SepSeq', 'ADS']: + learns_correctly(run_Lsharp, 'dfa', extension_rule=extension_rule, separation_rule=separation_rule) + + def test_extension_and_separation_rules_on_mealy_and_moore(self): + for automaton_type in ['mealy', 'moore']: + learns_correctly(run_Lsharp, automaton_type, extension_rule='ADS', separation_rule='ADS') + + def test_without_caching_and_non_det_check(self): + learns_correctly(run_Lsharp, 'dfa', cache_and_non_det_check=False) + + def test_return_data_reports_consistent_learning_rounds(self): + ground_truth = reference_automaton('dfa') + alphabet = ground_truth.get_input_alphabet() + sul = AutomatonSUL(ground_truth) + eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=50, walk_len=20) + learned_model, info = run_Lsharp(alphabet, sul, eq_oracle, automaton_type='dfa', print_level=0, + return_data=True) + self.assertTrue(bisimilar(ground_truth, learned_model)) + self.assertEqual(info['automaton_size'], len(learned_model.states)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_observation_table.py b/tests/learning_algs/deterministic/test_observation_table.py new file mode 100644 index 00000000000..0b5f11c2ffd --- /dev/null +++ b/tests/learning_algs/deterministic/test_observation_table.py @@ -0,0 +1,315 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Dfa, DfaState, MealyMachine, MealyState, MooreMachine, MooreState +from aalpy.learning_algs.deterministic.ObservationTable import ObservationTable +from aalpy.utils import get_Angluin_dfa +from aalpy.utils.ModelChecking import bisimilar + + +def three_state_dfa(): + """ + 3-state minimal DFA over {a, b}. q0 is accepting; q1 and q2 are only + distinguishable by the suffix 'b' (both go to a non-accepting state on 'a'). + q0 --a--> q1 q0 --b--> q0 + q1 --a--> q2 q1 --b--> q0 + q2 --a--> q1 q2 --b--> q2 + """ + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q2 = DfaState('q2', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q2, 'b': q0} + q2.transitions = {'a': q1, 'b': q2} + dfa = Dfa(q0, [q0, q1, q2]) + dfa.compute_prefixes() + return dfa + + +def two_state_mealy(): + """ + 2-state Mealy machine over {a, b} where a self loop is only reachable from s1. + s0 --a/1--> s1 s0 --b/0--> s0 + s1 --a/0--> s0 s1 --b/1--> s1 + """ + s0 = MealyState('s0') + s1 = MealyState('s1') + s0.transitions = {'a': s1, 'b': s0} + s0.output_fun = {'a': 1, 'b': 0} + s1.transitions = {'a': s0, 'b': s1} + s1.output_fun = {'a': 0, 'b': 1} + mm = MealyMachine(s0, [s0, s1]) + mm.compute_prefixes() + return mm + + +def two_state_moore(): + s0 = MooreState('s0', output=1) + s1 = MooreState('s1', output=0) + s0.transitions = {'a': s1, 'b': s0} + s1.transitions = {'a': s0, 'b': s1} + moore = MooreMachine(s0, [s0, s1]) + moore.compute_prefixes() + return moore + + +class TestObservationTableInit(unittest.TestCase): + def test_dfa_initial_e_set_contains_empty_word(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + self.assertEqual(table.E, [tuple()]) + self.assertEqual(table.S, [tuple()]) + self.assertEqual(table.A, [('a',), ('b',)]) + + def test_moore_initial_e_set_contains_empty_word(self): + moore = two_state_moore() + sul = AutomatonSUL(moore) + table = ObservationTable(['a', 'b'], sul, 'moore') + self.assertEqual(table.E, [tuple()]) + + def test_mealy_initial_e_set_is_whole_alphabet(self): + mm = two_state_mealy() + sul = AutomatonSUL(mm) + table = ObservationTable(['a', 'b'], sul, 'mealy') + self.assertEqual(table.E, [('a',), ('b',)]) + + def test_invalid_automaton_type_raises(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + with self.assertRaises(AssertionError): + ObservationTable(['a', 'b'], sul, 'not_a_type') + + +class TestSDotA(unittest.TestCase): + def test_s_dot_a_excludes_elements_already_in_s(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + self.assertEqual(set(table.s_dot_a()), {('a',), ('b',)}) + + table.S.append(('a',)) + self.assertEqual(set(table.s_dot_a()), {('b',), ('a', 'a'), ('a', 'b')}) + + +class TestUpdateObsTable(unittest.TestCase): + def test_update_fills_row_for_empty_prefix(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + self.assertEqual(table.T[tuple()], (True,)) + self.assertEqual(table.T[('a',)], (False,)) + self.assertEqual(table.T[('b',)], (True,)) + + def test_update_is_idempotent_and_does_not_reissue_queries(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + queries_after_first = sul.num_queries + table.update_obs_table() + self.assertEqual(sul.num_queries, queries_after_first) + + def test_update_with_explicit_s_and_e_set(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.E.append(('b',)) + table.update_obs_table(s_set=[tuple()], e_set=[('b',)]) + self.assertEqual(len(table.T[tuple()]), 1) + self.assertEqual(table.T[tuple()], (True,)) + + def test_growing_e_set_requires_e_set_argument_for_new_column(self): + """ + update_obs_table() with no arguments re-derives update_S/update_E from S/E, and for a row + that is already partially filled it only (re)asks the *first* `len(E) - len(T[s])` columns + of E in order -- it does not know which specific column(s) are new. Callers therefore must + pass e_set= when the E set grows (as LStar.py always does); calling it + bare after extending self.E does not compute the new column correctly. + """ + dfa = get_Angluin_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + table.E.append(('a',)) + table.update_obs_table() + # The new column was (incorrectly, from the bare call's perspective) filled by re-querying + # the *old* suffix again instead of the newly appended one. + self.assertEqual(table.T[('a',)], (False, False)) + + # Using the documented e_set argument computes the new column correctly instead. + table.T.clear() + table.update_obs_table() + table.update_obs_table(e_set=[('a',)]) + self.assertEqual(table.T[('a',)], (False, True)) + + +class TestGetRowsToClose(unittest.TestCase): + def test_no_rows_to_close_returns_none_when_table_closed(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + table.S.append(('a',)) + table.S.append(('a', 'a')) + table.update_obs_table() + self.assertIsNone(table.get_rows_to_close('longest_first')) + + def test_shortest_first_returns_the_distinguishing_row(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + rows = table.get_rows_to_close('shortest_first') + self.assertEqual(rows, [('a',)]) + + def test_single_returns_only_one_row(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + rows = table.get_rows_to_close('single') + self.assertEqual(rows, [('a',)]) + + def _angluin_table_with_two_rows_to_close(self): + dfa = get_Angluin_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + table.E.append(('a',)) + table.update_obs_table(e_set=[('a',)]) + return table + + def test_longest_first_and_shortest_first_find_both_distinguishing_rows(self): + table = self._angluin_table_with_two_rows_to_close() + self.assertEqual(set(table.get_rows_to_close('longest_first')), {('a',), ('b',)}) + self.assertEqual(set(table.get_rows_to_close('shortest_first')), {('a',), ('b',)}) + + def test_single_longest_returns_a_single_row(self): + table = self._angluin_table_with_two_rows_to_close() + rows = table.get_rows_to_close('single_longest') + self.assertEqual(len(rows), 1) + + def test_longest_first_orders_rows_by_decreasing_length(self): + # Directly populate S/T (rather than driving this through a SUL) to get full control over + # row values and prefix lengths, since get_rows_to_close only ever looks at S, A and T. + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.S = [tuple(), ('a',), ('a', 'b')] + table.T[tuple()] = (0,) + table.T[('a',)] = (1,) + table.T[('a', 'b')] = (2,) + table.T[('b',)] = (3,) + table.T[('a', 'a')] = (4,) + table.T[('a', 'b', 'a')] = (5,) + table.T[('a', 'b', 'b')] = (6,) + + rows = table.get_rows_to_close('longest_first') + self.assertEqual(set(rows), {('b',), ('a', 'a'), ('a', 'b', 'a'), ('a', 'b', 'b')}) + lengths = [len(r) for r in rows] + self.assertEqual(lengths, sorted(lengths, reverse=True)) + self.assertEqual(len(rows[0]), 3) + + +class TestGetCausesOfInconsistency(unittest.TestCase): + def test_consistent_table_returns_none(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + self.assertIsNone(table.get_causes_of_inconsistency()) + + def test_inconsistency_detected_and_cause_returned(self): + dfa = get_Angluin_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + table.E.append(('a',)) + table.update_obs_table(e_set=[('a',)]) + table.S.append(('a',)) + table.S.append(('b',)) + table.update_obs_table() + + # ('b', 'a') has the same row, (False, False), as ('b',) under the current E, but the two + # prefixes are not actually equivalent (they reach different DFA states); adding the + # duplicate-row prefix to S manually is what a cex-processing step would eventually force, + # and it makes the table inconsistent since their 'b'-extensions differ. + table.S.append(('b', 'a')) + table.update_obs_table() + + cause = table.get_causes_of_inconsistency() + self.assertEqual(cause, [('b',)]) + + +class TestGenHypothesis(unittest.TestCase): + def test_gen_hypothesis_dfa_matches_original(self): + dfa = get_Angluin_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + table.E.append(('a',)) + table.update_obs_table(e_set=[('a',)]) + table.S.append(('a',)) + table.S.append(('b',)) + table.update_obs_table() + table.E.append(('b',)) + table.update_obs_table(e_set=[('b',)]) + table.S.append(('a', 'b')) + table.update_obs_table() + + hyp = table.gen_hypothesis() + self.assertEqual(len(hyp.states), 4) + self.assertTrue(bisimilar(dfa, hyp)) + + def test_gen_hypothesis_mealy_output_fun_matches(self): + mm = two_state_mealy() + sul = AutomatonSUL(mm) + table = ObservationTable(['a', 'b'], sul, 'mealy') + table.update_obs_table() + table.S.append(('a',)) + table.update_obs_table() + + hyp = table.gen_hypothesis() + self.assertEqual(len(hyp.states), 2) + for word in [('a',), ('b',), ('a', 'a'), ('b', 'a', 'b')]: + expected = mm.execute_sequence(mm.initial_state, word) + actual = hyp.execute_sequence(hyp.initial_state, word) + self.assertEqual(expected, actual) + + def test_gen_hypothesis_no_cex_processing_deduplicates_rows(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + # ('a',) is genuinely new, but ('b',) is a self-loop back to q0, so its row is identical to + # the empty prefix's row; the table is still closed since ('a',) closes the only new row. + table.S.append(('a',)) + table.S.append(('b',)) + table.update_obs_table() + self.assertIsNone(table.get_rows_to_close()) + + hyp = table.gen_hypothesis(no_cex_processing_used=True) + # () and ('b',) collapse into a single representative state, ('a',) remains distinct. + self.assertEqual(len(hyp.states), 2) + + +class TestGetRowRepresentatives(unittest.TestCase): + def test_representatives_prefer_shortest_prefix_per_row(self): + dfa = three_state_dfa() + sul = AutomatonSUL(dfa) + table = ObservationTable(['a', 'b'], sul, 'dfa') + table.update_obs_table() + table.S.append(('b',)) + table.S.append(('b', 'b')) + table.update_obs_table() + + representatives = table._get_row_representatives() + # (), ('b',) and ('b', 'b') all share the same row (all lead to accepting q0), + # so only the shortest, (), should be kept as representative. + self.assertEqual(representatives, [tuple()]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_observation_tree.py b/tests/learning_algs/deterministic/test_observation_tree.py new file mode 100644 index 00000000000..49db5263ce0 --- /dev/null +++ b/tests/learning_algs/deterministic/test_observation_tree.py @@ -0,0 +1,172 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs.deterministic.ObservationTree import MealyNode, MooreNode, ObservationTree +from aalpy.utils import get_Angluin_dfa + + +def dfa_tree(extension_rule=None, separation_rule='SepSeq'): + sul = AutomatonSUL(get_Angluin_dfa()) + return ObservationTree(['a', 'b'], sul, 'dfa', extension_rule, separation_rule) + + +def mealy_tree(extension_rule=None, separation_rule='SepSeq'): + sul = AutomatonSUL(get_Angluin_dfa()) + return ObservationTree(['a', 'b'], sul, 'mealy', extension_rule, separation_rule) + + +class TestObservationTreeInit(unittest.TestCase): + def test_dfa_root_output_is_queried_eagerly(self): + tree = dfa_tree() + self.assertIsInstance(tree.root, MooreNode) + self.assertTrue(tree.root.output) + + def test_mealy_root_has_no_output_attribute_use(self): + tree = mealy_tree() + self.assertIsInstance(tree.root, MealyNode) + + def test_basis_starts_with_only_the_root(self): + tree = dfa_tree() + self.assertEqual(tree.basis, [tree.root]) + self.assertEqual(tree.frontier_to_basis_dict, {}) + + +class TestInsertAndGetObservation(unittest.TestCase): + def test_insert_and_retrieve_matches(self): + tree = dfa_tree() + tree.insert_observation(['a', 'b'], [False, True]) + self.assertEqual(tree.get_observation(['a', 'b']), [False, True]) + self.assertEqual(tree.get_observation(['a']), [False]) + + def test_mismatched_lengths_raise(self): + tree = dfa_tree() + with self.assertRaises(ValueError): + tree.insert_observation(['a', 'b'], [False]) + + def test_unseen_sequence_returns_none(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + self.assertIsNone(tree.get_observation(['b'])) + self.assertIsNone(tree.get_observation(['a', 'b'])) + + def test_mealy_reinserting_same_input_with_conflicting_output_raises(self): + tree = mealy_tree() + tree.insert_observation(['a'], [1]) + with self.assertRaises(Exception): + tree.insert_observation(['a'], [2]) + + def test_mealy_reinserting_same_input_output_is_idempotent(self): + tree = mealy_tree() + tree.insert_observation(['a'], [1]) + tree.insert_observation(['a'], [1]) + self.assertEqual(tree.get_observation(['a']), [1]) + + +class TestGetOutputsMatchesGetObservation(unittest.TestCase): + def test_get_outputs_from_root_agrees_with_get_observation(self): + tree = dfa_tree() + tree.insert_observation(['a', 'b'], [False, True]) + self.assertEqual(tree.get_outputs(tree.root, ['a', 'b']), tree.get_observation(['a', 'b'])) + + def test_get_outputs_from_non_root_basis_state(self): + tree = dfa_tree() + tree.insert_observation(['a', 'b'], [False, True]) + node_a = tree.get_successor(['a']) + self.assertEqual(tree.get_outputs(node_a, ['b']), [True]) + + def test_get_outputs_mealy_from_root_agrees_with_get_observation(self): + tree = mealy_tree() + tree.insert_observation(['a', 'b'], [1, 2]) + self.assertEqual(tree.get_outputs(tree.root, ['a', 'b']), tree.get_observation(['a', 'b'])) + + +class TestTraversalHelpers(unittest.TestCase): + def test_get_successor_and_transfer_and_access_sequence(self): + tree = dfa_tree() + tree.insert_observation(['a', 'b'], [False, True]) + node_ab = tree.get_successor(['a', 'b']) + self.assertIsNotNone(node_ab) + self.assertEqual(tree.get_transfer_sequence(tree.root, node_ab), ['a', 'b']) + self.assertEqual(tree.get_access_sequence(node_ab), ('a', 'b')) + + def test_get_successor_unknown_path_returns_none(self): + tree = dfa_tree() + self.assertIsNone(tree.get_successor(['a'])) + + def test_get_transfer_sequence_returns_none_for_unrelated_node(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.insert_observation(['b'], [False]) + node_a = tree.get_successor(['a']) + node_b = tree.get_successor(['b']) + # node_a is not an ancestor of node_b, so there is no transfer sequence between them. + self.assertIsNone(tree.get_transfer_sequence(node_a, node_b)) + + def test_get_size_counts_all_created_nodes(self): + tree = dfa_tree() + size_before = tree.get_size() + tree.insert_observation(['a', 'b'], [False, True]) + self.assertEqual(tree.get_size(), size_before + 2) + + +class TestFrontierAndBasisPromotion(unittest.TestCase): + def test_new_frontier_state_is_promoted_to_basis_after_two_rounds(self): + tree = dfa_tree() + # ('a',) is apart from the root (True vs False), so it has zero basis candidates once + # check_frontier_consistency notices it; promotion of an isolated frontier state only + # happens on the *next* round (promote_frontier_state runs before the dict is (re)populated). + tree.insert_observation(['a'], [False]) + + tree.update_frontier_and_basis() + self.assertEqual(len(tree.basis), 1) + self.assertEqual(len(tree.frontier_to_basis_dict), 1) + sole_frontier_candidates = next(iter(tree.frontier_to_basis_dict.values())) + self.assertEqual(sole_frontier_candidates, []) + + tree.update_frontier_and_basis() + self.assertEqual(len(tree.basis), 2) + self.assertEqual(tree.frontier_to_basis_dict, {}) + + def test_find_basis_candidates_excludes_apart_states(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + node_a = tree.get_successor(['a']) + # root.output is True, node_a.output is False: they are apart, so root cannot be a candidate. + candidates = tree.find_basis_candidates(node_a) + self.assertEqual(candidates, set()) + + def test_make_basis_complete_explores_missing_alphabet_symbols(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.update_frontier_and_basis() + tree.update_frontier_and_basis() + self.assertEqual(len(tree.basis), 2) + + tree.make_basis_complete() + for basis_state in tree.basis: + for inp in tree.alphabet: + self.assertIsNotNone(basis_state.get_successor(inp)) + + +class TestConstructHypothesis(unittest.TestCase): + def test_construct_hypothesis_reflects_current_tree_knowledge(self): + tree = dfa_tree() + tree.insert_observation(['a'], [False]) + tree.update_frontier_and_basis() + tree.update_frontier_and_basis() + tree.make_basis_complete() + tree.make_frontiers_identified() + self.assertTrue(tree.is_observation_tree_adequate()) + + hyp = tree.construct_hypothesis() + self.assertEqual(len(hyp.states), 2) + + ground_truth = get_Angluin_dfa() + for word in [tuple(), ('a',), ('b',), ('a', 'a'), ('a', 'b')]: + hyp_out = hyp.execute_sequence(hyp.initial_state, list(word)) + gt_out = ground_truth.execute_sequence(ground_truth.initial_state, list(word)) + self.assertEqual(hyp_out, gt_out) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic/test_random_learning_runs.py b/tests/learning_algs/deterministic/test_random_learning_runs.py new file mode 100644 index 00000000000..fcb1c333601 --- /dev/null +++ b/tests/learning_algs/deterministic/test_random_learning_runs.py @@ -0,0 +1,59 @@ +import random + +import pytest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs import run_KV, run_Lsharp, run_Lstar +from aalpy.oracles import RandomWMethodEqOracle +from aalpy.utils import generate_random_deterministic_automata +from aalpy.utils.ModelChecking import bisimilar + +# Trimmed down from the original (50 seeds x 16 sizes x 3 types x 3 algorithms = 7200 cases) sweep: +# a handful of seeds and small state counts is enough to catch a regression in any of the three +# algorithms across automaton types, while keeping this whole module comfortably under a second. +SEEDS = list(range(6)) +MODEL_SIZES = [ + (2, 2, 2), + (3, 2, 3), + (4, 3, 2), + (6, 2, 3), +] + +TEST_CASES = [ + pytest.param( + learning_alg, + automaton_type, + seed_val, + num_states, + input_size, + output_size, + id=f"{learning_alg.__name__}-{automaton_type}-states={num_states}-seed={seed_val}", + ) + for num_states, input_size, output_size in MODEL_SIZES + for seed_val in SEEDS + for automaton_type in ['dfa', 'moore', 'mealy'] + for learning_alg in [run_Lstar, run_Lsharp, run_KV] +] + + +@pytest.mark.parametrize("learning_alg,automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES) +def test_learning_algs_on_small_random_automata(learning_alg, automaton_type, seed_val, num_states, input_size, + output_size): + random.seed(seed_val) + + model = generate_random_deterministic_automata( + automaton_type, + num_states=num_states, + input_alphabet_size=input_size, + output_alphabet_size=output_size, + ) + + sul = AutomatonSUL(model) + input_alphabet = model.get_input_alphabet() + + eq_oracle = RandomWMethodEqOracle(input_alphabet, sul, walks_per_state=num_states * 10, walk_len=15) + + learned_model = learning_alg(input_alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) diff --git a/tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py b/tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py new file mode 100644 index 00000000000..30f63d9cd2a --- /dev/null +++ b/tests/learning_algs/deterministic/test_random_learning_runs_exhaustive.py @@ -0,0 +1,76 @@ +import random + +import pytest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs import run_KV, run_Lsharp, run_Lstar +from aalpy.oracles import RandomWMethodEqOracle +from aalpy.utils import generate_random_deterministic_automata +from aalpy.utils.ModelChecking import bisimilar + +pytestmark = pytest.mark.exhaustive + +# Full sweep this repo used to run at the root (tests/test_deterministic_learning_runs.py) before it was +# trimmed down to a fast default (see the sibling test_random_learning_runs.py): 50 seeds x 16 sizes x 3 +# automaton types x 3 algorithms = 7200 cases. Wide sweeps like this are what caught e.g. the +# linear_cex_processing direction bug and the ObservationTree off-by-one during this test suite's build-out +# - a handful of seeds can get lucky and never hit the input sequence that triggers a given bug. +SEEDS = list(range(50)) +MODEL_SIZES = [ + (2, 2, 2), + (2, 2, 3), + (3, 2, 2), + (3, 2, 3), + (3, 3, 2), + (4, 2, 3), + (4, 3, 2), + (5, 3, 3), + (6, 2, 3), + (6, 3, 2), + (10, 2, 3), + (10, 2, 4), + (10, 2, 2), + (10, 2, 3), + (20, 5, 5), + (30, 3, 4), +] + +TEST_CASES = [ + pytest.param( + learning_alg, + automaton_type, + seed_val, + num_states, + input_size, + output_size, + id=f"{learning_alg.__name__}-{automaton_type}-states={num_states}-seed={seed_val}", + ) + for num_states, input_size, output_size in MODEL_SIZES + for seed_val in SEEDS + for automaton_type in ['dfa', 'moore', 'mealy'] + for learning_alg in [run_Lstar, run_Lsharp, run_KV] +] + + +@pytest.mark.parametrize("learning_alg,automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES) +@pytest.mark.timeout(5) +def test_learning_algs_on_small_random_automata_exhaustive(learning_alg, automaton_type, seed_val, num_states, + input_size, output_size): + random.seed(seed_val) + + model = generate_random_deterministic_automata( + automaton_type, + num_states=num_states, + input_alphabet_size=input_size, + output_alphabet_size=output_size, + ) + + sul = AutomatonSUL(model) + input_alphabet = model.get_input_alphabet() + + eq_oracle = RandomWMethodEqOracle(input_alphabet, sul, walks_per_state=num_states * 10, walk_len=15) + + learned_model = learning_alg(input_alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) diff --git a/tests/learning_algs/deterministic_passive/test_active_rpni.py b/tests/learning_algs/deterministic_passive/test_active_rpni.py new file mode 100644 index 00000000000..b7448bc19ce --- /dev/null +++ b/tests/learning_algs/deterministic_passive/test_active_rpni.py @@ -0,0 +1,113 @@ +import random +import unittest + +from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState +from aalpy.learning_algs.deterministic_passive.active_RPNI import ( + RandomWordSampler, RpniActiveSampler, run_active_RPNI, +) +from aalpy.SULs import AutomatonSUL +from aalpy.utils.ModelChecking import bisimilar + + +def even_a_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +def three_state_moore(): + q0 = MooreState('q0', 0) + q1 = MooreState('q1', 1) + q2 = MooreState('q2', 2) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q2, 'b': q0} + q2.transitions = {'a': q2, 'b': q1} + return MooreMachine(q0, [q0, q1, q2]) + + +class ConstantSampler(RpniActiveSampler): + """Deterministic sampler used to exercise the RpniActiveSampler contract with fixed words.""" + + def __init__(self, words): + self.words = words + + def sample(self, sul, model): + samples = [] + for word in self.words: + outputs = sul.query(word) + samples.append((word, outputs[-1])) + return samples + + +class NoOpSampler(RpniActiveSampler): + """Sampler that never queries the SUL, used where only the initial data matters.""" + + def sample(self, sul, model): + return [] + + +def bootstrap_data(ground_truth, sul, initial_output): + """ + Builds a minimal initial data set that includes one transition per alphabet symbol, so that the very + first hypothesis learned by run_active_RPNI already has an input alphabet for RandomWordSampler to + sample from (it derives its alphabet from the transitions of the current hypothesis). + """ + data = [((), initial_output)] + for letter in ground_truth.get_input_alphabet(): + outputs = sul.query((letter,)) + data.append(((letter,), outputs[-1])) + return data + + +class TestRunActiveRpni(unittest.TestCase): + def test_learns_correct_dfa_with_random_word_sampler(self): + random.seed(1) + ground_truth = even_a_dfa() + sul = AutomatonSUL(ground_truth) + sampler = RandomWordSampler(num_walks=20, min_walk_len=1, max_walk_len=5) + data = bootstrap_data(ground_truth, sul, True) + + learned = run_active_RPNI(data=data, sul=sul, sampler=sampler, n_iter=5, + automaton_type='dfa', print_info=False) + + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_learns_correct_moore_machine_with_enough_iterations(self): + random.seed(2) + ground_truth = three_state_moore() + sul = AutomatonSUL(ground_truth) + sampler = RandomWordSampler(num_walks=30, min_walk_len=1, max_walk_len=6) + data = bootstrap_data(ground_truth, sul, 0) + + learned = run_active_RPNI(data=data, sul=sul, sampler=sampler, n_iter=6, + automaton_type='moore', print_info=False) + + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_data_grows_with_each_iteration(self): + ground_truth = even_a_dfa() + sul = AutomatonSUL(ground_truth) + sampler = ConstantSampler([('a',), ('b', 'a')]) + data = [((), True)] + + run_active_RPNI(data=data, sul=sul, sampler=sampler, n_iter=3, + automaton_type='dfa', print_info=False) + + # 3 iterations each adding 2 fixed samples on top of the initial one + self.assertEqual(len(data), 1 + 3 * 2) + + def test_returns_none_when_data_is_inconsistent(self): + ground_truth = even_a_dfa() + sul = AutomatonSUL(ground_truth) + # conflicting labels for the same (empty) sequence makes the data inconsistent from the start + data = [((), True), ((), False)] + + learned = run_active_RPNI(data=data, sul=sul, sampler=NoOpSampler(), n_iter=2, + automaton_type='dfa', print_info=False) + self.assertIsNone(learned) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic_passive/test_classic_rpni.py b/tests/learning_algs/deterministic_passive/test_classic_rpni.py new file mode 100644 index 00000000000..c9d91d215b8 --- /dev/null +++ b/tests/learning_algs/deterministic_passive/test_classic_rpni.py @@ -0,0 +1,85 @@ +import unittest +from itertools import product + +from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState +from aalpy.learning_algs.deterministic_passive.ClassicRPNI import ClassicRPNI +from aalpy.utils.ModelChecking import bisimilar + + +def even_a_dfa(): + """2-state DFA accepting words with an even number of 'a's over {'a', 'b'}.""" + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +def full_sample(automaton, depth=3): + data = [] + if isinstance(automaton, (Dfa, MooreMachine)): + data.append(((), automaton.initial_state.output)) + alphabet = automaton.get_input_alphabet() + for level in range(1, depth + 1): + for seq in product(alphabet, repeat=level): + automaton.reset_to_initial() + outputs = automaton.execute_sequence(automaton.initial_state, seq) + data.append((seq, outputs[-1])) + return data + + +class TestClassicRpniDfa(unittest.TestCase): + def test_learns_minimal_dfa_from_complete_sample(self): + ground_truth = even_a_dfa() + data = full_sample(ground_truth, depth=3) + learned = ClassicRPNI(data, 'dfa', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_merges_states_with_identical_output_and_behavior(self): + # 'a', 'b', and 'ab'/'ba' should all merge into a single accepting state + # since the language only distinguishes the empty word. + data = [((), True), (('a',), True), (('b',), True), (('a', 'b'), True), (('b', 'a'), True)] + learned = ClassicRPNI(data, 'dfa', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 1) + + +class TestClassicRpniMoore(unittest.TestCase): + def test_learns_from_complete_sample(self): + q0 = MooreState('q0', 0) + q1 = MooreState('q1', 1) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + ground_truth = MooreMachine(q0, [q0, q1]) + + data = full_sample(ground_truth, depth=3) + learned = ClassicRPNI(data, 'moore', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + +class TestClassicRpniMealy(unittest.TestCase): + def test_learns_from_complete_sample(self): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'x', 'b': 'y'} + q1.transitions = {'a': q0, 'b': q1} + q1.output_fun = {'a': 'y', 'b': 'x'} + ground_truth = MealyMachine(q0, [q0, q1]) + + data = full_sample(ground_truth, depth=3) + learned = ClassicRPNI(data, 'mealy', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + +class TestClassicRpniNonDeterministicData(unittest.TestCase): + def test_root_node_none_for_conflicting_data(self): + data = [((), True), ((), False)] + rpni = ClassicRPNI(data, 'dfa', print_info=False) + self.assertIsNone(rpni.root_node) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic_passive/test_gsm_rpni.py b/tests/learning_algs/deterministic_passive/test_gsm_rpni.py new file mode 100644 index 00000000000..28514a471af --- /dev/null +++ b/tests/learning_algs/deterministic_passive/test_gsm_rpni.py @@ -0,0 +1,116 @@ +import unittest +from itertools import product + +from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState +from aalpy.learning_algs.deterministic_passive.GsmRPNI import GsmRPNI +from aalpy.learning_algs.deterministic_passive.ClassicRPNI import ClassicRPNI +from aalpy.utils.ModelChecking import bisimilar + + +def even_a_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +def full_sample(automaton, depth=3): + data = [] + if isinstance(automaton, (Dfa, MooreMachine)): + data.append(((), automaton.initial_state.output)) + alphabet = automaton.get_input_alphabet() + for level in range(1, depth + 1): + for seq in product(alphabet, repeat=level): + automaton.reset_to_initial() + outputs = automaton.execute_sequence(automaton.initial_state, seq) + data.append((seq, outputs[-1])) + return data + + +class TestGsmRpniDfa(unittest.TestCase): + def test_learns_minimal_dfa_from_complete_sample(self): + ground_truth = even_a_dfa() + data = full_sample(ground_truth, depth=3) + learned = GsmRPNI(data, 'dfa', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_merges_states_with_identical_output_and_behavior(self): + data = [((), True), (('a',), True), (('b',), True), (('a', 'b'), True), (('b', 'a'), True)] + learned = GsmRPNI(data, 'dfa', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 1) + + def test_dfa_internally_uses_moore_representation(self): + gsm = GsmRPNI([((), True)], 'dfa', print_info=False) + self.assertEqual(gsm.automaton_type, 'moore') + self.assertEqual(gsm.final_automaton_type, 'dfa') + + +class TestGsmRpniMoore(unittest.TestCase): + def test_learns_from_complete_sample(self): + q0 = MooreState('q0', 0) + q1 = MooreState('q1', 1) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + ground_truth = MooreMachine(q0, [q0, q1]) + + data = full_sample(ground_truth, depth=3) + learned = GsmRPNI(data, 'moore', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + +class TestGsmRpniMealy(unittest.TestCase): + def test_learns_from_complete_sample(self): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'x', 'b': 'y'} + q1.transitions = {'a': q0, 'b': q1} + q1.output_fun = {'a': 'y', 'b': 'x'} + ground_truth = MealyMachine(q0, [q0, q1]) + + data = full_sample(ground_truth, depth=3) + learned = GsmRPNI(data, 'mealy', print_info=False).run_rpni() + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + +class TestGsmRpniNonDeterministicData(unittest.TestCase): + def test_root_node_none_for_conflicting_data(self): + data = [((), True), ((), False)] + rpni = GsmRPNI(data, 'dfa', print_info=False) + self.assertIsNone(rpni.root_node) + + +class TestGsmAndClassicAgree(unittest.TestCase): + """ + Both underlying RPNI strategies should produce bisimilar (typically identical) results for the + same consistent, complete sample, even though they compute compatibility differently + (partition-based vs copy-and-fold). + """ + + def test_agree_on_dfa_sample(self): + ground_truth = even_a_dfa() + data = full_sample(ground_truth, depth=4) + gsm_learned = GsmRPNI(list(data), 'dfa', print_info=False).run_rpni() + classic_learned = ClassicRPNI(list(data), 'dfa', print_info=False).run_rpni() + self.assertTrue(bisimilar(gsm_learned, classic_learned)) + + def test_agree_on_mealy_sample(self): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'x', 'b': 'y'} + q1.transitions = {'a': q0, 'b': q1} + q1.output_fun = {'a': 'y', 'b': 'x'} + ground_truth = MealyMachine(q0, [q0, q1]) + data = full_sample(ground_truth, depth=4) + gsm_learned = GsmRPNI(list(data), 'mealy', print_info=False).run_rpni() + classic_learned = ClassicRPNI(list(data), 'mealy', print_info=False).run_rpni() + self.assertTrue(bisimilar(gsm_learned, classic_learned)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic_passive/test_papni.py b/tests/learning_algs/deterministic_passive/test_papni.py new file mode 100644 index 00000000000..1034c6cd7c3 --- /dev/null +++ b/tests/learning_algs/deterministic_passive/test_papni.py @@ -0,0 +1,75 @@ +import random +import unittest +from itertools import product + +from aalpy.automata.Vpa import Vpa, VpaAlphabet, VpaState, VpaTransition +from aalpy.learning_algs.deterministic_passive.PAPNI import run_PAPNI +from aalpy.utils import is_balanced + + +def balanced_parens_vpa_with_internal(): + """1-state VPA accepting balanced '(' / ')' words, with an 'i' internal symbol self-loop.""" + q0 = VpaState('q0', is_accepting=True) + q0.transitions['('].append(VpaTransition(q0, q0, '(', 'push', '(')) + q0.transitions[')'].append(VpaTransition(q0, q0, ')', 'pop', '(')) + q0.transitions['i'].append(VpaTransition(q0, q0, 'i', None, None)) + vpa = Vpa(q0, [q0]) + alphabet = VpaAlphabet(internal_alphabet=['i'], call_alphabet=['('], return_alphabet=[')']) + return vpa, alphabet + + +def generate_data(vpa, alphabet, depth=4): + merged_alphabet = alphabet.get_merged_alphabet() + data = [] + for level in range(0, depth + 1): + for seq in product(merged_alphabet, repeat=level): + vpa.reset_to_initial() + outputs = vpa.execute_sequence(vpa.initial_state, seq, []) + label = outputs[-1] if outputs else vpa.initial_state.is_accepting + data.append((seq, label)) + return data + + +class TestRunPapni(unittest.TestCase): + def check_learned_model_matches_ground_truth(self, algorithm): + vpa, alphabet = balanced_parens_vpa_with_internal() + data = generate_data(vpa, alphabet, depth=4) + + learned_model = run_PAPNI(data, alphabet, algorithm=algorithm, print_info=False) + self.assertIsNotNone(learned_model) + + random.seed(42) + merged_alphabet = alphabet.get_merged_alphabet() + for _ in range(200): + length = random.randint(0, 6) + seq = tuple(random.choice(merged_alphabet) for _ in range(length)) + + expected = is_balanced(list(seq), alphabet) + + learned_model.reset_to_initial() + outputs = learned_model.execute_sequence(learned_model.initial_state, seq, []) + actual = outputs[-1] if outputs else learned_model.initial_state.is_accepting + + self.assertEqual(actual, expected, f'mismatch on sequence {seq} with algorithm {algorithm}') + + def test_edsm_algorithm(self): + self.check_learned_model_matches_ground_truth('edsm') + + def test_gsm_algorithm(self): + self.check_learned_model_matches_ground_truth('gsm') + + def test_classic_algorithm(self): + self.check_learned_model_matches_ground_truth('classic') + + def test_unbalanced_sequences_are_filtered_out_of_data(self): + vpa, alphabet = balanced_parens_vpa_with_internal() + data = [((')',), False), (('(',), False), ((), True)] + # even though the label for the unbalanced sequences is wrong (False for both, which is actually + # correct here), run_PAPNI should still work since unbalanced sequences are dropped before learning. + learned_model = run_PAPNI(data, alphabet, algorithm='classic', print_info=False) + self.assertIsNotNone(learned_model) + self.assertTrue(learned_model.initial_state.is_accepting) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py b/tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py new file mode 100644 index 00000000000..b67bf6ade4c --- /dev/null +++ b/tests/learning_algs/deterministic_passive/test_rpni_helper_functions.py @@ -0,0 +1,198 @@ +import unittest + +from aalpy.learning_algs.deterministic_passive.rpni_helper_functions import ( + RpniNode, check_sequence, createPTA, extract_unique_sequences, to_automaton, +) + + +class TestRpniNode(unittest.TestCase): + def test_default_moore_output_is_none(self): + node = RpniNode(automaton_type='moore') + self.assertIsNone(node.output) + self.assertEqual(node.children, {}) + + def test_default_mealy_output_is_empty_dict(self): + node = RpniNode(automaton_type='mealy') + self.assertEqual(node.output, {}) + + def test_shallow_copy_shares_child_nodes_but_new_children_dict(self): + child = RpniNode(automaton_type='moore') + parent = RpniNode(output=True, children={'a': child}, automaton_type='moore') + copy = parent.shallow_copy() + self.assertIsNot(copy.children, parent.children) + self.assertIs(copy.children['a'], child) + + def test_shallow_copy_mealy_output_is_independent_dict(self): + parent = RpniNode(output={'a': 'x'}, automaton_type='mealy') + copy = parent.shallow_copy() + copy.output['b'] = 'y' + self.assertNotIn('b', parent.output) + + def test_deep_copy_duplicates_subtree(self): + child = RpniNode(automaton_type='moore') + child.prefix = ('a',) + parent = RpniNode(output=True, children={'a': child}, automaton_type='moore') + parent.prefix = () + copy = parent.copy() + self.assertIsNot(copy.children['a'], child) + self.assertEqual(copy.children['a'].prefix, ('a',)) + + def test_lt_compares_prefix_length(self): + short = RpniNode(automaton_type='moore') + short.prefix = ('a',) + long = RpniNode(automaton_type='moore') + long.prefix = ('a', 'b') + self.assertTrue(short < long) + self.assertFalse(long < short) + + def test_eq_compares_prefix_not_identity(self): + n1 = RpniNode(automaton_type='moore') + n1.prefix = ('a',) + n2 = RpniNode(automaton_type='moore') + n2.prefix = ('a',) + self.assertEqual(n1, n2) + self.assertIsNot(n1, n2) + + def test_compatible_outputs_moore_none_matches_anything(self): + n1 = RpniNode(output=None, automaton_type='moore') + n2 = RpniNode(output=True, automaton_type='moore') + self.assertTrue(n1.compatible_outputs(n2)) + self.assertTrue(n2.compatible_outputs(n1)) + + def test_compatible_outputs_moore_conflicting_values(self): + n1 = RpniNode(output=True, automaton_type='moore') + n2 = RpniNode(output=False, automaton_type='moore') + self.assertFalse(n1.compatible_outputs(n2)) + + def test_compatible_outputs_mealy_disjoint_inputs_are_compatible(self): + n1 = RpniNode(output={'a': 'x'}, automaton_type='mealy') + n2 = RpniNode(output={'b': 'y'}, automaton_type='mealy') + self.assertTrue(n1.compatible_outputs(n2)) + + def test_compatible_outputs_mealy_conflicting_shared_input(self): + n1 = RpniNode(output={'a': 'x'}, automaton_type='mealy') + n2 = RpniNode(output={'a': 'z'}, automaton_type='mealy') + self.assertFalse(n1.compatible_outputs(n2)) + + def test_get_child_by_prefix_follows_transitions(self): + leaf = RpniNode(automaton_type='moore') + mid = RpniNode(children={'b': leaf}, automaton_type='moore') + root = RpniNode(children={'a': mid}, automaton_type='moore') + self.assertIs(root.get_child_by_prefix(('a', 'b')), leaf) + + def test_get_child_by_prefix_empty_returns_self(self): + root = RpniNode(automaton_type='moore') + self.assertIs(root.get_child_by_prefix(()), root) + + +class TestCreatePTA(unittest.TestCase): + def test_moore_builds_tree_with_correct_outputs(self): + data = [((), True), (('a',), False), (('a', 'a'), True)] + root = createPTA(data, 'moore') + self.assertEqual(root.output, True) + self.assertEqual(root.children['a'].output, False) + self.assertEqual(root.children['a'].children['a'].output, True) + + def test_moore_conflicting_labels_returns_none(self): + data = [((), True), ((), False)] + self.assertIsNone(createPTA(data, 'moore')) + + def test_moore_conflicting_labels_at_leaf_returns_none(self): + data = [(('a',), True), (('a',), False)] + self.assertIsNone(createPTA(data, 'moore')) + + def test_mealy_builds_tree_with_transition_outputs(self): + data = [(('a',), 'x'), (('a', 'b'), 'y')] + root = createPTA(data, 'mealy') + self.assertEqual(root.output, {'a': 'x'}) + self.assertEqual(root.children['a'].output, {'b': 'y'}) + + def test_mealy_conflicting_labels_returns_none(self): + data = [(('a',), 'x'), (('a',), 'z')] + self.assertIsNone(createPTA(data, 'mealy')) + + def test_prefixes_are_recorded_on_nodes(self): + data = [(('a', 'b'), True)] + root = createPTA(data, 'dfa') + self.assertEqual(root.prefix, ()) + self.assertEqual(root.children['a'].prefix, ('a',)) + self.assertEqual(root.children['a'].children['b'].prefix, ('a', 'b')) + + +class TestCheckSequence(unittest.TestCase): + def test_valid_moore_sequence_accepted(self): + data = [((), True), (('a',), False)] + root = createPTA(data, 'moore') + self.assertTrue(check_sequence(root, [True, ('a', False)], 'moore')) + + def test_invalid_moore_sequence_rejected(self): + data = [((), True), (('a',), False)] + root = createPTA(data, 'moore') + self.assertFalse(check_sequence(root, [True, ('a', True)], 'moore')) + + def test_none_output_in_test_sequence_is_ignored(self): + data = [((), True), (('a',), False)] + root = createPTA(data, 'moore') + self.assertTrue(check_sequence(root, [None, ('a', None)], 'moore')) + + def test_valid_mealy_sequence_accepted(self): + data = [(('a',), 'x'), (('a', 'b'), 'y')] + root = createPTA(data, 'mealy') + self.assertTrue(check_sequence(root, [('a', 'x'), ('b', 'y')], 'mealy')) + + def test_invalid_mealy_sequence_rejected(self): + data = [(('a',), 'x'), (('a', 'b'), 'y')] + root = createPTA(data, 'mealy') + self.assertFalse(check_sequence(root, [('a', 'z')], 'mealy')) + + +class TestExtractUniqueSequences(unittest.TestCase): + def test_extracts_one_sequence_per_leaf_moore(self): + data = [((), True), (('a',), False), (('b',), False)] + root = createPTA(data, 'moore') + sequences = extract_unique_sequences(root, 'moore') + self.assertEqual(len(sequences), 2) + self.assertIn([True, ('a', False)], sequences) + self.assertIn([True, ('b', False)], sequences) + + def test_extracted_sequences_round_trip_through_check_sequence(self): + data = [((), True), (('a', 'b'), False)] + root = createPTA(data, 'moore') + sequences = extract_unique_sequences(root, 'moore') + for seq in sequences: + self.assertTrue(check_sequence(root, seq, 'moore')) + + +class TestToAutomaton(unittest.TestCase): + def test_dfa_none_outputs_default_to_false(self): + data = [(('a',), True)] + root = createPTA(data, 'dfa') + # root has no explicit label -> output stays None until to_automaton fixes it up + red = [root, root.children['a']] + dfa = to_automaton(red, 'dfa') + self.assertFalse(dfa.initial_state.output) + self.assertTrue(dfa.initial_state.transitions['a'].output) + + def test_moore_conversion_preserves_topology(self): + data = [((), 1), (('a',), 2), (('a', 'a'), 3)] + root = createPTA(data, 'moore') + red = [root, root.children['a'], root.children['a'].children['a']] + moore = to_automaton(red, 'moore') + self.assertEqual(moore.initial_state.output, 1) + s1 = moore.initial_state.transitions['a'] + self.assertEqual(s1.output, 2) + s2 = s1.transitions['a'] + self.assertEqual(s2.output, 3) + + def test_mealy_conversion_sets_output_fun(self): + data = [(('a',), 'x'), (('a', 'b'), 'y')] + root = createPTA(data, 'mealy') + node_a = root.children['a'] + red = [root, node_a, node_a.children['b']] + mealy = to_automaton(red, 'mealy') + self.assertEqual(mealy.initial_state.output_fun['a'], 'x') + self.assertEqual(mealy.initial_state.transitions['a'].output_fun['b'], 'y') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/deterministic_passive/test_run_rpni_integration.py b/tests/learning_algs/deterministic_passive/test_run_rpni_integration.py new file mode 100644 index 00000000000..5a9670ae871 --- /dev/null +++ b/tests/learning_algs/deterministic_passive/test_run_rpni_integration.py @@ -0,0 +1,105 @@ +import unittest +from itertools import product +from pathlib import Path + +import aalpy +from aalpy.automata import Dfa, MooreMachine, MealyMachine +from aalpy.learning_algs import run_RPNI +from aalpy.utils import load_automaton_from_file +from aalpy.utils.ModelChecking import compare_automata + +DOT_MODELS_DIR = Path(__file__).resolve().parents[3] / 'DotModels' + +correct_automata = { + Dfa: load_automaton_from_file(str(DOT_MODELS_DIR / 'SimpleABC' / 'simple_abc_dfa.dot'), automaton_type='dfa'), + MooreMachine: load_automaton_from_file(str(DOT_MODELS_DIR / 'SimpleABC' / 'simple_abc_moore.dot'), + automaton_type='moore'), + MealyMachine: load_automaton_from_file(str(DOT_MODELS_DIR / 'SimpleABC' / 'simple_abc_mealy.dot'), + automaton_type='mealy'), +} + +automata_type = {Dfa: 'dfa', MooreMachine: 'moore', MealyMachine: 'mealy'} + + +def prove_equivalence(learned_automaton): + correct_automaton = correct_automata[learned_automaton.__class__] + + # only works if the correct automaton is already minimal + if len(learned_automaton.states) != len(correct_automaton.states): + return False + + return correct_automaton == learned_automaton + + +def generate_data(ground_truth, depth=5, step=1): + data = [] + if isinstance(ground_truth, (aalpy.automata.Dfa, aalpy.automata.MooreMachine)): + data.append(((), ground_truth.initial_state.output)) + + alphabet = ground_truth.get_input_alphabet() + for level in range(1, depth + 1, step): + for seq in product(alphabet, repeat=level): + ground_truth.reset_to_initial() + outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq) + data.append((seq, outputs[-1])) + + return data + + +class TestRunRpniAllConfigurations(unittest.TestCase): + """ + Ported from the legacy tests/test_deterministic_passive.py: learns each reference automaton (loaded + from DotModels/SimpleABC) via run_RPNI for both the 'gsm' and 'classic' algorithms, on complete and + input-incomplete sample data, and checks that the learned model equals the reference. + """ + + def test_all_configuration_combinations_complete_data(self): + algorithms = ['gsm', 'classic'] + + for automata_class, correct_automaton in correct_automata.items(): + data = generate_data(correct_automaton, depth=3) + for algorithm in algorithms: + learned_model = run_RPNI(data, automaton_type=automata_type[automata_class], + algorithm=algorithm, print_info=False) + + if not prove_equivalence(learned_model): + cex = compare_automata(learned_model, correct_automaton) + self.fail(f'{algorithm}/{automata_type[automata_class]}: learned model does not match ' + f'reference. Counterexamples: {cex}') + + def test_all_configuration_combinations_input_incomplete_data(self): + algorithms = ['gsm', 'classic'] + + for automata_class, correct_automaton in correct_automata.items(): + data = generate_data(correct_automaton, depth=3, step=2) + if automata_type[automata_class] == 'moore': + data += [(('a', 'a', 'a', 'a'), 1), (('b', 'b', 'b', 'b'), 2), (('c', 'c', 'c', 'c'), 3)] + for algorithm in algorithms: + learned_model = run_RPNI(data, automaton_type=automata_type[automata_class], + algorithm=algorithm, print_info=False) + + if not prove_equivalence(learned_model): + cex = compare_automata(learned_model, correct_automaton) + self.fail(f'{algorithm}/{automata_type[automata_class]}: learned model does not match ' + f'reference. Counterexamples: {cex}') + + def test_returns_none_for_nondeterministic_data(self): + data = [((), True), ((), False)] + learned_model = run_RPNI(data, automaton_type='dfa', algorithm='classic', print_info=False) + self.assertIsNone(learned_model) + + def test_input_completeness_sink_state(self): + data = [((), True), (('a',), False), (('b',), True)] + learned_model = run_RPNI(data, automaton_type='dfa', algorithm='gsm', + input_completeness='sink_state', print_info=False) + self.assertTrue(learned_model.is_input_complete()) + + def test_input_completeness_self_loop(self): + data = [((), True), (('a',), False), (('b',), True)] + learned_model = run_RPNI(data, automaton_type='dfa', algorithm='gsm', + input_completeness='self_loop', print_info=False) + self.assertTrue(learned_model.is_input_complete()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/general_passive/test_generalized_state_merging.py b/tests/learning_algs/general_passive/test_generalized_state_merging.py new file mode 100644 index 00000000000..2b1630fd090 --- /dev/null +++ b/tests/learning_algs/general_passive/test_generalized_state_merging.py @@ -0,0 +1,192 @@ +import random +import unittest +from itertools import product + +from aalpy.automata import Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState +from aalpy.learning_algs.general_passive.GeneralizedStateMerging import ( + GeneralizedStateMerging, Instrumentation, run_GSM, +) +from aalpy.learning_algs.general_passive.GsmNode import GsmNode, unknown_output +from aalpy.utils.HelperFunctions import dfa_from_moore +from aalpy.utils.ModelChecking import bisimilar + + +def alternating_moore(depth=4): + """2-state Moore machine over {'a', 'b'}: 'a' toggles state, 'b' stays put.""" + q0 = MooreState('q0', 0) + q1 = MooreState('q1', 1) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return MooreMachine(q0, [q0, q1]) + + +def parity_mealy(): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'x', 'b': 'y'} + q1.transitions = {'a': q0, 'b': q1} + q1.output_fun = {'a': 'y', 'b': 'x'} + return MealyMachine(q0, [q0, q1]) + + +def labeled_sequence_data(automaton, depth=3): + data = [] + alphabet = automaton.get_input_alphabet() + for level in range(0, depth + 1): + for seq in product(alphabet, repeat=level): + automaton.reset_to_initial() + if len(seq) == 0: + label = automaton.initial_state.output + else: + outputs = automaton.execute_sequence(automaton.initial_state, seq) + label = outputs[-1] + data.append((seq, label)) + return data + + +class RecordingInstrumentation(Instrumentation): + """Instrumentation that records each hook call, used to check that run_GSM actually invokes them.""" + + def __init__(self): + super().__init__() + self.reset_called = False + self.pta_done = False + self.promotions = [] + self.merges = [] + self.learning_done_called = False + + def reset(self, gsm): + self.reset_called = True + + def pta_construction_done(self, root): + self.pta_done = True + + def log_promote(self, node): + self.promotions.append(node) + + def log_merge(self, part): + self.merges.append(part) + + def learning_done(self, root): + self.learning_done_called = True + + +class TestRunGsmDeterministic(unittest.TestCase): + def test_learns_correct_moore_machine(self): + ground_truth = alternating_moore() + data = labeled_sequence_data(ground_truth, depth=3) + learned = run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences') + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_learns_correct_mealy_machine(self): + ground_truth = parity_mealy() + alphabet = ground_truth.get_input_alphabet() + traces = [] + for level in range(1, 4): + for seq in product(alphabet, repeat=level): + ground_truth.reset_to_initial() + outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq) + traces.append(list(zip(seq, outputs))) + learned = run_GSM(traces, output_behavior='mealy', transition_behavior='deterministic', + data_format='io_traces') + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_dfa_via_moore_and_dfa_from_moore_conversion(self): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + ground_truth = Dfa(q0, [q0, q1]) + + data = labeled_sequence_data(ground_truth, depth=3) + learned_moore = run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences') + learned_dfa = dfa_from_moore(learned_moore) + self.assertEqual(len(learned_dfa.states), 2) + self.assertTrue(bisimilar(learned_dfa, ground_truth)) + + def test_convert_false_returns_gsm_node(self): + data = [((), True)] + result = run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', convert=False) + self.assertIsInstance(result, GsmNode) + + def test_raises_for_invalid_output_behavior(self): + with self.assertRaises(ValueError): + GeneralizedStateMerging(output_behavior='invalid') + + def test_raises_for_invalid_transition_behavior(self): + with self.assertRaises(ValueError): + GeneralizedStateMerging(transition_behavior='invalid') + + def test_raises_for_nondeterministic_data_with_deterministic_behavior(self): + gsm = GeneralizedStateMerging(output_behavior='mealy', transition_behavior='deterministic') + # two different outputs for the same input from the root is nondeterministic + traces = [[('a', 'x')], [('a', 'y')]] + with self.assertRaises(ValueError): + gsm.run(traces, data_format='io_traces') + + def test_missing_score_calc_for_nondeterministic_behavior_raises(self): + with self.assertRaises(ValueError): + GeneralizedStateMerging(transition_behavior='nondeterministic') + + +class TestRunGsmInstrumentation(unittest.TestCase): + def test_instrumentation_hooks_are_invoked(self): + ground_truth = alternating_moore() + data = labeled_sequence_data(ground_truth, depth=3) + instrumentation = RecordingInstrumentation() + + run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', instrumentation=instrumentation) + + self.assertTrue(instrumentation.reset_called) + self.assertTrue(instrumentation.pta_done) + self.assertTrue(instrumentation.learning_done_called) + # some merges should have happened since the ground truth is only 2 states + self.assertGreater(len(instrumentation.merges), 0) + + +class TestRunGsmPreprocessingPostprocessing(unittest.TestCase): + def test_preprocessing_and_postprocessing_are_applied(self): + calls = [] + + def pta_preprocessing(root): + calls.append('pre') + return root + + def postprocessing(root): + calls.append('post') + return root + + data = [((), True), (('a',), False)] + run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', pta_preprocessing=pta_preprocessing, + postprocessing=postprocessing) + + self.assertEqual(calls, ['pre', 'post']) + + +class TestConsiderOnlyMinBlueAndDepthFirst(unittest.TestCase): + def test_consider_only_min_blue_still_learns_correct_model(self): + ground_truth = alternating_moore() + data = labeled_sequence_data(ground_truth, depth=3) + learned = run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', consider_only_min_blue=True) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_depth_first_still_learns_correct_model(self): + ground_truth = alternating_moore() + data = labeled_sequence_data(ground_truth, depth=3) + learned = run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', depth_first=True, + compatibility_on_futures=True) + self.assertTrue(bisimilar(learned, ground_truth)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/general_passive/test_gsm_algorithms.py b/tests/learning_algs/general_passive/test_gsm_algorithms.py new file mode 100644 index 00000000000..9e88397a168 --- /dev/null +++ b/tests/learning_algs/general_passive/test_gsm_algorithms.py @@ -0,0 +1,196 @@ +import random +import unittest +from itertools import product + +from aalpy.automata import ( + Dfa, DfaState, MooreMachine, MooreState, MealyMachine, MealyState, Mdp, MdpState, StochasticMealyMachine, + StochasticMealyState, +) +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs.general_passive.GsmAlgorithms import run_EDSM, run_Alergia_EDSM, run_k_tails +from aalpy.utils.ModelChecking import bisimilar + + +def even_a_dfa(): + q0 = DfaState('q0', is_accepting=True) + q1 = DfaState('q1', is_accepting=False) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return Dfa(q0, [q0, q1]) + + +def alternating_moore(): + q0 = MooreState('q0', 0) + q1 = MooreState('q1', 1) + q0.transitions = {'a': q1, 'b': q0} + q1.transitions = {'a': q0, 'b': q1} + return MooreMachine(q0, [q0, q1]) + + +def parity_mealy(): + q0 = MealyState('q0') + q1 = MealyState('q1') + q0.transitions = {'a': q1, 'b': q0} + q0.output_fun = {'a': 'x', 'b': 'y'} + q1.transitions = {'a': q0, 'b': q1} + q1.output_fun = {'a': 'y', 'b': 'x'} + return MealyMachine(q0, [q0, q1]) + + +def labeled_sequence_data(automaton, depth=3): + data = [] + alphabet = automaton.get_input_alphabet() + is_mealy = isinstance(automaton, MealyMachine) + start_level = 1 if is_mealy else 0 + for level in range(start_level, depth + 1): + for seq in product(alphabet, repeat=level): + automaton.reset_to_initial() + if len(seq) == 0: + label = automaton.initial_state.output + else: + outputs = automaton.execute_sequence(automaton.initial_state, seq) + label = outputs[-1] + data.append((seq, label)) + return data + + +class TestRunEdsm(unittest.TestCase): + def test_learns_minimal_dfa(self): + ground_truth = even_a_dfa() + data = labeled_sequence_data(ground_truth, depth=3) + learned = run_EDSM(data, automaton_type='dfa', print_info=False) + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_learns_minimal_moore_machine(self): + ground_truth = alternating_moore() + data = labeled_sequence_data(ground_truth, depth=3) + learned = run_EDSM(data, automaton_type='moore', print_info=False) + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_learns_minimal_mealy_machine(self): + ground_truth = parity_mealy() + data = labeled_sequence_data(ground_truth, depth=3) + learned = run_EDSM(data, automaton_type='mealy', print_info=False) + self.assertEqual(len(learned.states), 2) + self.assertTrue(bisimilar(learned, ground_truth)) + + def test_input_completeness_sink_state(self): + data = [((), True), (('a',), False), (('b',), True)] + learned = run_EDSM(data, automaton_type='dfa', input_completeness='sink_state', print_info=False) + self.assertTrue(learned.is_input_complete()) + + def test_input_completeness_self_loop(self): + data = [((), True), (('a',), False), (('b',), True)] + learned = run_EDSM(data, automaton_type='dfa', input_completeness='self_loop', print_info=False) + self.assertTrue(learned.is_input_complete()) + + +def nd_moore_output_sequence_is_possible(machine, inputs, expected_outputs): + """ + Checks whether there exists at least one path through the (possibly nondeterministic) Moore machine + that reproduces the expected output sequence for the given inputs. + """ + current_states = {machine.initial_state} + for in_sym, expected_out in zip(inputs, expected_outputs): + next_states = set() + for state in current_states: + for target in state.transitions.get(in_sym, []): + if target.output == expected_out: + next_states.add(target) + if not next_states: + return False + current_states = next_states + return True + + +class TestRunKTails(unittest.TestCase): + def io_traces_for_moore(self, ground_truth, depth=4): + alphabet = ground_truth.get_input_alphabet() + traces = [] + for level in range(1, depth + 1): + for seq in product(alphabet, repeat=level): + ground_truth.reset_to_initial() + outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq) + traces.append([ground_truth.initial_state.output] + list(zip(seq, outputs))) + return traces + + def test_learned_model_reproduces_training_traces_with_large_k(self): + ground_truth = alternating_moore() + traces = self.io_traces_for_moore(ground_truth, depth=4) + + learned = run_k_tails(traces, automaton_type='moore', k=10, print_info=False) + + for trace in traces: + initial_output = trace[0] + inputs = [i for i, _ in trace[1:]] + outputs = [o for _, o in trace[1:]] + self.assertEqual(learned.initial_state.output, initial_output) + self.assertTrue(nd_moore_output_sequence_is_possible(learned, inputs, outputs), + f'trace {trace} not reproducible by learned k-tails model') + + def test_small_k_merges_more_aggressively_than_large_k(self): + # with a small k, compatibility is only checked shallowly, so more (possibly behaviorally + # different) states get merged, generally yielding a smaller (or equal) automaton than a + # large k that checks compatibility much more thoroughly. + ground_truth = alternating_moore() + traces = self.io_traces_for_moore(ground_truth, depth=4) + + learned_small_k = run_k_tails(traces, automaton_type='moore', k=0, print_info=False) + learned_large_k = run_k_tails(traces, automaton_type='moore', k=10, print_info=False) + + self.assertLessEqual(len(learned_small_k.states), len(learned_large_k.states)) + + +class TestRunAlergiaEdsm(unittest.TestCase): + def deterministic_mdp(self): + """3-state MDP with only probability-1 transitions, so its behavior is effectively deterministic.""" + q0 = MdpState('q0', output='label') + q1 = MdpState('q1', output='label') + q2 = MdpState('q2', output='label') + q0.transitions['a'] = [(q1, 1.0)] + q0.transitions['b'] = [(q0, 1.0)] + q1.transitions['a'] = [(q2, 1.0)] + q1.transitions['b'] = [(q0, 1.0)] + q2.transitions['a'] = [(q2, 1.0)] + q2.transitions['b'] = [(q1, 1.0)] + return Mdp(q0, [q0, q1, q2]) + + def generate_traces(self, mdp, num_traces=200, max_len=6, seed=1): + random.seed(seed) + sul = AutomatonSUL(mdp) + alphabet = mdp.get_input_alphabet() + traces = [] + for _ in range(num_traces): + length = random.randint(1, max_len) + inputs = [random.choice(alphabet) for _ in range(length)] + outputs = sul.query(tuple(inputs)) + trace = [mdp.initial_state.output] + for i, o in zip(inputs, outputs): + trace.append((i, o)) + traces.append(trace) + return traces + + def test_learns_model_matching_deterministic_ground_truth_behavior(self): + ground_truth = self.deterministic_mdp() + traces = self.generate_traces(ground_truth) + + learned = run_Alergia_EDSM(traces, automaton_type='mdp', eps=0.05, print_info=False) + + random.seed(7) + alphabet = ground_truth.get_input_alphabet() + for _ in range(50): + inputs = tuple(random.choice(alphabet) for _ in range(random.randint(1, 6))) + + ground_truth.reset_to_initial() + expected = ground_truth.execute_sequence(ground_truth.initial_state, inputs) + + learned.reset_to_initial() + actual = learned.execute_sequence(learned.initial_state, inputs) + + self.assertEqual(actual, expected, f'mismatch on {inputs}') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/general_passive/test_gsm_node.py b/tests/learning_algs/general_passive/test_gsm_node.py new file mode 100644 index 00000000000..55fc5b8ba67 --- /dev/null +++ b/tests/learning_algs/general_passive/test_gsm_node.py @@ -0,0 +1,294 @@ +import unittest + +from aalpy.learning_algs.general_passive.GsmNode import ( + GsmNode, TransitionInfo, detect_data_format, intersection_iterator, union_iterator, unknown_output, +) + + +class TestIterators(unittest.TestCase): + def test_intersection_iterator_only_common_keys(self): + a = {'x': 1, 'y': 2} + b = {'y': 20, 'z': 30} + result = list(intersection_iterator(a, b)) + self.assertEqual(result, [('y', 2, 20)]) + + def test_union_iterator_uses_default_for_missing(self): + a = {'x': 1} + b = {'y': 2} + result = sorted(union_iterator(a, b, default=-1)) + self.assertEqual(result, [('x', 1, -1), ('y', -1, 2)]) + + +class TestDetectDataFormat(unittest.TestCase): + def test_empty_data_defaults_to_io_traces(self): + self.assertEqual(detect_data_format([]), 'io_traces') + + def test_gsm_node_is_tree_format(self): + node = GsmNode((None, unknown_output), None) + self.assertEqual(detect_data_format(node), 'tree') + + def test_labeled_sequences_detected(self): + data = [(('a', 'b'), 1), (('a',), 2)] + self.assertEqual(detect_data_format(data), 'labeled_sequences') + + def test_io_traces_detected(self): + data = [[('a', 'x'), ('b', 'y')], [('a', 'x')]] + self.assertEqual(detect_data_format(data), 'io_traces') + + def test_non_sequence_data_raises(self): + with self.assertRaises(ValueError): + detect_data_format([1, 2, 3]) + + def test_ambiguous_short_traces_default_without_consistency_check(self): + # a single 2-tuple could be a labeled_sequence or an io_trace of length 1; + # without check_consistency, the format is decided as soon as unambiguous, using the first entry + data = [(('a',), 1)] + fmt = detect_data_format(data) + self.assertIn(fmt, ('labeled_sequences', 'io_traces')) + + +class TestGsmNodeBasics(unittest.TestCase): + def test_root_has_no_predecessor_and_zero_prefix_length(self): + root = GsmNode((None, unknown_output), None) + self.assertIsNone(root.predecessor) + self.assertEqual(root.get_prefix_length(), 0) + self.assertEqual(root.get_prefix(), []) + + def test_add_trace_builds_chain_with_correct_prefix(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x'), ('b', 'y')]) + node_a = root.transitions['a']['x'].target + node_ab = node_a.transitions['b']['y'].target + self.assertEqual(node_a.get_prefix_length(), 1) + self.assertEqual(node_ab.get_prefix(), [('a', 'x'), ('b', 'y')]) + self.assertIs(node_ab.get_root(), root) + + def test_add_trace_increments_count_for_repeated_trace(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + root.add_trace([('a', 'x')]) + t_info = root.transitions['a']['x'] + self.assertEqual(t_info.count, 2) + self.assertEqual(t_info.original_count, 2) + + def test_get_by_prefix_returns_none_for_undefined_path(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + self.assertIsNone(root.get_by_prefix([('b', 'y')])) + + def test_get_by_prefix_ignores_leading_none_input(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + node = root.get_by_prefix([(None, 'initial'), ('a', 'x')]) + self.assertIs(node, root.transitions['a']['x'].target) + + def test_get_all_nodes_includes_root_and_children(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x'), ('b', 'y')]) + nodes = root.get_all_nodes() + self.assertEqual(len(nodes), 3) + + def test_is_tree_true_for_pta(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + root.add_trace([('b', 'y')]) + self.assertTrue(root.is_tree()) + + def test_is_tree_false_when_node_shared(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + shared = root.transitions['a']['x'].target + # manually introduce a shared target to simulate a merged (non-tree) structure + root.transitions['b']['y'] = TransitionInfo(shared, 1, None, None) + self.assertFalse(root.is_tree()) + + def test_shallow_copy_shares_targets_but_independent_transitions_dict(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + copy = root.shallow_copy() + self.assertIsNot(copy.transitions, root.transitions) + self.assertIs(copy.transitions['a']['x'].target, root.transitions['a']['x'].target) + copy.transitions['b']['y'] = TransitionInfo(copy, 1, None, None) + self.assertNotIn('b', root.transitions) + + def test_make_input_complete_adds_self_loops_for_missing_inputs(self): + root = GsmNode((None, 'root_out'), None) + root.add_trace([('a', 'x')]) + # 'b' is used elsewhere in the tree but not from root + node_a = root.transitions['a']['x'].target + node_a.add_trace([('b', 'y')]) + missing = root.make_input_complete() + self.assertIn((root, 'b', 'root_out'), missing) + self.assertIs(root.transitions['b']['root_out'].target, root) + + +class TestGsmNodeOrderingAndOutputs(unittest.TestCase): + def test_lt_orders_by_prefix_length_then_lexicographically(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 1), ('a', 1)]) + root.add_trace([('b', 1)]) + node_a = root.transitions['a'][1].target + node_b = root.transitions['b'][1].target + node_aa = node_a.transitions['a'][1].target + self.assertTrue(node_a < node_aa) + self.assertTrue(node_a < node_b) # same length, 'a' < 'b' + self.assertFalse(node_b < node_a) + + def test_resolve_unknown_prefix_output_only_updates_if_unknown(self): + node = GsmNode(('a', unknown_output), None) + node.resolve_unknown_prefix_output('resolved') + self.assertEqual(node.get_prefix_output(), 'resolved') + node.resolve_unknown_prefix_output('other') + self.assertEqual(node.get_prefix_output(), 'resolved') + + def test_add_labeled_sequence_sets_prefix_output_on_final_node(self): + root = GsmNode((None, unknown_output), None) + root.add_labeled_sequence((('a', 'b'), 'label1')) + # only the final step's transition dict key is resolved from unknown_output to the real label; + # intermediate steps remain keyed by unknown_output. + node = root.get_by_prefix([('a', unknown_output), ('b', 'label1')]) + self.assertIsNotNone(node) + self.assertEqual(node.get_prefix_output(), 'label1') + + def test_add_labeled_sequence_raises_on_conflicting_label_for_same_sequence(self): + root = GsmNode((None, unknown_output), None) + root.add_labeled_sequence((('a',), 'out1')) + with self.assertRaises(ValueError): + root.add_labeled_sequence((('a',), 'out2')) + + def test_is_locally_deterministic_true_for_single_output_per_input(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + self.assertTrue(root.is_locally_deterministic()) + + def test_is_locally_deterministic_false_for_two_outputs_same_input(self): + root = GsmNode((None, unknown_output), None) + root.transitions['a']['x'] = TransitionInfo(GsmNode(('a', 'x'), root), 1, None, None) + root.transitions['a']['y'] = TransitionInfo(GsmNode(('a', 'y'), root), 1, None, None) + self.assertFalse(root.is_locally_deterministic()) + self.assertFalse(root.is_deterministic()) + + def test_deterministic_compatible_true_when_no_shared_inputs(self): + n1 = GsmNode((None, unknown_output), None) + n1.add_trace([('a', 'x')]) + n2 = GsmNode((None, unknown_output), None) + n2.add_trace([('b', 'y')]) + self.assertTrue(n1.deterministic_compatible(n2)) + + def test_deterministic_compatible_false_on_output_mismatch_for_shared_input(self): + n1 = GsmNode((None, unknown_output), None) + n1.add_trace([('a', 'x')]) + n2 = GsmNode((None, unknown_output), None) + n2.add_trace([('a', 'y')]) + self.assertFalse(n1.deterministic_compatible(n2)) + + def test_deterministic_compatible_true_when_unknown_output_present(self): + n1 = GsmNode((None, unknown_output), None) + n1.transitions['a'][unknown_output] = TransitionInfo(GsmNode(('a', unknown_output), n1), 1, None, None) + n2 = GsmNode((None, unknown_output), None) + n2.add_trace([('a', 'x')]) + self.assertTrue(n1.deterministic_compatible(n2)) + + def test_is_moore_true_when_child_output_matches_transition_output(self): + root = GsmNode((None, 'root_out'), None) + root.add_trace([('a', 'child_out')]) + self.assertTrue(root.is_moore()) + + def test_is_moore_false_when_child_output_mismatches(self): + root = GsmNode((None, 'root_out'), None) + child = GsmNode(('a', 'transition_out'), root) + root.transitions['a']['transition_out'] = TransitionInfo(child, 1, None, None) + child.prefix_access_pair = ('a', 'different_child_out') + self.assertFalse(root.is_moore()) + + def test_moore_compatible_true_for_matching_or_unknown_outputs(self): + n1 = GsmNode(('a', 'x'), None) + n2 = GsmNode(('a', 'x'), None) + n3 = GsmNode(('a', unknown_output), None) + self.assertTrue(n1.moore_compatible(n2)) + self.assertTrue(n1.moore_compatible(n3)) + + def test_moore_compatible_false_for_conflicting_outputs(self): + n1 = GsmNode(('a', 'x'), None) + n2 = GsmNode(('a', 'y'), None) + self.assertFalse(n1.moore_compatible(n2)) + + def test_count_sums_transition_counts(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + root.add_trace([('a', 'x')]) + root.add_trace([('b', 'y')]) + self.assertEqual(root.count(), 3) + + def test_local_log_likelihood_contribution_zero_for_single_outcome(self): + # a deterministic transition (single outcome for its input) contributes 0 to the log-likelihood, + # since n*log(n) - n*log(n) == 0 regardless of count. + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + root.add_trace([('a', 'x')]) + self.assertAlmostEqual(root.local_log_likelihood_contribution(), 0.0) + + def test_local_log_likelihood_contribution_negative_for_split_outcomes(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + root.add_trace([('a', 'y')]) + self.assertLess(root.local_log_likelihood_contribution(), 0.0) + + +class TestGsmNodeCreatePTA(unittest.TestCase): + def test_labeled_sequences_format(self): + data = [(('a', 'b'), 1), (('a', 'c'), 2)] + root = GsmNode.createPTA(data, output_behavior='moore', data_format='labeled_sequences') + node_a = root.transitions['a'][unknown_output].target + self.assertEqual(node_a.get_prefix_length(), 1) + + def test_io_traces_moore_uses_first_output_as_root_output(self): + data = [[0, ('a', 1)], [0, ('a', 1)]] + root = GsmNode.createPTA(data, output_behavior='moore', data_format='io_traces') + self.assertEqual(root.get_prefix_output(), 0) + + def test_io_traces_mealy_has_no_root_output(self): + data = [[('a', 'x')]] + root = GsmNode.createPTA(data, output_behavior='mealy', data_format='io_traces') + self.assertEqual(root.get_prefix_output(), unknown_output) + + def test_tree_format_passthrough_requires_tree_structure(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + result = GsmNode.createPTA(root, output_behavior='mealy', data_format='tree') + self.assertIs(result, root) + + def test_tree_format_rejects_non_tree(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + shared = root.transitions['a']['x'].target + root.transitions['b']['y'] = TransitionInfo(shared, 1, None, None) + with self.assertRaises(ValueError): + GsmNode.createPTA(root, output_behavior='mealy', data_format='tree') + + +class TestGsmNodeToAutomaton(unittest.TestCase): + def test_to_automaton_deterministic_moore(self): + root = GsmNode((None, 0), None) + root.add_trace([('a', 1)]) + automaton = root.to_automaton('moore', 'deterministic') + self.assertEqual(automaton.initial_state.output, 0) + self.assertEqual(automaton.initial_state.transitions['a'].output, 1) + + def test_to_automaton_raises_on_non_moore_structure_when_moore_requested(self): + root = GsmNode((None, 'root_out'), None) + child = GsmNode(('a', 'transition_out'), root) + root.transitions['a']['transition_out'] = TransitionInfo(child, 1, None, None) + child.prefix_access_pair = ('a', 'different_output') + with self.assertRaises(ValueError): + root.to_automaton('moore', 'deterministic') + + def test_to_automaton_deterministic_mealy(self): + root = GsmNode((None, unknown_output), None) + root.add_trace([('a', 'x')]) + automaton = root.to_automaton('mealy', 'deterministic') + self.assertEqual(automaton.initial_state.output_fun['a'], 'x') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/general_passive/test_instrumentation.py b/tests/learning_algs/general_passive/test_instrumentation.py new file mode 100644 index 00000000000..cd7d88d8322 --- /dev/null +++ b/tests/learning_algs/general_passive/test_instrumentation.py @@ -0,0 +1,79 @@ +import unittest + +from aalpy.learning_algs.general_passive.GeneralizedStateMerging import run_GSM +from aalpy.learning_algs.general_passive.Instrumentation import MergeViolationDebugger, ProgressReport +from aalpy.learning_algs.general_passive.GsmNode import GsmNode, TransitionInfo + + +class TestProgressReport(unittest.TestCase): + def test_records_pta_size_and_merge_counts_for_a_small_run(self): + data = [((), True), (('a',), True), (('b',), True)] + instrumentation = ProgressReport(lvl=2) + + run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', instrumentation=instrumentation) + + self.assertEqual(instrumentation.pta_size, 3) + # 'a' and 'b' children both have the same (True) output and no further transitions, so they merge + self.assertGreaterEqual(instrumentation.nr_merged_states_total, 1) + self.assertIn('pta creation time', instrumentation.stats) + self.assertIn('learning time', instrumentation.stats) + self.assertIn('total time', instrumentation.stats) + + def test_lvl_zero_skips_detailed_tracking(self): + instrumentation = ProgressReport(lvl=0) + self.assertFalse(hasattr(instrumentation, 'log')) + + data = [((), True), (('a',), False)] + # should not raise even though detailed tracking attributes are absent + run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', instrumentation=instrumentation) + + +class TestMergeViolationDebugger(unittest.TestCase): + def build_matching_ground_truth_tree(self): + # data [((), True), (('a',), True), (('b',), True)] is only ever consistent with a single-state + # automaton that self-loops on 'a' and 'b'; the ground truth tree must reflect that so that the + # actual merges GSM performs (root with 'a', root with 'b') are considered correct. + root = GsmNode((None, True), None) + root.transitions['a'][True] = TransitionInfo(root, 1, None, None) + root.transitions['b'][True] = TransitionInfo(root, 1, None, None) + return root + + def test_logs_correct_merges_and_promotions_against_ground_truth(self): + ground_truth = self.build_matching_ground_truth_tree() + debugger = MergeViolationDebugger(ground_truth) + + data = [((), True), (('a',), True), (('b',), True)] + run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', instrumentation=debugger) + + kinds = [entry[0] for entry in debugger.log] + self.assertIn('promote', kinds) + self.assertNotIn('wrong promote', kinds) + self.assertNotIn('wrong merge', kinds) + self.assertNotIn('broken merge', kinds) + + def test_flags_wrong_merge_against_mismatched_ground_truth(self): + # a ground truth tree where 'a' and 'b' are distinct states never merges them; + # comparing against it while the actual run does merge them should be flagged as wrong. + mismatched_ground_truth = GsmNode((None, True), None) + mismatched_ground_truth.add_trace([('a', True)]) + mismatched_ground_truth.add_trace([('b', True)]) + # sabotage: make root.get_by_prefix for 'b' point to a node distinct from 'a's, but give it a + # different (non-tree) identity so the debugger's identity check for a real merge fails + debugger = MergeViolationDebugger(mismatched_ground_truth) + + # data where 'a' and 'b' children both lead to identical (mergeable) leaves + data = [((), True), (('a',), True), (('b',), True)] + run_GSM(data, output_behavior='moore', transition_behavior='deterministic', + data_format='labeled_sequences', instrumentation=debugger) + + kinds = [entry[0] for entry in debugger.log] + # since 'a' and 'b' are genuinely distinct nodes in the ground truth tree, merging them in the + # actual run must be flagged as a wrong merge. + self.assertIn('wrong merge', kinds) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/general_passive/test_score_functions_gsm.py b/tests/learning_algs/general_passive/test_score_functions_gsm.py new file mode 100644 index 00000000000..5180f0a256e --- /dev/null +++ b/tests/learning_algs/general_passive/test_score_functions_gsm.py @@ -0,0 +1,260 @@ +import unittest + +from aalpy.learning_algs.general_passive.GsmNode import GsmNode, TransitionInfo, unknown_output +from aalpy.learning_algs.general_passive.ScoreFunctionsGSM import ( + AIC_score, EDSM_frequency_score, EDSM_score, ScoreCalculation, ScoreCombinator, ScoreWithKTail, + ScoreWithSinks, differential_info, hoeffding_compatibility, local_to_global_compatibility, lower_threshold, + make_greedy, transform_score, +) + + +def node_with_counts(counts, prefix_access_pair=(None, unknown_output)): + """Builds a node whose single input 'i' has the given {output: count} outgoing transitions.""" + node = GsmNode(prefix_access_pair, None) + for out_sym, count in counts.items(): + target = GsmNode(('i', out_sym), node) + node.transitions['i'][out_sym] = TransitionInfo(target, count, target, count) + return node + + +class TestScoreCalculationDefaults(unittest.TestCase): + def test_default_local_compatibility_always_true(self): + sc = ScoreCalculation() + self.assertTrue(sc.local_compatibility(GsmNode((None, None), None), GsmNode((None, None), None))) + self.assertFalse(sc.has_local_compatibility()) + + def test_default_score_function_always_true(self): + sc = ScoreCalculation() + self.assertTrue(sc.score_function({})) + self.assertFalse(sc.has_score_function()) + + def test_custom_functions_are_detected_as_overridden(self): + sc = ScoreCalculation(local_compatibility=lambda a, b: False, score_function=lambda p: 42) + self.assertTrue(sc.has_local_compatibility()) + self.assertTrue(sc.has_score_function()) + + +class TestHoeffdingCompatibility(unittest.TestCase): + def test_identical_distributions_are_compatible(self): + a = node_with_counts({'x': 100, 'y': 100}) + b = node_with_counts({'x': 100, 'y': 100}) + compat = hoeffding_compatibility(0.05) + self.assertTrue(compat(a, b)) + + def test_very_different_distributions_are_incompatible(self): + a = node_with_counts({'x': 1000, 'y': 0}) + b = node_with_counts({'x': 0, 'y': 1000}) + compat = hoeffding_compatibility(0.05) + self.assertFalse(compat(a, b)) + + def test_zero_total_count_is_ignored(self): + a = node_with_counts({}) + b = node_with_counts({'x': 100}) + compat = hoeffding_compatibility(0.05) + self.assertTrue(compat(a, b)) + + def test_disjoint_inputs_are_compatible(self): + a = GsmNode((None, None), None) + a.transitions['i']['x'] = TransitionInfo(GsmNode(('i', 'x'), a), 100, GsmNode(('i', 'x'), a), 100) + b = GsmNode((None, None), None) + b.transitions['j']['y'] = TransitionInfo(GsmNode(('j', 'y'), b), 100, GsmNode(('j', 'y'), b), 100) + compat = hoeffding_compatibility(0.05) + self.assertTrue(compat(a, b)) + + +class TestScoreWithKTail(unittest.TestCase): + def test_beyond_depth_k_is_always_compatible(self): + always_false = ScoreCalculation(local_compatibility=lambda a, b: False) + wrapped = ScoreWithKTail(always_false, k=1) + + root = GsmNode((None, None), None) + blue_shallow = GsmNode(('a', None), root) + blue_shallow_child = GsmNode(('a', None), blue_shallow) + + wrapped.reset() + # first call establishes the depth offset at blue_shallow's depth (1) + self.assertFalse(wrapped.local_compatibility(root, blue_shallow)) + # a node one level deeper than the offset (depth 2) is beyond k=1 -> compatible regardless + self.assertTrue(wrapped.local_compatibility(root, blue_shallow_child)) + + def test_within_depth_k_delegates_to_wrapped_score(self): + always_false = ScoreCalculation(local_compatibility=lambda a, b: False) + wrapped = ScoreWithKTail(always_false, k=5) + root = GsmNode((None, None), None) + blue = GsmNode(('a', None), root) + wrapped.reset() + self.assertFalse(wrapped.local_compatibility(root, blue)) + + +class TestScoreWithSinks(unittest.TestCase): + def test_rejects_merge_between_sink_and_non_sink(self): + always_true = ScoreCalculation(local_compatibility=lambda a, b: True) + is_sink = lambda n: n.get_prefix_output() == 'sink' + wrapped = ScoreWithSinks(always_true, sink_cond=is_sink) + wrapped.reset() + + sink_node = GsmNode((None, 'sink'), None) + normal_node = GsmNode((None, 'normal'), None) + self.assertFalse(wrapped.local_compatibility(sink_node, normal_node)) + + def test_allows_merge_between_two_sinks_by_default(self): + always_true = ScoreCalculation(local_compatibility=lambda a, b: True) + is_sink = lambda n: n.get_prefix_output() == 'sink' + wrapped = ScoreWithSinks(always_true, sink_cond=is_sink) + wrapped.reset() + + sink_a = GsmNode((None, 'sink'), None) + sink_b = GsmNode((None, 'sink'), None) + self.assertTrue(wrapped.local_compatibility(sink_a, sink_b)) + + def test_rejects_merge_between_two_sinks_when_disallowed(self): + always_true = ScoreCalculation(local_compatibility=lambda a, b: True) + is_sink = lambda n: n.get_prefix_output() == 'sink' + wrapped = ScoreWithSinks(always_true, sink_cond=is_sink, allow_sink_merge=False) + wrapped.reset() + + sink_a = GsmNode((None, 'sink'), None) + sink_b = GsmNode((None, 'sink'), None) + self.assertFalse(wrapped.local_compatibility(sink_a, sink_b)) + + def test_sink_check_only_applies_on_first_call(self): + always_true = ScoreCalculation(local_compatibility=lambda a, b: True) + is_sink = lambda n: n.get_prefix_output() == 'sink' + wrapped = ScoreWithSinks(always_true, sink_cond=is_sink, allow_sink_merge=False) + wrapped.reset() + + sink_a = GsmNode((None, 'sink'), None) + normal = GsmNode((None, 'normal'), None) + # consume the "first call" check with a compatible (non-sink) pair + self.assertTrue(wrapped.local_compatibility(normal, normal)) + # subsequent calls skip the sink check entirely, so this doesn't get rejected + self.assertTrue(wrapped.local_compatibility(sink_a, sink_a)) + + +class TestScoreCombinator(unittest.TestCase): + def test_default_aggregate_compatibility_commits_to_first_non_none(self): + s1 = ScoreCalculation(local_compatibility=lambda a, b: None) + s2 = ScoreCalculation(local_compatibility=lambda a, b: False) + combined = ScoreCombinator([s1, s2]) + self.assertFalse(combined.local_compatibility(None, None)) + + def test_default_aggregate_compatibility_true_when_all_none(self): + s1 = ScoreCalculation(local_compatibility=lambda a, b: None) + combined = ScoreCombinator([s1]) + self.assertTrue(combined.local_compatibility(None, None)) + + def test_default_aggregate_score_collects_all_scores(self): + s1 = ScoreCalculation(score_function=lambda p: 1) + s2 = ScoreCalculation(score_function=lambda p: 2) + combined = ScoreCombinator([s1, s2]) + self.assertEqual(combined.score_function({}), [1, 2]) + + def test_reset_delegates_to_all_scores(self): + calls = [] + + class Tracking(ScoreCalculation): + def reset(self): + calls.append(id(self)) + + s1, s2 = Tracking(), Tracking() + combined = ScoreCombinator([s1, s2]) + combined.reset() + self.assertEqual(len(calls), 2) + + +class TestLocalToGlobalCompatibility(unittest.TestCase): + def test_true_when_all_local_checks_pass(self): + fun = local_to_global_compatibility(lambda a, b: True) + self.assertTrue(fun({'old': 'new'})) + + def test_false_when_any_local_check_fails(self): + fun = local_to_global_compatibility(lambda a, b: a != 'bad_new') + self.assertFalse(fun({'old': 'bad_new'})) + + +class TestDifferentialInfo(unittest.TestCase): + def test_merging_identical_nodes_does_not_change_likelihood(self): + # merging two structurally identical nodes into one should not change the log-likelihood, + # but should reduce the number of parameters (fewer distinct transitions after the merge). + old1 = node_with_counts({'x': 5, 'y': 5}) + old2 = node_with_counts({'x': 5, 'y': 5}) + merged = node_with_counts({'x': 10, 'y': 10}) + part = {old1: merged, old2: merged} + llh_diff, param_diff = differential_info(part) + self.assertAlmostEqual(llh_diff, 0.0) + self.assertGreater(param_diff, 0) + + +class TestScoreTransforms(unittest.TestCase): + def test_transform_score_on_plain_value(self): + self.assertEqual(transform_score(5, lambda x: x * 2), 10) + + def test_transform_score_on_callable(self): + fun = transform_score(lambda part: 5, lambda x: x * 2) + self.assertEqual(fun({}), 10) + + def test_transform_score_on_score_calculation(self): + # regression test: transform_score used to reassign score.score_function to a lambda that + # referenced score.score_function again, causing infinite recursion on the first call. + sc = ScoreCalculation(score_function=lambda part: 5) + transformed = transform_score(sc, lambda x: x * 2) + self.assertIs(transformed, sc) + self.assertEqual(transformed.score_function({}), 10) + + def test_transform_score_on_score_calculation_can_be_applied_twice(self): + sc = ScoreCalculation(score_function=lambda part: 5) + transform_score(sc, lambda x: x * 2) + transform_score(sc, lambda x: x + 1) + self.assertEqual(sc.score_function({}), 11) + + def test_make_greedy_rejects_only_false(self): + self.assertTrue(make_greedy(0)) + self.assertTrue(make_greedy('anything')) + self.assertFalse(make_greedy(False)) + + def test_lower_threshold_rejects_values_at_or_below_threshold(self): + self.assertEqual(lower_threshold(5, 3), 5) + self.assertFalse(lower_threshold(3, 3)) + self.assertFalse(lower_threshold(1, 3)) + + +class TestBuiltinScoreFunctions(unittest.TestCase): + def test_aic_score_rejects_partitions_below_threshold(self): + score_fun = AIC_score(alpha=1000) + old1 = node_with_counts({'x': 5}) + merged = node_with_counts({'x': 5}) + result = score_fun({old1: merged}) + self.assertFalse(result) + + def test_edsm_frequency_score_counts_contradicted_evidence(self): + score_fun = EDSM_frequency_score(min_evidence=-1) + old_node = node_with_counts({'x': 5}) + new_node = node_with_counts({'x': 10}) # count changed by the merge -> contradicted evidence + result = score_fun({old_node: new_node}) + self.assertEqual(result, 5) + + def test_edsm_frequency_score_rejects_below_min_evidence(self): + score_fun = EDSM_frequency_score(min_evidence=10) + old_node = node_with_counts({'x': 5}) + new_node = node_with_counts({'x': 10}) + result = score_fun({old_node: new_node}) + self.assertFalse(result) + + def test_edsm_score_counts_merged_minus_partitions(self): + score_fun = EDSM_score(min_evidence=-1) + merged = node_with_counts({}) + part = {node_with_counts({}): merged, node_with_counts({}): merged, node_with_counts({}): 'other'} + result = score_fun(part) + # 3 original nodes map to 2 distinct partition representatives -> 3 - 2 = 1 + self.assertEqual(result, 1) + + def test_edsm_score_rejects_below_min_evidence(self): + score_fun = EDSM_score(min_evidence=5) + merged = node_with_counts({}) + part = {node_with_counts({}): merged, node_with_counts({}): merged} + result = score_fun(part) + self.assertFalse(result) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py new file mode 100644 index 00000000000..40b93f59e17 --- /dev/null +++ b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_lstar.py @@ -0,0 +1,67 @@ +import random +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs import run_abstracted_ONFSM_Lstar +from aalpy.oracles import RandomWalkEqOracle, RandomWordEqOracle +from aalpy.utils import get_benchmark_ONFSM + +SEEDS = range(8) + + +class TestRunAbstractedOnfsmLstar(unittest.TestCase): + def test_learns_benchmark_onfsm_with_identity_abstraction(self): + # An empty abstraction mapping means every output maps to itself (see + # AbstractedNonDetObservationTable.get_abstraction), so this should learn a model + # equivalent to the plain (non-abstracted) ONFSM learner's result. + onfsm = get_benchmark_ONFSM() + alphabet = onfsm.get_input_alphabet() + + for seed in SEEDS: + random.seed(seed) + sul = AutomatonSUL(onfsm) + oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5) + + learned_onfsm = run_abstracted_ONFSM_Lstar(alphabet, sul, oracle, abstraction_mapping={}, + n_sampling=20, print_level=0) + + eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=3000, reset_prob=0.09, + reset_after_cex=True) + cex = eq_oracle.find_cex(learned_onfsm) + + self.assertIsNone(cex, f'seed {seed}: independent oracle found a counterexample') + self.assertEqual(len(learned_onfsm.states), len(onfsm.states), + f'seed {seed}: learned model has wrong number of states') + + def test_real_abstraction_yields_a_model_no_larger_than_the_identity_one(self): + # Grouping outputs 0 and 2 into the same equivalence class can only merge states, never + # split them, so the abstracted model should never have more states than the ground truth. + random.seed(0) + onfsm = get_benchmark_ONFSM() + alphabet = onfsm.get_input_alphabet() + sul = AutomatonSUL(onfsm) + oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5) + + learned_onfsm = run_abstracted_ONFSM_Lstar(alphabet, sul, oracle, + abstraction_mapping={0: 'low', 2: 'low', 3: 'high'}, + n_sampling=20, print_level=0) + + self.assertLessEqual(len(learned_onfsm.states), len(onfsm.states)) + + def test_return_data_reports_consistent_learning_info(self): + random.seed(0) + onfsm = get_benchmark_ONFSM() + alphabet = onfsm.get_input_alphabet() + sul = AutomatonSUL(onfsm) + oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5) + + learned_onfsm, info = run_abstracted_ONFSM_Lstar(alphabet, sul, oracle, abstraction_mapping={}, + n_sampling=20, print_level=0, return_data=True) + + self.assertEqual(info['automaton_size'], len(learned_onfsm.states)) + self.assertGreaterEqual(info['learning_rounds'], 1) + self.assertGreaterEqual(info['queries_learning'], 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py new file mode 100644 index 00000000000..72a56613896 --- /dev/null +++ b/tests/learning_algs/non_deterministic/test_abstracted_onfsm_observation_table.py @@ -0,0 +1,149 @@ +import random +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs.non_deterministic.AbstractedOnfsmObservationTable import AbstractedNonDetObservationTable +from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper +from aalpy.utils import get_benchmark_ONFSM + +ABSTRACTION = {0: 'even', 2: 'even', 3: 'odd'} + + +def wrapped_sul(): + return NonDeterministicSULWrapper(AutomatonSUL(get_benchmark_ONFSM())) + + +def initial_round(at): + """Mirrors the first few lines of run_abstracted_ONFSM_Lstar: the initial row must be queried + and abstracted before S_dot_A/T are populated enough for get_row_to_close() to work.""" + at.update_obs_table() + new_rows = at.update_extended_S() + at.update_obs_table(s_set=new_rows) + + +class TestConstruction(unittest.TestCase): + def test_initializes_empty_S_dot_A_and_E(self): + at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10) + self.assertEqual(at.S, [((), ())]) + self.assertEqual(at.S_dot_A, []) + self.assertEqual(at.E, []) + self.assertEqual(at.A, [('a',), ('b',)]) + + def test_wraps_a_plain_non_det_observation_table_internally(self): + at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10) + self.assertEqual(at.observation_table.alphabet, ['a', 'b']) + + def test_asserts_on_missing_alphabet_or_sul(self): + with self.assertRaises(AssertionError): + AbstractedNonDetObservationTable(None, wrapped_sul(), ABSTRACTION, 10) + with self.assertRaises(AssertionError): + AbstractedNonDetObservationTable(['a'], None, ABSTRACTION, 10) + + +class TestGetAbstraction(unittest.TestCase): + def test_maps_known_outputs_to_their_equivalence_class(self): + at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10) + self.assertEqual(at.get_abstraction(0), 'even') + self.assertEqual(at.get_abstraction(2), 'even') + self.assertEqual(at.get_abstraction(3), 'odd') + + def test_falls_back_to_the_original_output_when_unmapped(self): + at = AbstractedNonDetObservationTable(['a', 'b'], wrapped_sul(), ABSTRACTION, 10) + self.assertEqual(at.get_abstraction('unmapped_output'), 'unmapped_output') + + +class TestAbstractObsTable(unittest.TestCase): + def test_T_contains_abstracted_outputs_not_raw_ones(self): + random.seed(0) + sul = wrapped_sul() + at = AbstractedNonDetObservationTable(sul.sul.automaton.get_input_alphabet(), sul, ABSTRACTION, 20) + at.update_obs_table() + + raw_traces = sul.cache.get_all_traces(((), ()), ('a',)) + self.assertTrue(any(t[0] in (0, 2, 3) for t in raw_traces)) + + abstracted_cell = at.T[((), ())][('a',)] + for value in abstracted_cell: + self.assertIn(value, [('even',), ('odd',)]) + + def test_raw_cache_keeps_concrete_outputs_alongside_the_abstraction(self): + random.seed(0) + sul = wrapped_sul() + alphabet = sul.sul.automaton.get_input_alphabet() + at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20) + at.update_obs_table() + raw_cell = sul.cache.get_all_traces(((), ()), ('a',)) + self.assertIn((0,), raw_cell) + + +class TestGetRowToClose(unittest.TestCase): + def test_moves_a_row_from_S_dot_A_into_S(self): + random.seed(0) + sul = wrapped_sul() + alphabet = sul.sul.automaton.get_input_alphabet() + at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20) + initial_round(at) + + s_before = list(at.S) + s_dot_a_before = list(at.S_dot_A) + row = at.get_row_to_close() + + self.assertIsNotNone(row) + self.assertIn(row, at.S) + self.assertNotIn(row, at.S_dot_A) + self.assertIn(row, s_dot_a_before) + self.assertNotIn(row, s_before) + + def test_repeated_calls_do_not_require_re_querying_in_between(self): + # Unlike NonDetObservationTable.get_row_to_close, which needs a fresh + # query_missing_observations() call between invocations, this abstracted variant works off + # S_dot_A directly, so consecutive calls can each pull a different row without re-querying. + random.seed(0) + sul = wrapped_sul() + alphabet = sul.sul.automaton.get_input_alphabet() + at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20) + initial_round(at) + + first = at.get_row_to_close() + second = at.get_row_to_close() + self.assertIsNotNone(first) + self.assertIsNotNone(second) + self.assertNotEqual(first, second) + + +class TestCleanObsTable(unittest.TestCase): + def test_removes_duplicate_rows_from_S(self): + random.seed(0) + sul = wrapped_sul() + alphabet = sul.sul.automaton.get_input_alphabet() + at = AbstractedNonDetObservationTable(alphabet, sul, ABSTRACTION, 20) + initial_round(at) + + row_to_close = at.get_row_to_close() + while row_to_close is not None: + row_to_close = at.get_row_to_close() + + sizes_before = len(at.S) + at.clean_obs_table() + self.assertLessEqual(len(at.S), sizes_before) + + +class TestExtendSDotA(unittest.TestCase): + def test_only_adds_prefixes_not_already_present(self): + at = AbstractedNonDetObservationTable(['a'], wrapped_sul(), ABSTRACTION, 10) + cex_prefixes = [(('a',), (0,))] + added_first = at.extend_S_dot_A(cex_prefixes) + added_second = at.extend_S_dot_A(cex_prefixes) + self.assertEqual(added_first, cex_prefixes) + self.assertEqual(added_second, []) + self.assertEqual(at.S_dot_A.count((('a',), (0,))), 1) + + def test_does_not_add_prefixes_already_in_S(self): + at = AbstractedNonDetObservationTable(['a'], wrapped_sul(), ABSTRACTION, 10) + added = at.extend_S_dot_A([((), ())]) + self.assertEqual(added, []) + self.assertEqual(at.S_dot_A, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py b/tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py new file mode 100644 index 00000000000..21e7b186e29 --- /dev/null +++ b/tests/learning_algs/non_deterministic/test_non_deterministic_sul_wrapper.py @@ -0,0 +1,121 @@ +import unittest + +from aalpy.base import SUL +from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper +from aalpy.learning_algs.non_deterministic.TraceTree import TraceTree + + +class AlternatingNonDetSUL(SUL): + """A tiny hand-written non-deterministic SUL: 'a' alternates between 'x' and 'y' outputs, + 'b' always returns 'z'. Deterministic alternation (instead of random.choice) keeps the tests + reproducible while still exercising the wrapper's handling of multiple observed outputs.""" + + def __init__(self): + super().__init__() + self.pre_calls = 0 + self.post_calls = 0 + self.a_step_counter = 0 + + def pre(self): + self.pre_calls += 1 + + def post(self): + self.post_calls += 1 + + def step(self, letter): + if letter == 'a': + out = 'x' if self.a_step_counter % 2 == 0 else 'y' + self.a_step_counter += 1 + else: + out = 'z' + return out + + +class TestConstruction(unittest.TestCase): + def test_wraps_given_sul_and_creates_empty_cache(self): + raw = AlternatingNonDetSUL() + wrapper = NonDeterministicSULWrapper(raw) + self.assertIs(wrapper.sul, raw) + self.assertIsInstance(wrapper.cache, TraceTree) + self.assertIsNone(wrapper.cache.curr_node) + + +class TestPreAndPost(unittest.TestCase): + def test_pre_resets_cache_cursor_and_delegates_to_wrapped_sul(self): + raw = AlternatingNonDetSUL() + wrapper = NonDeterministicSULWrapper(raw) + wrapper.pre() + self.assertIs(wrapper.cache.curr_node, wrapper.cache.root_node) + self.assertEqual(raw.pre_calls, 1) + + def test_post_delegates_to_wrapped_sul(self): + raw = AlternatingNonDetSUL() + wrapper = NonDeterministicSULWrapper(raw) + wrapper.post() + self.assertEqual(raw.post_calls, 1) + + +class TestStep(unittest.TestCase): + def test_step_returns_wrapped_output(self): + raw = AlternatingNonDetSUL() + wrapper = NonDeterministicSULWrapper(raw) + wrapper.pre() + self.assertEqual(wrapper.step('a'), 'x') + self.assertEqual(wrapper.step('a'), 'y') + + def test_step_records_input_output_pair_in_cache(self): + raw = AlternatingNonDetSUL() + wrapper = NonDeterministicSULWrapper(raw) + wrapper.pre() + wrapper.step('a') + self.assertIsNotNone(wrapper.cache.get_to_node(('a',), ('x',))) + + def test_step_moves_cache_cursor_along_the_path(self): + raw = AlternatingNonDetSUL() + wrapper = NonDeterministicSULWrapper(raw) + wrapper.pre() + wrapper.step('a') + node_after_first = wrapper.cache.curr_node + wrapper.step('a') + self.assertIs(wrapper.cache.curr_node.parent, node_after_first) + + +class TestQueryAccumulatesTracesInCache(unittest.TestCase): + def test_repeated_queries_accumulate_both_branches_of_a_non_det_input(self): + wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL()) + for _ in range(4): + wrapper.query(('a',)) + traces = wrapper.cache.get_all_traces(((), ()), ('a',)) + self.assertCountEqual(traces, [('x',), ('y',)]) + + def test_deterministic_input_only_ever_records_one_output(self): + wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL()) + for _ in range(3): + wrapper.query(('b',)) + traces = wrapper.cache.get_all_traces(((), ()), ('b',)) + self.assertEqual(traces, [('z',)]) + + def test_query_uses_base_sul_bookkeeping_for_queries_and_steps(self): + wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL()) + wrapper.query(('a', 'b')) + wrapper.query(('a',)) + self.assertEqual(wrapper.num_queries, 2) + self.assertEqual(wrapper.num_steps, 3) + + def test_query_calls_pre_and_post_on_wrapped_sul_each_time(self): + raw = AlternatingNonDetSUL() + wrapper = NonDeterministicSULWrapper(raw) + wrapper.query(('a',)) + wrapper.query(('a',)) + self.assertEqual(raw.pre_calls, 2) + self.assertEqual(raw.post_calls, 2) + + def test_frequency_counter_tracks_how_often_each_branch_was_sampled(self): + wrapper = NonDeterministicSULWrapper(AlternatingNonDetSUL()) + for _ in range(6): + wrapper.query(('a',)) + self.assertEqual(wrapper.cache.get_s_e_sampling_frequency(((), ()), ('a',)), 6) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/non_deterministic/test_onfsm_lstar.py b/tests/learning_algs/non_deterministic/test_onfsm_lstar.py new file mode 100644 index 00000000000..c809c3d4acd --- /dev/null +++ b/tests/learning_algs/non_deterministic/test_onfsm_lstar.py @@ -0,0 +1,64 @@ +import random +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs import run_non_det_Lstar +from aalpy.oracles import RandomWalkEqOracle, RandomWordEqOracle +from aalpy.utils import get_benchmark_ONFSM + +# The original version of this test (tests/test_non_deterministic.py) ran 100 fresh iterations; +# ONFSM learning is inherently randomized (all-weather sampling + random equivalence oracles), so a +# handful of seeded iterations is kept here to catch flakiness while staying fast. +SEEDS = range(8) + + +class TestRunNonDetLstar(unittest.TestCase): + def test_learns_benchmark_onfsm_with_correct_state_count_and_no_cex(self): + onfsm = get_benchmark_ONFSM() + alphabet = onfsm.get_input_alphabet() + + for seed in SEEDS: + random.seed(seed) + sul = AutomatonSUL(onfsm) + oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5) + + learned_onfsm = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=20, print_level=0) + + eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=3000, reset_prob=0.09, + reset_after_cex=True) + cex = eq_oracle.find_cex(learned_onfsm) + + self.assertIsNone(cex, f'seed {seed}: independent oracle found a counterexample') + self.assertEqual(len(learned_onfsm.states), len(onfsm.states), + f'seed {seed}: learned model has wrong number of states') + + def test_return_data_reports_consistent_learning_info(self): + random.seed(0) + onfsm = get_benchmark_ONFSM() + alphabet = onfsm.get_input_alphabet() + sul = AutomatonSUL(onfsm) + oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5) + + learned_onfsm, info = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=20, print_level=0, + return_data=True) + + self.assertEqual(info['automaton_size'], len(learned_onfsm.states)) + self.assertGreaterEqual(info['learning_rounds'], 1) + self.assertGreaterEqual(info['queries_learning'], 1) + + def test_stochastic_flag_returns_stochastic_mealy_machine(self): + from aalpy.automata import StochasticMealyMachine + + random.seed(0) + onfsm = get_benchmark_ONFSM() + alphabet = onfsm.get_input_alphabet() + sul = AutomatonSUL(onfsm) + oracle = RandomWordEqOracle(alphabet, sul, num_walks=200, min_walk_len=2, max_walk_len=5) + + learned = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=20, stochastic=True, print_level=0) + + self.assertIsInstance(learned, StochasticMealyMachine) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py b/tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py new file mode 100644 index 00000000000..78ecfa5806c --- /dev/null +++ b/tests/learning_algs/non_deterministic/test_onfsm_lstar_exhaustive.py @@ -0,0 +1,37 @@ +import random + +import pytest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs import run_non_det_Lstar +from aalpy.oracles import RandomWalkEqOracle, RandomWordEqOracle +from aalpy.utils import get_benchmark_ONFSM + +pytestmark = pytest.mark.exhaustive + +# Full sweep this repo used to run at the root (tests/test_non_deterministic.py) before it was trimmed +# down to 8 seeded iterations (see the sibling test_onfsm_lstar.py): 100 fresh iterations with a wider +# random-walk/sampling budget. ONFSM learning is inherently randomized, so more iterations catch rarer +# non-determinism/sampling edge cases that a small seeded run can miss. +ITERATIONS = 100 + + +def test_learns_benchmark_onfsm_with_correct_state_count_and_no_cex_exhaustive(): + onfsm = get_benchmark_ONFSM() + alphabet = onfsm.get_input_alphabet() + + for i in range(ITERATIONS): + sul = AutomatonSUL(onfsm) + + oracle = RandomWordEqOracle(alphabet, sul, num_walks=500, min_walk_len=2, max_walk_len=5) + + learned_onfsm = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=50, print_level=0) + + eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=10000, reset_prob=0.09, + reset_after_cex=True) + + cex = eq_oracle.find_cex(learned_onfsm) + + assert cex is None, f'iteration {i}: independent oracle found a counterexample' + assert len(learned_onfsm.states) == len(onfsm.states), \ + f'iteration {i}: learned model has wrong number of states' diff --git a/tests/learning_algs/non_deterministic/test_onfsm_observation_table.py b/tests/learning_algs/non_deterministic/test_onfsm_observation_table.py new file mode 100644 index 00000000000..9a9daa6dcdd --- /dev/null +++ b/tests/learning_algs/non_deterministic/test_onfsm_observation_table.py @@ -0,0 +1,174 @@ +import random +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Onfsm, OnfsmState +from aalpy.learning_algs.non_deterministic.NonDeterministicSULWrapper import NonDeterministicSULWrapper +from aalpy.learning_algs.non_deterministic.OnfsmObservationTable import NonDetObservationTable + + +def branching_onfsm(): + """s0 --a/x--> s1 --a/x--> s0 and s0 --a/y--> s2 --a/y--> s0. + A single-input-letter ONFSM whose two non-initial states are distinguishable by the suffix + 'a' (s1 always answers 'x', s2 always answers 'y').""" + s0 = OnfsmState('s0') + s1 = OnfsmState('s1') + s2 = OnfsmState('s2') + s0.transitions['a'].append(('x', s1)) + s0.transitions['a'].append(('y', s2)) + s1.transitions['a'].append(('x', s0)) + s2.transitions['a'].append(('y', s0)) + return Onfsm(s0, [s0, s1, s2]) + + +def wrapped_sul(onfsm): + return NonDeterministicSULWrapper(AutomatonSUL(onfsm)) + + +def close_table(ot): + """Drives the table-closing loop the same way run_non_det_Lstar does: querying missing + observations must be interleaved with get_row_to_close(), since get_extended_S() (and hence + get_row_to_close) only reflects rows the cache has actually been queried for so far.""" + ot.query_missing_observations() + row_to_close = ot.get_row_to_close() + while row_to_close is not None: + ot.query_missing_observations() + row_to_close = ot.get_row_to_close() + ot.clean_obs_table() + + +class TestConstruction(unittest.TestCase): + def test_initializes_A_and_E_from_alphabet(self): + ot = NonDetObservationTable(['a', 'b'], wrapped_sul(branching_onfsm()), 5) + self.assertEqual(ot.A, [('a',), ('b',)]) + self.assertEqual(ot.E, [('a',), ('b',)]) + + def test_S_starts_with_only_the_empty_row(self): + ot = NonDetObservationTable(['a'], wrapped_sul(branching_onfsm()), 5) + self.assertEqual(ot.S, [((), ())]) + + def test_asserts_on_missing_alphabet_or_sul(self): + with self.assertRaises(AssertionError): + NonDetObservationTable(None, wrapped_sul(branching_onfsm()), 5) + with self.assertRaises(AssertionError): + NonDetObservationTable(['a'], None, 5) + + +class TestQueryMissingObservations(unittest.TestCase): + def test_samples_initial_row_at_least_n_times_per_column(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + ot.query_missing_observations() + freq = sul.cache.get_s_e_sampling_frequency(((), ()), ('a',)) + self.assertGreaterEqual(freq, 15) + + def test_first_call_does_not_reach_beyond_depth_one(self): + # get_extended_S() is computed from the cache as it stands *before* query_missing_observations + # runs, so on a fresh table the very first call only samples the empty-prefix row - deeper + # rows only get discovered (and then sampled) on subsequent calls. + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + ot.query_missing_observations() + self.assertEqual(sul.cache.get_all_traces((('a',), ('x',)), ('a',)), []) + + +class TestGetRowToClose(unittest.TestCase): + def test_returns_none_for_a_freshly_closed_single_state_row(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + ot.query_missing_observations() + row = ot.get_row_to_close() + self.assertIsNotNone(row) + self.assertIn(row, ot.S) + + def test_full_closing_loop_reaches_a_fixed_point(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + self.assertIsNone(ot.get_row_to_close()) + + def test_closing_loop_finds_three_distinguishable_rows(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + self.assertEqual(len(ot.S), 3) + hashes = {ot.row_to_hashable(s) for s in ot.S} + self.assertEqual(len(hashes), 3) + + +class TestRowToHashable(unittest.TestCase): + def test_distinguishes_rows_with_different_observed_outputs(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + s1_row = next(s for s in ot.S if s[1] == ('x',)) + s2_row = next(s for s in ot.S if s[1] == ('y',)) + self.assertEqual(ot.row_to_hashable(s1_row), (frozenset({('x',)}),)) + self.assertEqual(ot.row_to_hashable(s2_row), (frozenset({('y',)}),)) + + def test_initial_row_reflects_both_branches(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + self.assertEqual(ot.row_to_hashable(((), ())), (frozenset({('x',), ('y',)}),)) + + +class TestCleanObsTable(unittest.TestCase): + def test_removes_duplicate_rows_that_loop_back_to_an_existing_state(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + + # ('a','a') with outputs ('y','y') loops back to s0 and is thus a duplicate of the empty row + duplicate_row = (('a', 'a'), ('y', 'y')) + ot.S.append(duplicate_row) + ot.query_missing_observations([duplicate_row], ot.E) + + ot.clean_obs_table() + self.assertNotIn(duplicate_row, ot.S) + self.assertEqual(len(ot.S), 3) + + +class TestGenHypothesis(unittest.TestCase): + def test_learned_table_yields_an_equivalent_three_state_onfsm(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + + hypothesis = ot.gen_hypothesis() + self.assertIsInstance(hypothesis, Onfsm) + self.assertEqual(len(hypothesis.states), 3) + self.assertCountEqual(hypothesis.outputs_on_input('a'), ['x', 'y']) + + def test_hypothesis_round_trips_a_known_trace(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + hypothesis = ot.gen_hypothesis() + + hypothesis.reset_to_initial() + self.assertEqual(hypothesis.step_to('a', 'x'), 'x') + self.assertEqual(hypothesis.step_to('a', 'x'), 'x') + self.assertIsNone(hypothesis.step_to('a', 'z')) + + def test_hypothesis_carries_characterization_set(self): + random.seed(0) + sul = wrapped_sul(branching_onfsm()) + ot = NonDetObservationTable(['a'], sul, 15) + close_table(ot) + hypothesis = ot.gen_hypothesis() + self.assertEqual(hypothesis.characterization_set, ot.E) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/non_deterministic/test_trace_tree.py b/tests/learning_algs/non_deterministic/test_trace_tree.py new file mode 100644 index 00000000000..e00c8f959e5 --- /dev/null +++ b/tests/learning_algs/non_deterministic/test_trace_tree.py @@ -0,0 +1,205 @@ +import unittest + +from aalpy.automata import Onfsm, OnfsmState +from aalpy.learning_algs.non_deterministic.TraceTree import TraceTree + + +def branching_onfsm(): + s0 = OnfsmState('s0') + s1 = OnfsmState('s1') + s2 = OnfsmState('s2') + s0.transitions['a'].append(('x', s1)) + s0.transitions['a'].append(('y', s2)) + s1.transitions['a'].append(('x', s0)) + s2.transitions['a'].append(('y', s0)) + return Onfsm(s0, [s0, s1, s2]) + + +class TestTraceTreeConstruction(unittest.TestCase): + def test_new_tree_has_root_and_no_cursor(self): + tree = TraceTree() + self.assertIsNone(tree.root_node.output) + self.assertIsNone(tree.curr_node) + + def test_reset_sets_cursor_to_root(self): + tree = TraceTree() + tree.reset() + self.assertIs(tree.curr_node, tree.root_node) + + +class TestAddToTree(unittest.TestCase): + def test_add_to_tree_creates_child_and_moves_cursor(self): + tree = TraceTree() + tree.reset() + tree.add_to_tree('a', 'x') + self.assertIsNot(tree.curr_node, tree.root_node) + self.assertEqual(tree.curr_node.output, 'x') + self.assertIs(tree.curr_node.parent, tree.root_node) + + def test_repeated_pair_reuses_node_and_bumps_frequency(self): + tree = TraceTree() + tree.reset() + tree.add_to_tree('a', 'x') + node_first = tree.curr_node + tree.reset() + tree.add_to_tree('a', 'x') + self.assertIs(tree.curr_node, node_first) + self.assertEqual(node_first.frequency_counter, 2) + + def test_different_output_for_same_input_creates_sibling(self): + tree = TraceTree() + tree.reset() + tree.add_to_tree('a', 'x') + tree.reset() + tree.add_to_tree('a', 'y') + self.assertEqual(len(tree.root_node.children['a']), 2) + outputs = {child.output for child in tree.root_node.children['a']} + self.assertEqual(outputs, {'x', 'y'}) + + +class TestAddTrace(unittest.TestCase): + def test_add_trace_resets_before_inserting(self): + tree = TraceTree() + tree.reset() + tree.add_to_tree('a', 'x') + tree.add_trace(('a',), ('y',)) + # add_trace resets the cursor to root first, so 'y' is a sibling of 'x', not a child of it + self.assertEqual(len(tree.root_node.children['a']), 2) + + def test_add_trace_builds_full_path(self): + tree = TraceTree() + tree.add_trace(('a', 'a'), ('x', 'y')) + node = tree.get_to_node(('a', 'a'), ('x', 'y')) + self.assertIsNotNone(node) + self.assertEqual(node.get_prefix(), ('x', 'y')) + + +class TestGetToNode(unittest.TestCase): + def test_returns_node_on_known_path(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + node = tree.get_to_node(('a',), ('x',)) + self.assertIsNotNone(node) + + def test_returns_none_on_unknown_output(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertIsNone(tree.get_to_node(('a',), ('z',))) + + def test_returns_root_for_empty_path(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertIs(tree.get_to_node((), ()), tree.root_node) + + +class TestGetAllTraces(unittest.TestCase): + def test_returns_empty_list_when_prefix_is_empty_tuple(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertEqual(tree.get_all_traces((), ('a',)), []) + + def test_returns_empty_list_when_suffix_is_empty(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertEqual(tree.get_all_traces(((), ()), ()), []) + + def test_root_prefix_is_a_2tuple_of_empty_tuples_and_is_queried_normally(self): + # ((), ()) is truthy as a whole (it's a non-empty 2-tuple), so the "not prefix" guard + # in get_all_traces never triggers for it - this is the standard shape of an S-set row. + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertEqual(tree.get_all_traces(((), ()), ('a',)), [('x',)]) + + def test_branches_are_all_returned_for_the_same_prefix(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + tree.add_trace(('a',), ('y',)) + traces = tree.get_all_traces(((), ()), ('a',)) + self.assertCountEqual(traces, [('x',), ('y',)]) + + def test_returns_only_the_suffix_portion_of_the_trace(self): + tree = TraceTree() + tree.add_trace(('a', 'b'), ('x', 'z')) + # prefix identifies the node reached after 'a'/'x', then we trace 'b' from there + traces = tree.get_all_traces((('a',), ('x',)), ('b',)) + self.assertEqual(traces, [('z',)]) + + def test_returns_empty_list_for_unknown_prefix(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertEqual(tree.get_all_traces((('a',), ('z',)), ('a',)), []) + + def test_returns_empty_list_when_suffix_input_never_observed(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertEqual(tree.get_all_traces(((), ()), ('b',)), []) + + +class TestGetTable(unittest.TestCase): + def test_get_table_matches_get_all_traces_per_cell(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + tree.add_trace(('a',), ('y',)) + s = [((), ())] + e = [('a',)] + table = tree.get_table(s, e) + self.assertCountEqual(table[s[0]][e[0]], tree.get_all_traces(s[0], e[0])) + + +class TestSamplingFrequencyAndDistribution(unittest.TestCase): + def test_frequency_counts_repeated_samples(self): + tree = TraceTree() + for _ in range(3): + tree.reset() + tree.add_to_tree('a', 'x') + for _ in range(2): + tree.reset() + tree.add_to_tree('a', 'y') + self.assertEqual(tree.get_s_e_sampling_frequency(((), ()), ('a',)), 5) + + def test_frequency_is_zero_for_never_sampled_path(self): + tree = TraceTree() + tree.add_trace(('a',), ('x',)) + self.assertEqual(tree.get_s_e_sampling_frequency(((), ()), ('b',)), 0) + + def test_frequency_over_multi_step_suffix(self): + tree = TraceTree() + for _ in range(4): + tree.reset() + tree.add_to_tree('a', 'x') + tree.add_to_tree('b', 'z') + self.assertEqual(tree.get_s_e_sampling_frequency(((), ()), ('a', 'b')), 4) + + def test_sampling_distribution_matches_observed_ratios(self): + tree = TraceTree() + for _ in range(3): + tree.reset() + tree.add_to_tree('a', 'x') + for _ in range(1): + tree.reset() + tree.add_to_tree('a', 'y') + distribution = tree.get_sampling_distributions(((), ()), 'a') + self.assertAlmostEqual(distribution['x'], 0.75) + self.assertAlmostEqual(distribution['y'], 0.25) + self.assertAlmostEqual(sum(distribution.values()), 1.0) + + +class TestFindCexInCache(unittest.TestCase): + def test_returns_none_when_cache_agrees_with_hypothesis(self): + onfsm = branching_onfsm() + tree = TraceTree() + tree.add_trace(('a', 'a'), ('x', 'x')) + tree.add_trace(('a', 'a'), ('y', 'y')) + self.assertIsNone(tree.find_cex_in_cache(onfsm)) + + def test_finds_cex_when_cache_disagrees_with_hypothesis(self): + onfsm = branching_onfsm() + tree = TraceTree() + # from s1 (reached via 'a'/'x'), only 'x' is a valid onward output, so 'y' is a cex + tree.add_trace(('a', 'a'), ('x', 'y')) + cex = tree.find_cex_in_cache(onfsm) + self.assertEqual(cex, (['a', 'a'], ['x', 'y'])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/resetless/test_hW.py b/tests/learning_algs/resetless/test_hW.py new file mode 100644 index 00000000000..02ec45b9e04 --- /dev/null +++ b/tests/learning_algs/resetless/test_hW.py @@ -0,0 +1,146 @@ +import pytest + +from aalpy import AutomatonSUL, RandomhWOracle, RandomWphWOracle, bisimilar, generate_random_deterministic_automata, \ + run_hW +from aalpy.SULs import MealySUL + +# trimmed from the original (2400-case, root-level) sweep in tests/test_hW.py: a handful of +# small sizes/seeds across the three automaton types is enough to exercise run_hW's main loop +# without turning the suite into a multi-minute run +SEEDS = list(range(6)) +MODEL_SIZES = [ + (2, 2, 2), + (3, 2, 2), + (4, 2, 3), + (6, 3, 2), +] + +TEST_CASES = [ + pytest.param( + automaton_type, + seed_val, + num_states, + input_size, + output_size, + id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}", + ) + for num_states, input_size, output_size in MODEL_SIZES + for seed_val in SEEDS + for automaton_type in ['dfa', 'moore', 'mealy'] +] + + +@pytest.mark.parametrize("automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES) +@pytest.mark.timeout(5) +def test_hw_seed(automaton_type, seed_val, num_states, input_size, output_size): + from random import seed + + seed(seed_val) + + model = generate_random_deterministic_automata( + automaton_type, + num_states=num_states, + input_alphabet_size=input_size, + output_alphabet_size=output_size, + ) + if not model.is_minimal(): + pytest.skip(f"seed {seed_val} does not produce a minimal model") + if not model.is_strongly_connected(): + pytest.skip( + f"seed {seed_val} does not produce a strongly connected model " + f"for states={num_states}, inputs={input_size}, outputs={output_size}" + ) + + sul = MealySUL(model) + input_alphabet = model.get_input_alphabet() + + learned_model = run_hW(input_alphabet, + sul, + RandomhWOracle(num_testing_steps=1000 * num_states, reset_testing_counter=True), + automaton_type=automaton_type, + query_for_initial_state=True, + print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) + + +STRATEGY_TEST_CASES = [ + pytest.param( + oracle_name, + seed_val, + num_states, + input_size, + output_size, + id=f"oracle={oracle_name}-states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}", + ) + for num_states, input_size, output_size in [(3, 2, 2), (5, 3, 3)] + for seed_val in range(4) + for oracle_name in ['random', 'wp'] +] + + +@pytest.mark.parametrize("oracle_name,seed_val,num_states,input_size,output_size", STRATEGY_TEST_CASES) +@pytest.mark.timeout(5) +def test_hw_eq_oracle(oracle_name, seed_val, num_states, input_size, output_size): + from random import seed + + seed(seed_val) + + model = generate_random_deterministic_automata( + 'mealy', + num_states=num_states, + input_alphabet_size=input_size, + output_alphabet_size=output_size, + ) + if not model.is_minimal() or not model.is_strongly_connected(): + pytest.skip(f"seed {seed_val} does not produce a minimal, strongly connected model") + + sul = AutomatonSUL(model) + input_alphabet = model.get_input_alphabet() + + if oracle_name == 'wp': + eq_oracle = RandomWphWOracle(random_walk_length=4 * num_states, + num_test_origin_states=4 * num_states) + else: + eq_oracle = RandomhWOracle(num_testing_steps=1000 * num_states) + + learned_model = run_hW(input_alphabet, + sul, + eq_oracle, + automaton_type='mealy', + query_for_initial_state=True, + print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) + + +def test_hw_uses_user_provided_h_and_w(): + from random import seed + seed(1) + + model = generate_random_deterministic_automata( + 'mealy', + num_states=20, + input_alphabet_size=4, + output_alphabet_size=4, + ) + + sul = AutomatonSUL(model) + input_alphabet = model.get_input_alphabet() + + char_set = model.compute_characterization_set() + + assert model.is_minimal() + + learned_model = run_hW(input_alphabet, + sul, + RandomhWOracle(num_testing_steps=2000), + automaton_type='mealy', + provided_characterization_set=char_set, + query_for_initial_state=True, + print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) diff --git a/tests/learning_algs/resetless/test_hW_exhaustive.py b/tests/learning_algs/resetless/test_hW_exhaustive.py new file mode 100644 index 00000000000..e3dc9387c40 --- /dev/null +++ b/tests/learning_algs/resetless/test_hW_exhaustive.py @@ -0,0 +1,160 @@ +import pytest + +from aalpy import AutomatonSUL, RandomhWOracle, RandomWphWOracle, bisimilar, generate_random_deterministic_automata, \ + run_hW +from aalpy.SULs import MealySUL + +pytestmark = pytest.mark.exhaustive + +# Full sweep this repo used to run at the root (tests/test_hW.py) before it was trimmed down to a fast +# default (see the sibling test_hW.py in this directory): 16 sizes x 50 seeds x 3 automaton types = 2400 +# cases for test_hw_seed_exhaustive, plus a wider strategy sweep below. +SEEDS = list(range(50)) +MODEL_SIZES = [ + (2, 2, 2), + (2, 2, 3), + (3, 2, 2), + (3, 2, 3), + (3, 3, 2), + (4, 2, 3), + (4, 3, 2), + (5, 3, 3), + (6, 2, 3), + (6, 3, 2), + (10, 2, 3), + (10, 2, 4), + (10, 2, 2), + (10, 2, 3), + (20, 5, 5), + (30, 3, 4), +] + +TEST_CASES = [ + pytest.param( + automaton_type, + seed_val, + num_states, + input_size, + output_size, + id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}", + ) + for num_states, input_size, output_size in MODEL_SIZES + for seed_val in SEEDS + for automaton_type in ['dfa', 'moore', 'mealy'] +] + + +@pytest.mark.parametrize("automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES) +@pytest.mark.timeout(5) +def test_hw_seed_exhaustive(automaton_type, seed_val, num_states, input_size, output_size): + from random import seed + + seed(seed_val) + + model = generate_random_deterministic_automata( + automaton_type, + num_states=num_states, + input_alphabet_size=input_size, + output_alphabet_size=output_size, + ) + if not model.is_minimal(): + pytest.skip(f"seed {seed_val} does not produce a minimal model") + if not model.is_strongly_connected(): + pytest.skip( + f"seed {seed_val} does not produce a strongly connected model " + f"for states={num_states}, inputs={input_size}, outputs={output_size}" + ) + + sul = MealySUL(model) + input_alphabet = model.get_input_alphabet() + + learned_model = run_hW(input_alphabet, + sul, + RandomhWOracle(num_testing_steps=1000 * num_states, reset_testing_counter=True), + automaton_type=automaton_type, + query_for_initial_state=True, + print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) + + +STRATEGY_TEST_CASES = [ + pytest.param( + oracle_name, + seed_val, + num_states, + input_size, + output_size, + id=f"oracle={oracle_name}-states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}", + ) + for num_states, input_size, output_size in [(3, 2, 2), (5, 3, 3), (10, 2, 3)] + for seed_val in range(10) + for oracle_name in ['random', 'wp'] +] + + +@pytest.mark.parametrize("oracle_name,seed_val,num_states,input_size,output_size", STRATEGY_TEST_CASES) +@pytest.mark.timeout(5) +def test_hw_eq_oracle_exhaustive(oracle_name, seed_val, num_states, input_size, output_size): + from random import seed + + seed(seed_val) + + model = generate_random_deterministic_automata( + 'mealy', + num_states=num_states, + input_alphabet_size=input_size, + output_alphabet_size=output_size, + ) + if not model.is_minimal() or not model.is_strongly_connected(): + pytest.skip(f"seed {seed_val} does not produce a minimal, strongly connected model") + + sul = AutomatonSUL(model) + input_alphabet = model.get_input_alphabet() + + if oracle_name == 'wp': + eq_oracle = RandomWphWOracle(random_walk_length=4 * num_states, + num_test_origin_states=4 * num_states) + else: + eq_oracle = RandomhWOracle(num_testing_steps=1000 * num_states) + + learned_model = run_hW(input_alphabet, + sul, + eq_oracle, + automaton_type='mealy', + query_for_initial_state=True, + print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) + + +def test_hw_uses_user_provided_h_and_w_exhaustive(): + from random import seed + seed(1) + + model = generate_random_deterministic_automata( + 'mealy', + num_states=100, + input_alphabet_size=4, + output_alphabet_size=4, + ) + + sul = AutomatonSUL(model) + input_alphabet = model.get_input_alphabet() + + char_set = model.compute_characterization_set() + + assert model.is_minimal() + + learned_model = run_hW(input_alphabet, + sul, + RandomhWOracle(num_testing_steps=2000), + automaton_type='mealy', + provided_characterization_set=char_set, + query_for_initial_state=True, + print_level=0) + + assert learned_model.is_minimal() + assert bisimilar(model, learned_model) diff --git a/tests/learning_algs/resetless/test_hw_datastructures.py b/tests/learning_algs/resetless/test_hw_datastructures.py new file mode 100644 index 00000000000..bbc3d15d1ca --- /dev/null +++ b/tests/learning_algs/resetless/test_hw_datastructures.py @@ -0,0 +1,130 @@ +import unittest + +from aalpy.learning_algs.resetless.hW_datastructures import HomingSequenceIndex, ModelState + + +class ModelStateTests(unittest.TestCase): + + def test_new_state_has_empty_bookkeeping(self): + state = ModelState(('a', 'b')) + self.assertEqual(state.hs, ('a', 'b')) + self.assertEqual(state.state_w_values, {}) + self.assertEqual(state.transitions, {}) + self.assertEqual(state.output_fun, {}) + self.assertEqual(state.transition_w_values, {}) + self.assertEqual(len(state.learned_w_per_input), 0) + + def test_learned_w_per_input_auto_creates_set_per_key(self): + state = ModelState(()) + state.learned_w_per_input['a'].add(('w',)) + self.assertEqual(state.learned_w_per_input['a'], {('w',)}) + # accessing an unseen key must not pollute any other key's set + self.assertEqual(state.learned_w_per_input['b'], set()) + state.learned_w_per_input['b'].add(('other',)) + self.assertEqual(state.learned_w_per_input['a'], {('w',)}) + + def test_learned_w_per_input_is_independent_per_instance(self): + # a shared mutable default here would make every ModelState alias the + # same per-input sets, silently corrupting unrelated states + state1 = ModelState(('x',)) + state2 = ModelState(('y',)) + state1.learned_w_per_input['a'].add(('w',)) + self.assertEqual(state2.learned_w_per_input['a'], set()) + + +class HomingSequenceIndexTests(unittest.TestCase): + + def test_empty_h_never_flags_nondeterminism(self): + index = HomingSequenceIndex() + trace = [('a', 'x'), ('a', 'x'), ('a', 'y')] + self.assertIsNone(index.scan(trace, h=())) + self.assertEqual(index.continuation_starts(('x',)), ()) + + def test_reset_clears_pairs_and_advances_scan_position(self): + index = HomingSequenceIndex() + trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '1')] + index.scan(trace, h=('a',)) + self.assertNotEqual(index.continuation_starts(('x',)), ()) + + index.reset(trace_len=len(trace)) + self.assertEqual(index.continuation_starts(('x',)), ()) + self.assertEqual(index._scan_pos, len(trace)) + self.assertEqual(index._next_occ_min_start, len(trace)) + self.assertEqual(index._pair_progress, {}) + + def test_scan_is_none_when_continuations_after_same_response_agree(self): + index = HomingSequenceIndex() + # h='a' occurs at position 0 and 2, both with response 'x'; both + # continuations produce the same ('b', '1') step, so h stays consistent + trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '1')] + self.assertIsNone(index.scan(trace, h=('a',))) + self.assertEqual(list(index.continuation_starts(('x',))), [1, 3]) + + def test_scan_detects_output_divergence_between_same_response_occurrences(self): + index = HomingSequenceIndex() + # h='a' occurs at position 0 and 2, both with response 'x', but the + # continuations disagree on the output of 'b' ('1' vs '2') + trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '2')] + extension = index.scan(trace, h=('a',)) + self.assertEqual(extension, ('b',)) + + def test_scan_returns_none_for_pairs_with_different_response(self): + index = HomingSequenceIndex() + # h='a' occurs twice but with different responses ('x' vs 'y'), so the + # two occurrences are never paired and no divergence can be reported + trace = [('a', 'x'), ('b', '1'), ('a', 'y'), ('b', '2')] + self.assertIsNone(index.scan(trace, h=('a',))) + + def test_scan_deletes_pair_once_inputs_diverge_without_reporting(self): + index = HomingSequenceIndex() + # continuations diverge on the *input* executed, not the output, so this + # is not h-non-determinism and must not be reported + trace = [('a', 'x'), ('b', '1'), ('a', 'x'), ('c', '1')] + self.assertIsNone(index.scan(trace, h=('a',))) + # the pair was dropped, so a later matching continuation does not + # magically resurrect a stale comparison + self.assertEqual(index._pair_progress, {}) + + def test_self_overlapping_occurrence_is_skipped_unless_forced(self): + index = HomingSequenceIndex() + # h = ('a', 'a') inside a run of four 'a's: occurrences start at 0, 1, 2 + # (all matching), but the one at 1 overlaps the just-registered occurrence + # at 0 (its continuation at 3 falls before the next allowed start) and is + # skipped as incidental; 0 and 2 are far enough apart to both register + trace = [('a', 'x'), ('a', 'y'), ('a', 'x'), ('a', 'y')] + h = ('a', 'a') + + index.scan(trace, h) + starts = sorted(c for starts in index._hs_cont_starts.values() for c in starts) + self.assertEqual(starts, [2, 4]) + self.assertNotIn(3, starts) + + def test_forced_cont_start_registers_otherwise_skipped_occurrence(self): + index = HomingSequenceIndex() + trace = [('a', 'x'), ('a', 'y'), ('a', 'x'), ('a', 'y')] + h = ('a', 'a') + + # continuation at position 3 belongs to the overlapping occurrence + # starting at 1, which would normally be skipped as incidental + index.scan(trace, h, forced_cont_start=3) + starts = sorted(c for starts in index._hs_cont_starts.values() for c in starts) + self.assertEqual(starts, [2, 3]) + + def test_continuation_starts_returns_empty_tuple_for_unknown_response(self): + index = HomingSequenceIndex() + index.scan([('a', 'x'), ('b', '1'), ('a', 'x'), ('b', '1')], h=('a',)) + self.assertEqual(index.continuation_starts(('never', 'seen')), ()) + + def test_incremental_scan_only_processes_newly_added_trace(self): + index = HomingSequenceIndex() + h = ('a',) + trace = [('a', 'x'), ('b', '1')] + self.assertIsNone(index.scan(trace, h)) + + trace = trace + [('a', 'x'), ('b', '2')] + extension = index.scan(trace, h) + self.assertEqual(extension, ('b',)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/resetless/test_resetless_oracles.py b/tests/learning_algs/resetless/test_resetless_oracles.py new file mode 100644 index 00000000000..1e74af4510a --- /dev/null +++ b/tests/learning_algs/resetless/test_resetless_oracles.py @@ -0,0 +1,279 @@ +import random +import unittest + +from aalpy.automata import MealyMachine, MealyState, MooreMachine, MooreState +from aalpy.learning_algs.resetless.resetless_oracles import ( + RandomhWOracle, + RandomWphWOracle, + find_counterexample_in_trace, + hWOracle, +) +from aalpy.SULs import AutomatonSUL + + +def chain_mealy(length, alphabet=('a', 'b')): + """Chain of states s0 -> s1 -> ... -> s_length, advanced by the first letter of alphabet, looping on the rest.""" + states = [MealyState(f's{i}') for i in range(length + 1)] + first = alphabet[0] + for i in range(length): + states[i].transitions = {a: states[i] for a in alphabet} + states[i].transitions[first] = states[i + 1] + states[i].output_fun = {a: 'o' for a in alphabet} + states[length].transitions = {a: states[length] for a in alphabet} + states[length].output_fun = {a: 'o' for a in alphabet} + mm = MealyMachine(states[0], states) + mm.compute_prefixes() + return mm + + +def ring_mealy(size, alphabet=('a', 'b')): + """ + Ring of `size` states: 'a' advances cyclically s0->s1->...->s(size-1)->s0, 'b' self-loops. + Strongly connected, so every state can reach every other state (unlike chain_mealy). + """ + states = [MealyState(f's{i}') for i in range(size)] + first = alphabet[0] + for i in range(size): + states[i].transitions = {a: states[i] for a in alphabet} + states[i].transitions[first] = states[(i + 1) % size] + states[i].output_fun = {a: 'o' for a in alphabet} + mm = MealyMachine(states[0], states) + mm.compute_prefixes() + return mm + + +class FakeLearner: + """Minimal stand-in for hW exposing just what the resetless oracles/backstop need.""" + + def __init__(self, sul, input_alphabet, W=None, is_moore=False): + self.sul = sul + self.input_alphabet = input_alphabet + self.W = W if W is not None else [] + self.is_moore = is_moore + self.global_trace = [] + + def step_wrapper(self, letter): + output = self.sul.step(letter) + self.global_trace.append((letter, output)) + return output + + +class HWOracleBaseTests(unittest.TestCase): + + def test_find_cex_is_not_implemented_on_base_class(self): + oracle = hWOracle() + self.assertIsNone(oracle.learner) + self.assertEqual(oracle.num_steps, 0) + with self.assertRaises(NotImplementedError): + oracle.find_cex(hypothesis=None) + + def test_execute_and_compare_stops_at_first_mismatch_and_counts_steps(self): + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + hypothesis.states[1].output_fun['a'] = 'x' + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b']) + oracle = hWOracle() + oracle.learner = learner + + cex = [] + mismatched = oracle._execute_and_compare(hypothesis, ('a', 'a', 'a'), cex) + + self.assertTrue(mismatched) + # the reference walks s0->s1->s2 while the hypothesis is stepped in lock-step; + # the divergent output_fun edit is only observed on the second 'a' (from s1) + self.assertEqual(cex, ['a', 'a']) + self.assertEqual(oracle.num_steps, 2) + + def test_execute_and_compare_returns_false_when_no_mismatch(self): + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b']) + oracle = hWOracle() + oracle.learner = learner + + cex = [] + mismatched = oracle._execute_and_compare(hypothesis, ('a', 'b', 'a'), cex) + + self.assertFalse(mismatched) + self.assertEqual(cex, ['a', 'b', 'a']) + self.assertEqual(oracle.num_steps, 3) + + +class RandomhWOracleTests(unittest.TestCase): + + def test_finds_cex_over_several_seeds(self): + successes = 0 + for seed_val in range(10): + random.seed(seed_val) + reference = chain_mealy(6) + hypothesis = chain_mealy(6) + hypothesis.states[6].output_fun['a'] = 'x' + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b']) + oracle = RandomhWOracle(num_testing_steps=500) + oracle.learner = learner + + cex = oracle.find_cex(hypothesis) + if cex is not None: + successes += 1 + self.assertEqual(cex[-1], 'a') + + self.assertGreaterEqual(successes, 9) + + def test_no_cex_for_equivalent_hypothesis(self): + random.seed(0) + reference = chain_mealy(4) + hypothesis = chain_mealy(4) + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b']) + oracle = RandomhWOracle(num_testing_steps=500) + oracle.learner = learner + + self.assertIsNone(oracle.find_cex(hypothesis)) + self.assertEqual(oracle.num_steps, 500) + + def test_reset_testing_counter_false_shares_budget_across_calls(self): + random.seed(0) + reference = chain_mealy(4) + hypothesis = chain_mealy(4) + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b']) + oracle = RandomhWOracle(num_testing_steps=10, reset_testing_counter=False) + oracle.learner = learner + + oracle.find_cex(hypothesis) + self.assertEqual(oracle.num_steps, 10) + + # the whole budget of 10 was already consumed by the first call, so a + # second call must execute zero further steps + oracle.find_cex(hypothesis) + self.assertEqual(oracle.num_steps, 10) + + def test_reset_testing_counter_true_replenishes_budget_each_call(self): + random.seed(0) + reference = chain_mealy(4) + hypothesis = chain_mealy(4) + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b']) + oracle = RandomhWOracle(num_testing_steps=10, reset_testing_counter=True) + oracle.learner = learner + + oracle.find_cex(hypothesis) + oracle.find_cex(hypothesis) + self.assertEqual(oracle.num_steps, 20) + + +class RandomWphWOracleTests(unittest.TestCase): + + def test_finds_cex_over_several_seeds(self): + successes = 0 + for seed_val in range(10): + random.seed(seed_val) + reference = ring_mealy(4) + hypothesis = ring_mealy(4) + hypothesis.states[2].output_fun['b'] = 'x' + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b'], W=[('a',), ('b',)]) + oracle = RandomWphWOracle(random_walk_length=10, num_test_origin_states=8) + oracle.learner = learner + + cex = oracle.find_cex(hypothesis) + if cex is not None: + successes += 1 + + self.assertGreaterEqual(successes, 9) + + def test_no_cex_for_equivalent_hypothesis(self): + random.seed(0) + reference = chain_mealy(4) + hypothesis = chain_mealy(4) + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b'], W=[('a',), ('b',)]) + oracle = RandomWphWOracle(random_walk_length=5, num_test_origin_states=5) + oracle.learner = learner + + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_empty_characterization_set_does_not_crash(self): + random.seed(0) + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b'], W=[]) + oracle = RandomWphWOracle(random_walk_length=5, num_test_origin_states=3) + oracle.learner = learner + + self.assertIsNone(oracle.find_cex(hypothesis)) + + def test_zero_origin_states_never_tests_anything(self): + reference = chain_mealy(3) + hypothesis = chain_mealy(3) + hypothesis.states[2].output_fun['a'] = 'x' + + sul = AutomatonSUL(reference) + sul.pre() + learner = FakeLearner(sul, ['a', 'b'], W=[('a',)]) + oracle = RandomWphWOracle(random_walk_length=5, num_test_origin_states=0) + oracle.learner = learner + + self.assertIsNone(oracle.find_cex(hypothesis)) + self.assertEqual(oracle.num_steps, 0) + + +class FindCounterexampleInTraceTests(unittest.TestCase): + + def test_returns_none_for_trace_fully_explained_by_hypothesis(self): + hypothesis = chain_mealy(3) + learner = FakeLearner(sul=None, input_alphabet=['a', 'b'], is_moore=False) + learner.global_trace = [('a', 'o'), ('a', 'o'), ('b', 'o')] + + self.assertIsNone(find_counterexample_in_trace(learner, hypothesis)) + + def test_finds_unexplained_suffix_for_mealy(self): + hypothesis = chain_mealy(3) + learner = FakeLearner(sul=None, input_alphabet=['a', 'b'], is_moore=False) + # 'x' is never produced by any state of the hypothesis + learner.global_trace = [('a', 'o'), ('a', 'x')] + + cex = find_counterexample_in_trace(learner, hypothesis) + + self.assertIsNotNone(cex) + self.assertEqual(cex[-1], 'a') + + def test_finds_unexplained_step_for_moore(self): + s0 = MooreState('s0', output='0') + s1 = MooreState('s1', output='1') + s0.transitions = {'a': s1} + s1.transitions = {'a': s0} + mm = MooreMachine(s0, [s0, s1]) + + learner = FakeLearner(sul=None, input_alphabet=['a'], is_moore=True) + # after one 'a' the hypothesis would be in s1 (output '1'), not '9' + learner.global_trace = [('a', '9')] + + cex = find_counterexample_in_trace(learner, mm) + self.assertEqual(cex, ['a']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic/test_difference_checker.py b/tests/learning_algs/stochastic/test_difference_checker.py new file mode 100644 index 00000000000..38dc7c867e1 --- /dev/null +++ b/tests/learning_algs/stochastic/test_difference_checker.py @@ -0,0 +1,140 @@ +import unittest + +from aalpy.learning_algs.stochastic.DifferenceChecker import ( + AdvancedHoeffdingChecker, + ChiSquareChecker, + HoeffdingChecker, + compute_epsilon, +) + + +class HoeffdingCheckerTest(unittest.TestCase): + + def test_identical_distributions_are_not_different(self): + checker = HoeffdingChecker(alpha=0.05) + c1 = {'x': 50, 'y': 50} + c2 = {'x': 500, 'y': 500} + self.assertFalse(checker.are_cells_different(c1, c2)) + + def test_disjoint_output_supports_are_different(self): + checker = HoeffdingChecker(alpha=0.05) + self.assertTrue(checker.are_cells_different({'x': 10}, {'y': 10})) + + def test_clearly_different_ratios_with_large_samples_are_different(self): + checker = HoeffdingChecker(alpha=0.05) + c1 = {'x': 950, 'y': 50} + c2 = {'x': 50, 'y': 950} + self.assertTrue(checker.are_cells_different(c1, c2)) + + def test_small_samples_with_different_ratios_can_be_indistinguishable(self): + # With very few samples the Hoeffding bound is wide, so even a 1 vs 0 count difference + # should not be flagged as a statistically significant difference. + checker = HoeffdingChecker(alpha=0.05) + self.assertFalse(checker.are_cells_different({'x': 1, 'y': 0}, {'x': 0, 'y': 1})) + + def test_empty_cells_are_not_different(self): + checker = HoeffdingChecker(alpha=0.05) + self.assertFalse(checker.are_cells_different({}, {})) + + def test_difference_value_and_use_diff_value_default_to_unsupported(self): + checker = HoeffdingChecker() + self.assertFalse(checker.use_diff_value()) + self.assertIsNone(checker.difference_value({'x': 1}, {'x': 1})) + + +class ComputeEpsilonTest(unittest.TestCase): + + def test_epsilon_decreases_with_more_samples(self): + small_n = compute_epsilon(0.05, 10) + large_n = compute_epsilon(0.05, 10000) + self.assertGreater(small_n, large_n) + + def test_epsilon_increases_with_smaller_alpha(self): + loose = compute_epsilon(0.5, 100) + strict = compute_epsilon(0.01, 100) + self.assertGreater(strict, loose) + + +class AdvancedHoeffdingCheckerTest(unittest.TestCase): + + def test_identical_distributions_are_not_different(self): + checker = AdvancedHoeffdingChecker(alpha=0.05) + self.assertFalse(checker.are_cells_different({'x': 100, 'y': 100}, {'x': 1000, 'y': 1000})) + + def test_clearly_different_ratios_are_different(self): + checker = AdvancedHoeffdingChecker(alpha=0.05) + self.assertTrue(checker.are_cells_different({'x': 950, 'y': 50}, {'x': 50, 'y': 950})) + + def test_use_diff_value_reflects_constructor_flag(self): + self.assertFalse(AdvancedHoeffdingChecker(use_diff=False).use_diff_value()) + self.assertTrue(AdvancedHoeffdingChecker(use_diff=True).use_diff_value()) + + def test_difference_value_is_symmetric_and_zero_for_identical_cells(self): + checker = AdvancedHoeffdingChecker() + cell = {'x': 10, 'y': 20} + self.assertEqual(checker.difference_value(cell, dict(cell)), 0) + + def test_difference_value_grows_with_more_disagreement(self): + checker = AdvancedHoeffdingChecker() + small_diff = checker.difference_value({'x': 51, 'y': 49}, {'x': 49, 'y': 51}) + large_diff = checker.difference_value({'x': 99, 'y': 1}, {'x': 1, 'y': 99}) + self.assertLess(small_diff, large_diff) + + def test_difference_value_with_one_empty_cell_uses_epsilon_bound(self): + checker = AdvancedHoeffdingChecker(alpha=0.05) + value = checker.difference_value({}, {'x': 10}) + self.assertGreater(value, 0) + + def test_difference_value_both_empty_is_zero(self): + checker = AdvancedHoeffdingChecker() + self.assertEqual(checker.difference_value({}, {}), 0) + + +class ChiSquareCheckerTest(unittest.TestCase): + + def test_invalid_alpha_raises(self): + with self.assertRaises(ValueError): + ChiSquareChecker(alpha=0.1234) + + def test_valid_alphas_are_accepted(self): + for alpha in (0.05, 0.01, 0.001): + ChiSquareChecker(alpha=alpha) + + def test_identical_distributions_are_not_different(self): + checker = ChiSquareChecker(alpha=0.05) + self.assertFalse(checker.are_cells_different({'x': 100, 'y': 100}, {'x': 1000, 'y': 1000})) + + def test_clearly_different_ratios_are_different(self): + checker = ChiSquareChecker(alpha=0.05) + self.assertTrue(checker.are_cells_different({'x': 950, 'y': 50}, {'x': 50, 'y': 950})) + + def test_empty_cell_is_never_different(self): + checker = ChiSquareChecker(alpha=0.05) + self.assertFalse(checker.are_cells_different({}, {'x': 10})) + self.assertFalse(checker.are_cells_different({'x': 10}, {})) + + def test_single_shared_output_key_has_zero_degrees_of_freedom_and_is_not_different(self): + checker = ChiSquareChecker(alpha=0.05) + self.assertFalse(checker.are_cells_different({'x': 5}, {'x': 500})) + + def test_disjoint_supports_falls_back_to_hoeffding_and_flags_large_samples_as_different(self): + checker = ChiSquareChecker(alpha=0.05) + self.assertTrue(checker.are_cells_different({'x': 1000}, {'y': 1000})) + + def test_use_diff_value_reflects_constructor_flag(self): + self.assertFalse(ChiSquareChecker(use_diff_value=False).use_diff_value()) + self.assertTrue(ChiSquareChecker(use_diff_value=True).use_diff_value()) + + def test_difference_value_zero_for_single_degree_of_freedom(self): + checker = ChiSquareChecker() + self.assertEqual(checker.difference_value({'x': 5}, {'x': 500}), 0) + + def test_difference_value_grows_with_disagreement(self): + checker = ChiSquareChecker() + small = checker.difference_value({'x': 51, 'y': 49}, {'x': 49, 'y': 51}) + large = checker.difference_value({'x': 99, 'y': 1}, {'x': 1, 'y': 99}) + self.assertLess(small, large) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic/test_sampling_based_observation_table.py b/tests/learning_algs/stochastic/test_sampling_based_observation_table.py new file mode 100644 index 00000000000..42ad1e5b470 --- /dev/null +++ b/tests/learning_algs/stochastic/test_sampling_based_observation_table.py @@ -0,0 +1,233 @@ +import unittest +from collections import defaultdict + +from aalpy.learning_algs.stochastic.SamplingBasedObservationTable import SamplingBasedObservationTable + + +class _Teacher: + initial_value = [False] + + def complete_query(self, s, e): + return True + + +class _CompatibilityChecker: + def __init__(self, different_pairs=frozenset()): + self.different_pairs = different_pairs + + def are_cells_different(self, c1, c2, **kwargs): + return (id(c1), id(c2)) in self.different_pairs or (id(c2), id(c1)) in self.different_pairs + + def use_diff_value(self): + return False + + +def _reachable_states(automaton): + reachable = set() + stack = [automaton.initial_state] + while stack: + state = stack.pop() + if state in reachable: + continue + reachable.add(state) + for transitions in state.transitions.values(): + for target, probability in transitions: + if probability and target not in reachable: + stack.append(target) + return reachable + + +class StochasticObservationTableTest(unittest.TestCase): + + def test_mdp_representatives_are_access_closed(self): + table = SamplingBasedObservationTable( + ['a', 'b', 'c', 'd'], 'mdp', _Teacher(), _CompatibilityChecker() + ) + + initial = (False,) + first = (False, 'd', False) + second = (False, 'd', False, 'c', False) + third = (False, 'd', False, 'c', False, 'b', False) + discovered = (False, 'd', False, 'c', False, 'b', False, 'c', True) + table.S = [initial, first, second, third, discovered] + + table.T = defaultdict(dict) + for row in table.S: + for inp in table.input_alphabet: + table.T[row][inp] = {row[-1]: 10} + + table.T[initial][('d',)] = {False: 10} + table.T[first][('c',)] = {False: 10} + table.T[second][('b',)] = {False: 10} + table.T[third][('c',)] = {False: 9, True: 1} + + table.update_compatibility_classes() + self.assertTrue(set(table.S).issubset(table.compatibility_classes_representatives)) + + hypothesis = table.generate_hypothesis() + non_chaos_states = {state for state in hypothesis.states if state.output != 'chaos'} + self.assertTrue(non_chaos_states.issubset(_reachable_states(hypothesis))) + + +class AreRowsCompatibleTest(unittest.TestCase): + + def test_mdp_rows_with_different_final_output_are_never_compatible(self): + table = SamplingBasedObservationTable(['a'], 'mdp', _Teacher(), _CompatibilityChecker()) + s1 = (False,) + s2 = (True,) + table.T[s1][('a',)] = {False: 5} + table.T[s2][('a',)] = {False: 5} + # cells are identical (checker would say compatible), but final outputs differ + self.assertFalse(table.are_rows_compatible(s1, s2)) + + def test_smm_rows_use_checker_only(self): + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + s2 = ('a', 'x') + table.T[s1][('a',)] = {'x': 5} + table.T[s2][('a',)] = {'x': 5} + self.assertTrue(table.are_rows_compatible(s1, s2)) + + def test_rows_incompatible_when_checker_flags_a_cell(self): + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + s2 = ('a', 'x') + cell1 = {'x': 5} + cell2 = {'x': 1, 'y': 4} + table.T[s1][('a',)] = cell1 + table.T[s2][('a',)] = cell2 + table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))}) + self.assertFalse(table.are_rows_compatible(s1, s2)) + + +class UpdateCompatibilityClassesTest(unittest.TestCase): + + def test_incompatible_rows_end_up_in_different_classes(self): + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + s2 = ('a', 'x') + cell1 = {'x': 5} + cell2 = {'x': 1, 'y': 4} + table.S = [s1, s2] + table.T[s1][('a',)] = cell1 + table.T[s2][('a',)] = cell2 + table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))}) + + table.update_compatibility_classes() + + self.assertEqual(set(table.compatibility_classes_representatives), {s1, s2}) + self.assertEqual(table.compatibility_class[s1], []) + self.assertEqual(table.compatibility_class[s2], []) + + def test_compatible_rows_merge_into_a_single_class(self): + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + s2 = ('a', 'x') + table.S = [s1, s2] + table.T[s1][('a',)] = {'x': 5} + table.T[s2][('a',)] = {'x': 3} + + table.update_compatibility_classes() + + self.assertEqual(len(table.compatibility_classes_representatives), 1) + rep = table.compatibility_classes_representatives[0] + other = s2 if rep == s1 else s1 + self.assertEqual(table.compatibility_class[rep], [other]) + + +class GetRowToCloseTest(unittest.TestCase): + + def test_returns_none_when_all_extensions_are_covered(self): + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + table.S = [s1] + # no output observed for input 'a' -> get_extended_s yields nothing + table.T[s1][('a',)] = {} + table.freq_query_cache[s1 + ('a',)] = {} + table.update_compatibility_classes() + self.assertIsNone(table.get_row_to_close()) + + def test_returns_uncovered_extension_row(self): + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + lt = ('a', 'x') + table.S = [s1] + cell1 = {'x': 5} + cell2 = {'y': 1} + table.T[s1][('a',)] = cell1 + table.T[lt][('a',)] = cell2 + table.freq_query_cache[s1 + ('a',)] = cell1 + table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))}) + table.update_compatibility_classes() + self.assertEqual(table.get_row_to_close(), lt) + + +class GetConsistencyViolationTest(unittest.TestCase): + + def _build_inconsistent_table(self): + # S = [s1, s2] compatible on E=[('a',)], but their extensions by (i='a', o='x') differ on the same + # column, which is exactly a consistency violation. + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + s2 = ('a', 'x') + table.S = [s1, s2] + # rows are compatible: same content on the only column + table.T[s1][('a',)] = {'x': 5} + table.T[s2][('a',)] = {'x': 5} + + ext1 = s1 + ('a', 'x') # ('a', 'x') + ext2 = s2 + ('a', 'x') # ('a', 'x', 'a', 'x') + cell1 = {'y': 1} + cell2 = {'z': 1} + table.T[ext1][('a',)] = cell1 + table.T[ext2][('a',)] = cell2 + table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))}) + return table, ext1, ext2 + + def test_detects_violation(self): + table, ext1, ext2 = self._build_inconsistent_table() + violation = table.get_consistency_violation() + self.assertEqual(violation, ('a', 'x', 'a')) + + def test_no_violation_when_extension_cells_agree(self): + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + s2 = ('a', 'x') + table.S = [s1, s2] + table.T[s1][('a',)] = {'x': 5} + table.T[s2][('a',)] = {'x': 5} + ext1 = s1 + ('a', 'x') + ext2 = s2 + ('a', 'x') + table.T[ext1][('a',)] = {'y': 1} + table.T[ext2][('a',)] = {'y': 1} + self.assertIsNone(table.get_consistency_violation()) + + def test_none_when_cex_processing_enabled(self): + table, _, _ = self._build_inconsistent_table() + table.cex_processing = 'longest_prefix' + self.assertIsNone(table.get_consistency_violation()) + + +class GetUnambPercentageTest(unittest.TestCase): + + def test_representative_rows_are_unambiguous_but_unexplored_extensions_are_not(self): + # S rows s1/s2 are in disjoint compatibility classes (unambiguous), but the freshly + # discovered extension rows have no data in T yet, so they are (correctly) compatible + # with every representative and therefore counted as ambiguous. + table = SamplingBasedObservationTable(['a'], 'smm', _Teacher(), _CompatibilityChecker()) + s1 = () + s2 = ('a', 'x') + cell1 = {'x': 5} + cell2 = {'x': 1, 'y': 4} + table.S = [s1, s2] + table.T[s1][('a',)] = cell1 + table.T[s2][('a',)] = cell2 + table.freq_query_cache[s1 + ('a',)] = cell1 + table.freq_query_cache[s2 + ('a',)] = cell2 + table.compatibility_checker = _CompatibilityChecker(different_pairs={(id(cell1), id(cell2))}) + + self.assertEqual(table.get_unamb_percentage(), 50.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic/test_stochastic_cex_processing.py b/tests/learning_algs/stochastic/test_stochastic_cex_processing.py new file mode 100644 index 00000000000..219a23de9da --- /dev/null +++ b/tests/learning_algs/stochastic/test_stochastic_cex_processing.py @@ -0,0 +1,93 @@ +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Mdp, MdpState +from aalpy.learning_algs.stochastic.StochasticCexProcessing import stochastic_longest_prefix, stochastic_rs + + +class StochasticLongestPrefixTest(unittest.TestCase): + + def test_no_matching_prefix_uses_whole_cex(self): + # mdp-style cex: output, input, output, input, output, ... + cex = ('o0', 'a', 'o1', 'b', 'o2') + prefixes = [('does', 'not', 'match')] + suffixes = stochastic_longest_prefix(cex, prefixes) + # suffixes are ordered shortest (index 0) to longest (last == the whole, untrimmed cex) + self.assertEqual(suffixes[0], ('o2',)) + self.assertEqual(suffixes[-1], cex) + self.assertEqual(len(suffixes), 3) + + def test_matching_prefix_trims_cex(self): + cex = ('o0', 'a', 'o1', 'b', 'o2') + # a prefix matching the leading input 'a' (comparing at odd indices, i.e. inputs) trims the cex down + # to ('b', 'o2'), which (having even length) yields a single length-1 suffix. + prefixes = [('o0', 'a', 'o1')] + suffixes = stochastic_longest_prefix(cex, prefixes) + self.assertEqual(suffixes, [('o2',)]) + + def test_longest_matching_prefix_is_preferred(self): + cex = ('o0', 'a', 'o1', 'b', 'o2', 'c', 'o3') + short_prefix = ('o0', 'a', 'o1') + long_prefix = ('o0', 'a', 'o1', 'b', 'o2') + # the short prefix alone would trim to ('b', 'o2', 'c', 'o3') (even length -> 2 suffixes); since the + # long prefix is tried first (prefixes are sorted by length, descending) and also matches, it wins, + # trimming to ('c', 'o3') (even length -> a single suffix). + suffixes_long_preferred = stochastic_longest_prefix(cex, [short_prefix, long_prefix]) + self.assertEqual(suffixes_long_preferred, [('o3',)]) + + suffixes_short_only = stochastic_longest_prefix(cex, [short_prefix]) + self.assertEqual(suffixes_short_only, [('o3',), ('o2', 'c', 'o3')]) + + def test_full_match_returns_empty_tuple(self): + cex = ('o0',) + prefixes = [('o0',)] + result = stochastic_longest_prefix(cex, prefixes) + self.assertEqual(result, ()) + + def test_suffixes_are_all_suffixes_of_trimmed_cex_with_odd_lengths(self): + cex = ('o0', 'a', 'o1', 'b', 'o2', 'c', 'o3') + suffixes = stochastic_longest_prefix(cex, []) + for suf in suffixes: + self.assertEqual(cex[len(cex) - len(suf):], suf) + self.assertEqual(len(suf) % 2, 1) + + +def _hypothesis_mdp(): + """ + 2-state deterministic MDP hypothesis: s0(A) --a--> s1(B) --a--> s0(A). Prefixes are assigned in the same + (initial_output, i1, o1, i2, o2, ...) format used by SamplingBasedObservationTable.generate_hypothesis. + """ + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s0.transitions['a'].append((s1, 1.0)) + s1.transitions['a'].append((s0, 1.0)) + s0.prefix = ('A',) + s1.prefix = ('A', 'a', 'B') + return Mdp(s0, [s0, s1]) + + +def _ground_truth_sul(): + """Ground truth: single-state deterministic MDP whose output is always 'X', diverging immediately from + the alternating A/B hypothesis above.""" + g0 = MdpState('g0', output='X') + g0.transitions['a'].append((g0, 1.0)) + return AutomatonSUL(Mdp(g0, [g0])) + + +class StochasticRsTest(unittest.TestCase): + + def test_rs_returns_suffix_pinpointing_first_divergence(self): + hypothesis = _hypothesis_mdp() + sul = _ground_truth_sul() + # real trace observed on the ground truth SUL (all outputs 'X'), in mdp cex format (o0, i1, o1, i2, o2) + cex = ('X', 'a', 'X', 'a', 'X') + + suffixes = stochastic_rs(sul, cex, hypothesis) + + self.assertEqual(suffixes, [('X',), ('X', 'a', 'X')]) + for suf in suffixes: + self.assertEqual(len(suf) % 2, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic/test_stochastic_lstar_integration.py b/tests/learning_algs/stochastic/test_stochastic_lstar_integration.py new file mode 100644 index 00000000000..3c792f36be5 --- /dev/null +++ b/tests/learning_algs/stochastic/test_stochastic_lstar_integration.py @@ -0,0 +1,104 @@ +import random +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Mdp, MdpState +from aalpy.learning_algs import run_stochastic_Lstar +from aalpy.oracles import RandomWalkEqOracle + +# NOTE on why this does not follow the legacy tests/test_stochastic.py verification approach: +# that test hardcoded aalpy.paths.path_to_prism to a Windows path and called model_check_experiment, which +# shells out to a real PRISM binary. PRISM is not installed on this machine (or in CI), so that check would +# always fail with FileNotFoundError. Instead we validate the learned model directly against the ground +# truth by sampling: comparing per-input-sequence output distributions between the ground truth MDP and the +# learned model (both Mdp and StochasticMealyMachine expose execute_sequence(state, input_seq) returning one +# output per input, so no PRISM/conversion step is required for this comparison). + + +def _ground_truth_mdp(): + """ + 3-state MDP ("biased grid"): s0(A) --a--> s1(B) w.p. 0.7 / s2(C) w.p. 0.3; s0 --b--> s0. + s1(B)/s2(C) are absorbing back to s0 on 'a', and self-loop on 'b'. + """ + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s2 = MdpState('s2', output='C') + s0.transitions['a'].append((s1, 0.7)) + s0.transitions['a'].append((s2, 0.3)) + s0.transitions['b'].append((s0, 1.0)) + s1.transitions['a'].append((s0, 1.0)) + s1.transitions['b'].append((s1, 1.0)) + s2.transitions['a'].append((s0, 1.0)) + s2.transitions['b'].append((s2, 1.0)) + return Mdp(s0, [s0, s1, s2]) + + +def _output_distribution(model, input_seq, n_samples): + """Samples model.execute_sequence(initial_state, input_seq) n_samples times and returns a normalized + frequency dict over the resulting output tuples.""" + counts = {} + for _ in range(n_samples): + outputs = tuple(model.execute_sequence(model.initial_state, list(input_seq))) + counts[outputs] = counts.get(outputs, 0) + 1 + return {k: v / n_samples for k, v in counts.items()} + + +def _total_variation_distance(dist1, dist2): + keys = set(dist1) | set(dist2) + return 0.5 * sum(abs(dist1.get(k, 0) - dist2.get(k, 0)) for k in keys) + + +def _assert_models_behave_similarly(test_case, ground_truth, learned_model, seed=123, n_samples=300, tolerance=0.35): + random.seed(seed) + input_sequences = [('a',), ('b', 'a'), ('a', 'a'), ('a', 'b', 'a')] + for input_seq in input_sequences: + truth_dist = _output_distribution(ground_truth, input_seq, n_samples) + learned_dist = _output_distribution(learned_model, input_seq, n_samples) + tvd = _total_variation_distance(truth_dist, learned_dist) + test_case.assertLessEqual( + tvd, tolerance, + f'Output distribution for {input_seq} diverged too much: truth={truth_dist} learned={learned_dist}') + + +class StochasticLStarIntegrationTest(unittest.TestCase): + """ + Trimmed-down sweep over run_stochastic_Lstar: one representative combination per automaton_type, plus a + couple of extra strategy/cex_processing/samples_cex_strategy variations, instead of the full 2x3x2x3 + cross-product from the legacy test (kept fast, and every combination exercises the same core algorithm). + """ + + def _learn(self, automaton_type, strategy, cex_processing, samples_cex_strategy, seed): + random.seed(seed) + ground_truth = _ground_truth_mdp() + input_alphabet = ground_truth.get_input_alphabet() + sul = AutomatonSUL(ground_truth) + eq_oracle = RandomWalkEqOracle(input_alphabet, sul=sul, num_steps=150, reset_prob=0.25, + reset_after_cex=True) + + learned_model = run_stochastic_Lstar( + input_alphabet=input_alphabet, eq_oracle=eq_oracle, sul=sul, + n_c=20, n_resample=100, min_rounds=5, max_rounds=20, + automaton_type=automaton_type, strategy=strategy, cex_processing=cex_processing, + samples_cex_strategy=samples_cex_strategy, target_unambiguity=0.99, print_level=0) + + return ground_truth, learned_model + + def test_learn_mdp_classic_strategy(self): + ground_truth, learned_model = self._learn('mdp', 'classic', None, None, seed=1) + _assert_models_behave_similarly(self, ground_truth, learned_model) + + def test_learn_smm_normal_strategy_with_longest_prefix_cex(self): + ground_truth, learned_model = self._learn('smm', 'normal', 'longest_prefix', None, seed=2) + _assert_models_behave_similarly(self, ground_truth, learned_model) + + def test_learn_mdp_chi2_strategy_with_bfs_samples_cex(self): + ground_truth, learned_model = self._learn('mdp', 'chi2', None, 'bfs', seed=3) + _assert_models_behave_similarly(self, ground_truth, learned_model) + + def test_learn_smm_classic_strategy_with_random_samples_cex(self): + ground_truth, learned_model = self._learn('smm', 'classic', None, 'random:200:0.3', seed=4) + _assert_models_behave_similarly(self, ground_truth, learned_model) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py b/tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py new file mode 100644 index 00000000000..58998228243 --- /dev/null +++ b/tests/learning_algs/stochastic/test_stochastic_lstar_integration_exhaustive.py @@ -0,0 +1,59 @@ +import random +import unittest + +import pytest + +from aalpy.SULs import AutomatonSUL +from aalpy.learning_algs import run_stochastic_Lstar +from aalpy.oracles import RandomWalkEqOracle +from tests.learning_algs.stochastic.test_stochastic_lstar_integration import ( + _assert_models_behave_similarly, _ground_truth_mdp) + +pytestmark = pytest.mark.exhaustive + +# Full cross-product this repo's legacy tests/test_stochastic.py swept (minus its PRISM dependency, dropped +# for the same reason as the sibling test_stochastic_lstar_integration.py): 2 automaton_types x 3 strategies +# x 2 cex_processing x 3 samples_cex_strategy = 36 combinations, instead of the trimmed handful kept in the +# fast default suite. +AUTOMATON_TYPES = ['mdp', 'smm'] +STRATEGIES = ['classic', 'normal', 'chi2'] +CEX_PROCESSING = [None, 'longest_prefix'] +SAMPLES_CEX_STRATEGY = [None, 'bfs', 'random:200:0.3'] + +TEST_CASES = [ + (automaton_type, strategy, cex_processing, samples_cex_strategy) + for automaton_type in AUTOMATON_TYPES + for strategy in STRATEGIES + for cex_processing in CEX_PROCESSING + for samples_cex_strategy in SAMPLES_CEX_STRATEGY +] + + +class StochasticLStarExhaustiveTest(unittest.TestCase): + def _learn(self, automaton_type, strategy, cex_processing, samples_cex_strategy, seed): + random.seed(seed) + ground_truth = _ground_truth_mdp() + input_alphabet = ground_truth.get_input_alphabet() + sul = AutomatonSUL(ground_truth) + eq_oracle = RandomWalkEqOracle(input_alphabet, sul=sul, num_steps=200, reset_prob=0.25, + reset_after_cex=True) + + learned_model = run_stochastic_Lstar( + input_alphabet=input_alphabet, eq_oracle=eq_oracle, sul=sul, + n_c=20, n_resample=1000, min_rounds=10, max_rounds=100, + automaton_type=automaton_type, strategy=strategy, cex_processing=cex_processing, + samples_cex_strategy=samples_cex_strategy, target_unambiguity=0.99, print_level=0) + + return ground_truth, learned_model + + def test_full_sweep(self): + for seed, (automaton_type, strategy, cex_processing, samples_cex_strategy) in enumerate(TEST_CASES): + with self.subTest(automaton_type=automaton_type, strategy=strategy, + cex_processing=cex_processing, samples_cex_strategy=samples_cex_strategy): + ground_truth, learned_model = self._learn(automaton_type, strategy, cex_processing, + samples_cex_strategy, seed=seed) + _assert_models_behave_similarly(self, ground_truth, learned_model) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic/test_stochastic_teacher.py b/tests/learning_algs/stochastic/test_stochastic_teacher.py new file mode 100644 index 00000000000..422fc8b9c0b --- /dev/null +++ b/tests/learning_algs/stochastic/test_stochastic_teacher.py @@ -0,0 +1,181 @@ +import random +import unittest + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Mdp, MdpState +from aalpy.learning_algs.stochastic.DifferenceChecker import HoeffdingChecker +from aalpy.learning_algs.stochastic.StochasticTeacher import Node, StochasticTeacher + + +def _branching_mdp(): + """ + s0(A) --a--> s1(B) with prob 0.7, s2(C) with prob 0.3; s0 --b--> s0(A) with prob 1.0. + s1, s2 --a/b--> s0 with prob 1.0 (absorbing back). + """ + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s2 = MdpState('s2', output='C') + s0.transitions['a'].append((s1, 0.7)) + s0.transitions['a'].append((s2, 0.3)) + s0.transitions['b'].append((s0, 1.0)) + s1.transitions['a'].append((s0, 1.0)) + s1.transitions['b'].append((s0, 1.0)) + s2.transitions['a'].append((s0, 1.0)) + s2.transitions['b'].append((s0, 1.0)) + return Mdp(s0, [s0, s1, s2]) + + +class _NullEqOracle: + num_queries = 0 + num_steps = 0 + + def reset_counter(self): + pass + + def find_cex(self, hypothesis): + return None + + +class StochasticTeacherAddAndQueryTest(unittest.TestCase): + + def setUp(self): + random.seed(42) + self.sul = AutomatonSUL(_branching_mdp()) + self.teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(), + automaton_type='mdp', compatibility_checker=HoeffdingChecker()) + + def test_initial_value_is_captured_from_sul(self): + self.assertEqual(self.teacher.initial_value, ['A']) + self.assertEqual(self.teacher.root_node.output, 'A') + + def test_add_updates_frequency_and_children(self): + self.teacher.back_to_root() + self.teacher.add('a', 'B') + self.assertEqual(self.teacher.root_node.input_frequencies['a'], 1) + child = self.teacher.root_node.get_child('a', 'B') + self.assertIsNotNone(child) + self.assertEqual(child.frequency, 1) + + self.teacher.back_to_root() + self.teacher.add('a', 'B') + # same (input, output) pair increments the same child's frequency + self.assertEqual(self.teacher.root_node.input_frequencies['a'], 2) + self.assertEqual(child.frequency, 2) + + def test_frequency_query_reflects_added_traces(self): + for _ in range(5): + self.teacher.back_to_root() + self.teacher.add('a', 'B') + for _ in range(3): + self.teacher.back_to_root() + self.teacher.add('a', 'C') + + # s is the mdp-format prefix (initial_output,), e is ('a',) + freq = self.teacher.frequency_query(('A',), ('a',)) + self.assertEqual(freq, {'B': 5, 'C': 3}) + + def test_frequency_query_missing_path_returns_empty_dict(self): + freq = self.teacher.frequency_query(('A', 'a', 'B'), ('a',)) + self.assertEqual(freq, {}) + + def test_complete_query_false_below_n_c_true_at_or_above(self): + for i in range(4): + self.teacher.back_to_root() + self.teacher.add('a', 'B') + self.assertFalse(self.teacher.complete_query(('A',), ('a',))) + + self.teacher.back_to_root() + self.teacher.add('a', 'B') + self.assertTrue(self.teacher.complete_query(('A',), ('a',))) + + def test_complete_query_is_cached(self): + for i in range(5): + self.teacher.back_to_root() + self.teacher.add('a', 'B') + self.assertTrue(self.teacher.complete_query(('A',), ('a',))) + # the mdp's leading initial-output symbol is stripped internally before building the cache key + self.assertIn(('a',), self.teacher.complete_query_cache) + + +class StochasticTeacherTreeQueryTest(unittest.TestCase): + + def setUp(self): + random.seed(1) + self.sul = AutomatonSUL(_branching_mdp()) + self.teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(), + automaton_type='mdp', compatibility_checker=HoeffdingChecker()) + + def test_tree_query_samples_from_pta_and_adds_to_tree(self): + pta_root = Node('A') + pta_root.input_frequencies['a'] = 10 + pta_root.children['a']['B'] = Node('B') + pta_root.children['a']['C'] = Node('C') + + for _ in range(50): + self.teacher.tree_query(pta_root) + + total_a = self.teacher.root_node.get_frequency_sum('a') + self.assertEqual(total_a, 50) + freqs = self.teacher.root_node.get_output_frequencies('a') + self.assertEqual(sum(freqs.values()), 50) + self.assertTrue(set(freqs.keys()).issubset({'B', 'C'})) + + +class StochasticTeacherEquivalenceQueryTest(unittest.TestCase): + + def setUp(self): + random.seed(7) + self.sul = AutomatonSUL(_branching_mdp()) + + def test_last_cex_is_reused_while_still_valid(self): + teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(), + automaton_type='mdp', compatibility_checker=HoeffdingChecker()) + hyp_s0 = MdpState('s0', output='A') + hyp_s0.prefix = ('A',) + hypothesis = Mdp(hyp_s0, [hyp_s0]) + # hypothesis has no transitions at all, so any cex trying to step 'a' is still "unprocessed"; + # cex format is (o0, i1, o1, ..., i_n), ending in a dangling input + teacher.last_cex = ('A', 'a', 'B', 'a') + + cex = teacher.equivalence_query(hypothesis) + self.assertEqual(cex, ('A', 'a', 'B', 'a')) + + def test_falls_back_to_eq_oracle_when_no_cached_or_tree_cex(self): + class _OracleWithCex: + num_queries = 0 + num_steps = 0 + + def reset_counter(self): + pass + + def find_cex(self, hypothesis): + return ('A', 'a', 'B', 'a', 'A') + + teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_OracleWithCex(), + automaton_type='mdp', compatibility_checker=HoeffdingChecker()) + hyp_s0 = MdpState('s0', output='A') + hyp_s0.prefix = ('A',) + hypothesis = Mdp(hyp_s0, [hyp_s0]) + + cex = teacher.equivalence_query(hypothesis) + # equivalence_query strips the last element of the oracle's cex + self.assertEqual(cex, ('A', 'a', 'B', 'a')) + self.assertEqual(teacher.last_cex, cex) + + def test_bfs_for_cex_in_tree_finds_output_mismatch(self): + teacher = StochasticTeacher(self.sul, n_c=5, eq_oracle=_NullEqOracle(), + automaton_type='mdp', compatibility_checker=HoeffdingChecker()) + for _ in range(10): + teacher.back_to_root() + teacher.add('a', 'B') + + hyp_s0 = MdpState('s0', output='A') + hyp_s0.prefix = ('A',) + hypothesis = Mdp(hyp_s0, [hyp_s0]) # no transitions at all for 'a' + + cex = teacher.bfs_for_cex_in_tree(hypothesis) + self.assertEqual(cex, ('A', 'a')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic_passive/test_alergia_integration.py b/tests/learning_algs/stochastic_passive/test_alergia_integration.py new file mode 100644 index 00000000000..e85159fecfd --- /dev/null +++ b/tests/learning_algs/stochastic_passive/test_alergia_integration.py @@ -0,0 +1,206 @@ +import os +import random +import tempfile +import unittest +from unittest.mock import patch + +from aalpy.SULs import AutomatonSUL +from aalpy.automata import Mdp, MdpState, MarkovChain, McState +from aalpy.learning_algs import run_Alergia, run_active_Alergia +from aalpy.learning_algs.stochastic_passive.ActiveAleriga import RandomWordSampler +from aalpy.learning_algs.stochastic_passive.Alergia import run_JAlergia + + +def _ground_truth_mdp(): + """ + 3-state MDP: s0(A) --a--> s1(B) w.p. 0.7 / s2(C) w.p. 0.3; s0 --b--> s0. + s1(B)/s2(C) absorb back to s0 on 'a', self-loop on 'b'. + """ + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s2 = MdpState('s2', output='C') + s0.transitions['a'].append((s1, 0.7)) + s0.transitions['a'].append((s2, 0.3)) + s0.transitions['b'].append((s0, 1.0)) + s1.transitions['a'].append((s0, 1.0)) + s1.transitions['b'].append((s1, 1.0)) + s2.transitions['a'].append((s0, 1.0)) + s2.transitions['b'].append((s2, 1.0)) + return Mdp(s0, [s0, s1, s2]) + + +def _ground_truth_mc(): + """2-state biased coin Markov chain: heads (0.7) self-loop-ish, tails (0.3).""" + heads = McState('heads', output='H') + tails = McState('tails', output='T') + heads.transitions.append((heads, 0.7)) + heads.transitions.append((tails, 0.3)) + tails.transitions.append((heads, 0.6)) + tails.transitions.append((tails, 0.4)) + return MarkovChain(heads, [heads, tails]) + + +def _generate_mdp_data(ground_truth, alphabet, num_traces, trace_len): + sul = AutomatonSUL(ground_truth) + data = [] + for _ in range(num_traces): + walk = [random.choice(alphabet) for _ in range(trace_len)] + outputs = sul.query(tuple(walk)) + trace = [ground_truth.initial_state.output] + for inp, out in zip(walk, outputs): + trace.append((inp, out)) + data.append(trace) + return data + + +def _generate_mc_data(ground_truth, num_traces, trace_len): + data = [] + for _ in range(num_traces): + ground_truth.reset_to_initial() + trace = [ground_truth.current_state.output] + for _ in range(trace_len): + trace.append(ground_truth.step()) + data.append(trace) + return data + + +def _output_distribution(model, input_seq, n_samples): + counts = {} + for _ in range(n_samples): + outputs = tuple(model.execute_sequence(model.initial_state, list(input_seq))) + counts[outputs] = counts.get(outputs, 0) + 1 + return {k: v / n_samples for k, v in counts.items()} + + +def _total_variation_distance(dist1, dist2): + keys = set(dist1) | set(dist2) + return 0.5 * sum(abs(dist1.get(k, 0) - dist2.get(k, 0)) for k in keys) + + +class RunAlergiaMdpTest(unittest.TestCase): + + def test_learned_mdp_resembles_ground_truth_output_distributions(self): + random.seed(10) + ground_truth = _ground_truth_mdp() + alphabet = ground_truth.get_input_alphabet() + data = _generate_mdp_data(ground_truth, alphabet, num_traces=3000, trace_len=4) + + learned_model = run_Alergia(data, automaton_type='mdp', eps=0.3, print_info=False) + + random.seed(99) + for input_seq in [('a',), ('a', 'a'), ('b', 'a')]: + truth_dist = _output_distribution(ground_truth, input_seq, 300) + learned_dist = _output_distribution(learned_model, input_seq, 300) + tvd = _total_variation_distance(truth_dist, learned_dist) + self.assertLessEqual(tvd, 0.35, f'{input_seq}: truth={truth_dist} learned={learned_dist}') + + +class RunAlergiaMcTest(unittest.TestCase): + + def test_learned_markov_chain_resembles_ground_truth_stationary_behaviour(self): + random.seed(11) + ground_truth = _ground_truth_mc() + data = _generate_mc_data(ground_truth, num_traces=3000, trace_len=5) + + learned_model = run_Alergia(data, automaton_type='mc', eps=0.3, print_info=False) + + def first_step_distribution(model, n): + counts = {'H': 0, 'T': 0} + for _ in range(n): + model.reset_to_initial() + out = model.step() + counts[out] = counts.get(out, 0) + 1 + return {k: v / n for k, v in counts.items()} + + random.seed(55) + truth_dist = first_step_distribution(ground_truth, 1000) + learned_dist = first_step_distribution(learned_model, 1000) + tvd = _total_variation_distance(truth_dist, learned_dist) + self.assertLessEqual(tvd, 0.2, f'truth={truth_dist} learned={learned_dist}') + + +class RunJAlergiaTest(unittest.TestCase): + + def test_missing_jar_returns_none_without_raising(self): + with tempfile.TemporaryDirectory() as tmp_dir: + missing_jar = os.path.join(tmp_dir, 'does_not_exist.jar') + data_file = os.path.join(tmp_dir, 'data.txt') + with open(data_file, 'w') as f: + f.write('A,a,B\n') + + result = run_JAlergia(data_file, 'mdp', missing_jar) + + self.assertIsNone(result) + + +class RandomWordSamplerTest(unittest.TestCase): + + def test_sample_uses_true_initial_output_and_aligned_input_output_pairs(self): + # Regression test: RandomWordSampler.sample() used to treat the output observed after the *first* + # input as if it were the SUL's initial output, and then paired random_walk[i] with the output + # observed after input i+1 (an off-by-one misalignment). Both bugs corrupted every generated sample + # and could even make consistent MDP data collection impossible (differing "initial outputs" trip + # the FPTA's initial-output consistency assertion). + random.seed(30) + # deterministic (prob-1.0 only) ground truth so re-querying the same inputs later is reproducible + s0 = MdpState('s0', output='A') + s1 = MdpState('s1', output='B') + s0.transitions['a'].append((s1, 1.0)) + s0.transitions['b'].append((s0, 1.0)) + s1.transitions['a'].append((s0, 1.0)) + s1.transitions['b'].append((s1, 1.0)) + ground_truth = Mdp(s0, [s0, s1]) + sul = AutomatonSUL(ground_truth) + sampler = RandomWordSampler(num_walks=20, min_walk_len=3, max_walk_len=3) + + samples = sampler.sample(sul, ground_truth) + + for sample in samples: + initial_output, *steps = sample + self.assertEqual(initial_output, ground_truth.initial_state.output) + + inputs = tuple(io[0] for io in steps) + outputs = tuple(io[1] for io in steps) + expected_outputs = tuple(sul.query(inputs)) + self.assertEqual(outputs, expected_outputs) + + +class RunActiveAlergiaTest(unittest.TestCase): + + def test_active_alergia_improves_model_over_iterations(self): + random.seed(21) + ground_truth = _ground_truth_mdp() + alphabet = ground_truth.get_input_alphabet() + initial_data = _generate_mdp_data(ground_truth, alphabet, num_traces=200, trace_len=3) + + sul = AutomatonSUL(ground_truth) + sampler = RandomWordSampler(num_walks=200, min_walk_len=2, max_walk_len=4) + + learned_model = run_active_Alergia(initial_data, sul, sampler, n_iter=3, eps=0.3, print_info=False) + + random.seed(88) + for input_seq in [('a',), ('b', 'a')]: + truth_dist = _output_distribution(ground_truth, input_seq, 300) + learned_dist = _output_distribution(learned_model, input_seq, 300) + tvd = _total_variation_distance(truth_dist, learned_dist) + self.assertLessEqual(tvd, 0.35, f'{input_seq}: truth={truth_dist} learned={learned_dist}') + + def test_active_alergia_forwards_automaton_type_to_run_alergia(self): + # Regression test: run_active_Alergia used to hardcode automaton_type='mdp' when calling run_Alergia, + # silently ignoring its own automaton_type parameter. Verify the parameter is now actually forwarded. + data = [['A', ('a', 'B')]] + sul = object() + + class _NoopSampler: + def sample(self, sul, model): + return [] + + with patch('aalpy.learning_algs.stochastic_passive.ActiveAleriga.run_Alergia') as mock_run_alergia: + mock_run_alergia.return_value = 'fake-model' + run_active_Alergia(data, sul, _NoopSampler(), n_iter=1, automaton_type='smm', print_info=False) + + self.assertEqual(mock_run_alergia.call_args.kwargs['automaton_type'], 'smm') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic_passive/test_compatibility_checker.py b/tests/learning_algs/stochastic_passive/test_compatibility_checker.py new file mode 100644 index 00000000000..e771caf3d04 --- /dev/null +++ b/tests/learning_algs/stochastic_passive/test_compatibility_checker.py @@ -0,0 +1,93 @@ +import unittest + +from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import HoeffdingCompatibility +from aalpy.learning_algs.stochastic_passive.FPTA import AlergiaPtaNode + + +def _mc_node(freq: dict) -> AlergiaPtaNode: + """Builds a standalone AlergiaPtaNode with plain-symbol (Alergia/MC-style) original frequencies.""" + node = AlergiaPtaNode(None, ()) + node.original_input_frequency = dict(freq) + node.original_children = {k: AlergiaPtaNode(k, (k,)) for k in freq} + return node + + +def _io_node(freq: dict) -> AlergiaPtaNode: + """Builds a standalone AlergiaPtaNode with (input, output)-keyed (IOAlergia-style) original frequencies.""" + node = AlergiaPtaNode(None, ()) + node.original_input_frequency = dict(freq) + node.original_children = {k: AlergiaPtaNode(k[1], (k,)) for k in freq} + return node + + +class HoeffdingCompatibilityMcTest(unittest.TestCase): + """For plain-symbol (Alergia) data, original_input_frequency keys are not tuples.""" + + def test_identical_distributions_are_compatible(self): + node_a = _mc_node({'x': 500, 'y': 500}) + node_b = _mc_node({'x': 500, 'y': 500}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertFalse(checker.are_states_different(node_a, node_b)) + + def test_clearly_different_distributions_are_different(self): + node_a = _mc_node({'x': 950, 'y': 50}) + node_b = _mc_node({'x': 50, 'y': 950}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertTrue(checker.are_states_different(node_a, node_b)) + + def test_no_data_on_either_side_is_never_different(self): + node_a = _mc_node({}) + node_b = _mc_node({'x': 10}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertFalse(checker.are_states_different(node_a, node_b)) + + def test_smaller_eps_makes_the_bound_wider_and_less_sensitive(self): + # A smaller eps means we demand more confidence before calling two distributions "different", + # which widens the Hoeffding bound; the same borderline difference can therefore be flagged as + # different for a larger eps but not for a much smaller one. + node_a = _mc_node({'x': 5150, 'y': 4850}) + node_b = _mc_node({'x': 4850, 'y': 5150}) + larger_eps = HoeffdingCompatibility(eps=0.3) + smaller_eps = HoeffdingCompatibility(eps=0.001) + self.assertTrue(larger_eps.are_states_different(node_a, node_b)) + self.assertFalse(smaller_eps.are_states_different(node_a, node_b)) + + +class HoeffdingCompatibilityIOAlergiaTest(unittest.TestCase): + """For (input, output)-keyed (IOAlergia) data, the Hoeffding bound is checked per shared input.""" + + def test_identical_conditional_output_distributions_are_compatible(self): + node_a = _io_node({('a', 'x'): 500, ('a', 'y'): 500}) + node_b = _io_node({('a', 'x'): 500, ('a', 'y'): 500}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertFalse(checker.are_states_different(node_a, node_b)) + + def test_different_conditional_output_distributions_are_different(self): + node_a = _io_node({('a', 'x'): 950, ('a', 'y'): 50}) + node_b = _io_node({('a', 'x'): 50, ('a', 'y'): 950}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertTrue(checker.are_states_different(node_a, node_b)) + + def test_disjoint_inputs_between_nodes_are_never_different(self): + # get_immutable_inputs intersection is empty -> the per-input loop never runs -> not different, + # regardless of how skewed each node's own distribution is + node_a = _io_node({('a', 'x'): 950, ('a', 'y'): 50}) + node_b = _io_node({('b', 'x'): 50, ('b', 'y'): 950}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertFalse(checker.are_states_different(node_a, node_b)) + + def test_different_output_for_shared_input_is_detected_even_with_extra_disjoint_input(self): + node_a = _io_node({('a', 'x'): 950, ('a', 'y'): 50, ('c', 'z'): 10}) + node_b = _io_node({('a', 'x'): 50, ('a', 'y'): 950}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertTrue(checker.are_states_different(node_a, node_b)) + + def test_no_data_on_either_side_is_never_different(self): + node_a = _io_node({}) + node_b = _io_node({('a', 'x'): 10}) + checker = HoeffdingCompatibility(eps=0.05) + self.assertFalse(checker.are_states_different(node_a, node_b)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/learning_algs/stochastic_passive/test_fpta.py b/tests/learning_algs/stochastic_passive/test_fpta.py new file mode 100644 index 00000000000..43a23b35887 --- /dev/null +++ b/tests/learning_algs/stochastic_passive/test_fpta.py @@ -0,0 +1,125 @@ +import unittest + +from aalpy.learning_algs.stochastic_passive.FPTA import AlergiaPtaNode, create_fpta + + +class CreateFptaMcTest(unittest.TestCase): + + def test_single_sequence_builds_a_chain(self): + # for 'mc', like 'mdp', the first element of each sequence is treated as the (shared) initial output + # of the root and is not itself a transition + data = [['a', 'b', 'a']] + root = create_fpta(data, 'mc') + + self.assertEqual(root.prefix, ()) + self.assertEqual(root.output, 'a') + self.assertEqual(root.input_frequency, {'b': 1}) + + node_b = root.children['b'] + self.assertEqual(node_b.output, 'b') + self.assertEqual(node_b.prefix, ('b',)) + self.assertEqual(node_b.input_frequency, {'a': 1}) + + node_ba = node_b.children['a'] + self.assertEqual(node_ba.output, 'a') + self.assertEqual(node_ba.children, {}) + + def test_shared_prefixes_merge_into_a_tree(self): + data = [['a', 'b'], ['a', 'c'], ['a', 'b']] + root = create_fpta(data, 'mc') + + self.assertEqual(root.output, 'a') + self.assertEqual(root.input_frequency, {'b': 2, 'c': 1}) + self.assertEqual(set(root.children.keys()), {'b', 'c'}) + + +class CreateFptaMdpTest(unittest.TestCase): + + def test_initial_output_is_first_element_of_first_sequence(self): + data = [['A', ('a', 'B'), ('b', 'A')], ['A', ('a', 'C')]] + root = create_fpta(data, 'mdp') + + self.assertEqual(root.output, 'A') + self.assertEqual(root.prefix, ()) + self.assertEqual(root.input_frequency, {('a', 'B'): 1, ('a', 'C'): 1}) + + node_b = root.children[('a', 'B')] + self.assertEqual(node_b.output, 'B') + self.assertEqual(node_b.prefix, (('a', 'B'),)) + self.assertEqual(node_b.input_frequency, {('b', 'A'): 1}) + + node_c = root.children[('a', 'C')] + self.assertEqual(node_c.output, 'C') + self.assertEqual(node_c.children, {}) + + def test_inconsistent_initial_output_raises(self): + data = [['A', ('a', 'B')], ['X', ('a', 'B')]] + with self.assertRaises(AssertionError): + create_fpta(data, 'mdp') + + +class CreateFptaSmmTest(unittest.TestCase): + + def test_no_initial_output_and_outputs_are_none(self): + data = [['a', 'o1', 'b', 'o2'], ['a', 'o1', 'a', 'o3']] + root = create_fpta(data, 'smm') + + self.assertIsNone(root.output) + self.assertEqual(root.prefix, ()) + self.assertEqual(root.input_frequency, {'a': 2}) + + node = root.children['a'] + # for smm the output is never inferred from the input element itself + self.assertIsNone(node.output) + self.assertEqual(node.input_frequency, {'o1': 2}) + + node2 = node.children['o1'] + self.assertEqual(set(node2.children.keys()), {'b', 'a'}) + + +class AlergiaPtaNodeHelpersTest(unittest.TestCase): + + def test_get_input_frequency_sums_over_outputs(self): + node = AlergiaPtaNode(None, ()) + node.input_frequency = {('a', 'x'): 3, ('a', 'y'): 2, ('b', 'x'): 5} + self.assertEqual(node.get_input_frequency('a'), 5) + self.assertEqual(node.get_input_frequency('b'), 5) + self.assertEqual(node.get_input_frequency('c'), 0) + + def test_get_output_frequencies_filters_by_input(self): + node = AlergiaPtaNode(None, ()) + node.input_frequency = {('a', 'x'): 3, ('a', 'y'): 2, ('b', 'x'): 5} + self.assertEqual(node.get_output_frequencies('a'), {'x': 3, 'y': 2}) + + def test_get_inputs_uses_mutable_input_frequency(self): + node = AlergiaPtaNode(None, ()) + node.input_frequency = {('a', 'x'): 1, ('b', 'y'): 1} + self.assertEqual(node.get_inputs(), {'a', 'b'}) + + def test_successors_returns_children_values(self): + # AlergiaPtaNode defines __eq__ by prefix but is unhashable, so successors are compared by identity + parent = AlergiaPtaNode(None, ()) + child1 = AlergiaPtaNode('x', ('a',)) + child2 = AlergiaPtaNode('y', ('b',)) + parent.children = {'a': child1, 'b': child2} + successors = parent.successors() + self.assertEqual(len(successors), 2) + self.assertTrue(any(s is child1 for s in successors)) + self.assertTrue(any(s is child2 for s in successors)) + + def test_ordering_is_by_prefix_length_then_value(self): + short = AlergiaPtaNode(None, ('a',)) + long = AlergiaPtaNode(None, ('a', 'b')) + other_short = AlergiaPtaNode(None, ('z',)) + self.assertLess(short, long) + self.assertLess(short, other_short) + self.assertLessEqual(short, short) + + def test_equality_is_based_on_prefix_only(self): + node1 = AlergiaPtaNode('x', ('a', 'b')) + node2 = AlergiaPtaNode('y', ('a', 'b')) + self.assertEqual(node1, node2) + + +if __name__ == '__main__': + unittest.main() From 3b42e4b77498e6d4c5e9aab690b0315152a815ea Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:20:19 +0200 Subject: [PATCH 16/25] Remove old test files --- tests/test_charSet.py | 68 -------- tests/test_deterministic.py | 202 ---------------------- tests/test_deterministic_learning_runs.py | 70 -------- tests/test_deterministic_passive.py | 93 ---------- tests/test_file_operations.py | 38 ---- tests/test_hW.py | 156 ----------------- tests/test_non_deterministic.py | 30 ---- tests/test_rwpmethod_oracle.py | 113 ------------ tests/test_stochastic.py | 70 -------- tests/test_wmethod_oracle.py | 109 ------------ tests/test_wpmethod_oracle.py | 113 ------------ 11 files changed, 1062 deletions(-) delete mode 100644 tests/test_charSet.py delete mode 100644 tests/test_deterministic.py delete mode 100644 tests/test_deterministic_learning_runs.py delete mode 100644 tests/test_deterministic_passive.py delete mode 100644 tests/test_file_operations.py delete mode 100644 tests/test_hW.py delete mode 100644 tests/test_non_deterministic.py delete mode 100644 tests/test_rwpmethod_oracle.py delete mode 100644 tests/test_stochastic.py delete mode 100644 tests/test_wmethod_oracle.py delete mode 100644 tests/test_wpmethod_oracle.py diff --git a/tests/test_charSet.py b/tests/test_charSet.py deleted file mode 100644 index 25d4731f0c5..00000000000 --- a/tests/test_charSet.py +++ /dev/null @@ -1,68 +0,0 @@ -import unittest - -from aalpy.utils import get_Angluin_dfa, load_automaton_from_file -from aalpy.utils.HelperFunctions import all_suffixes - - -class TestCharSet(unittest.TestCase): - - def get_test_automata(self): - return {"angluin_dfa": get_Angluin_dfa(), - "angluin_mealy": load_automaton_from_file('../DotModels/Angluin_Mealy.dot', automaton_type='mealy'), - "angluin_moore": load_automaton_from_file('../DotModels/Angluin_Moore.dot', automaton_type='moore'), - "mqtt": load_automaton_from_file('../DotModels/MQTT/emqtt__two_client_will_retain.dot', - automaton_type='mealy'), - "openssl": load_automaton_from_file('../DotModels/TLS/OpenSSL_1.0.2_server_regular.dot', - automaton_type='mealy'), - "tcp_server": load_automaton_from_file('../DotModels/TCP/TCP_Linux_Client.dot', - automaton_type='mealy')} - - def test_can_differentiate(self): - automata = self.get_test_automata() - for init_with_alphabet in [True, False]: - for (online_suffix_closure, split_all_blocks) in [(False, False), (False, True), (True, False), - (True, True)]: - for test_aut_name in automata: - print(f"Testing with {test_aut_name}") - test_aut = automata[test_aut_name] - char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \ - if init_with_alphabet else None - if "dfa" in test_aut_name or "moore" in test_aut_name: - char_set_init = [] if char_set_init is None else char_set_init - char_set_init.append(()) - char_set = test_aut.compute_characterization_set(char_set_init=char_set_init, - online_suffix_closure=online_suffix_closure, - split_all_blocks=split_all_blocks) - print(f"Char. set {char_set}") - all_responses = set() - for s in test_aut.states: - responses_from_s = [] - for c in char_set: - responses_from_s.append(tuple(test_aut.compute_output_seq(s, c))) - all_responses.add(tuple(responses_from_s)) - - # every state must have a unique response to the whole characterization set - assert len(all_responses) == len(test_aut.states) - - def test_suffix_closed(self): - automata = self.get_test_automata() - for init_with_alphabet in [True, False]: - online_suffix_closure = True - for split_all_blocks in [True, False]: - for test_aut_name in automata: - print(f"Testing with {test_aut_name}") - test_aut = automata[test_aut_name] - char_set_init = list(map(lambda input: tuple([input]), test_aut.get_input_alphabet())) \ - if init_with_alphabet else None - if "dfa" in test_aut_name or "moore" in test_aut_name: - char_set_init = [] if char_set_init is None else char_set_init - char_set_init.append(()) - char_set = test_aut.compute_characterization_set(char_set_init=char_set_init, - online_suffix_closure=online_suffix_closure, - split_all_blocks=split_all_blocks) - print(f"Char. set {char_set}") - for s in char_set: - for suffix in all_suffixes(s): - if suffix not in char_set: - print(suffix) - assert suffix in char_set diff --git a/tests/test_deterministic.py b/tests/test_deterministic.py deleted file mode 100644 index d95349509d7..00000000000 --- a/tests/test_deterministic.py +++ /dev/null @@ -1,202 +0,0 @@ -import unittest - -from aalpy.SULs import AutomatonSUL -from aalpy.automata import Dfa, MealyMachine, MooreMachine -from aalpy.learning_algs import run_Lstar -from aalpy.oracles import WMethodEqOracle, WpMethodEqOracle, RandomWalkEqOracle, StatePrefixEqOracle, TransitionFocusOracle, \ - RandomWMethodEqOracle, BreadthFirstExplorationEqOracle, RandomWordEqOracle, CacheBasedEqOracle, \ - KWayStateCoverageEqOracle, RandomWpMethodEqOracle -from aalpy.utils import get_Angluin_dfa, load_automaton_from_file -from aalpy.utils.ModelChecking import bisimilar - -correct_automata = {Dfa: get_Angluin_dfa(), - MealyMachine: load_automaton_from_file('../DotModels/Angluin_Mealy.dot', automaton_type='mealy'), - MooreMachine: load_automaton_from_file('../DotModels/Angluin_Moore.dot', automaton_type='moore')} - - -class DeterministicTest(unittest.TestCase): - - def prove_equivalence(self, learned_automaton): - - correct_automaton = correct_automata[learned_automaton.__class__] - - # only work if correct automaton is already minimal - if len(learned_automaton.states) != len(correct_automaton.states): - print(len(learned_automaton.states), len(correct_automaton.states)) - return False - - return bisimilar(correct_automaton, learned_automaton) - - def test_closing_strategies(self): - - dfa = get_Angluin_dfa() - - alphabet = dfa.get_input_alphabet() - - closing_strategies = ['shortest_first', 'longest_first', 'single'] - automata_type = ['dfa', 'mealy', 'moore'] - - for automata in automata_type: - for closing in closing_strategies: - sul = AutomatonSUL(dfa) - eq_oracle = RandomWalkEqOracle(alphabet, sul, 1000) - - learned_dfa = run_Lstar(alphabet, sul, eq_oracle, automaton_type=automata, closing_strategy=closing, - cache_and_non_det_check=True, cex_processing='rs', print_level=0) - - is_eq = self.prove_equivalence(learned_dfa) - if not is_eq: - assert False - - assert True - - def test_suffix_closedness(self): - - angluin_example = get_Angluin_dfa() - - alphabet = angluin_example.get_input_alphabet() - - suffix_closedness = [True, False] - automata_type = ['dfa', 'mealy', 'moore'] - - for automata in automata_type: - for s_closed in suffix_closedness: - sul = AutomatonSUL(angluin_example) - eq_oracle = RandomWalkEqOracle(alphabet, sul, 500) - - learned_dfa = run_Lstar(alphabet, sul, eq_oracle, automaton_type=automata, - all_prefixes_in_obs_table=True, - e_set_suffix_closed=s_closed, - cache_and_non_det_check=True, cex_processing='rs', print_level=0) - - is_eq = self.prove_equivalence(learned_dfa) - if not is_eq: - assert False - - assert True - - def test_cex_processing(self): - angluin_example = get_Angluin_dfa() - - alphabet = angluin_example.get_input_alphabet() - - cex_processing = [None, 'longest_prefix', 'rs'] - automata_type = ['dfa', 'mealy', 'moore'] - - for automata in automata_type: - for cex in cex_processing: - sul = AutomatonSUL(angluin_example) - eq_oracle = RandomWalkEqOracle(alphabet, sul, 500) - - learned_dfa = run_Lstar(alphabet, sul, eq_oracle, automaton_type=automata, - cache_and_non_det_check=True, cex_processing=cex, print_level=0) - - is_eq = self.prove_equivalence(learned_dfa) - if not is_eq: - assert False - - assert True - - def test_eq_oracles(self): - angluin_example = get_Angluin_dfa() - - alphabet = angluin_example.get_input_alphabet() - - automata_type = ['dfa', 'mealy', 'moore'] - - for automata in automata_type: - sul = AutomatonSUL(angluin_example) - - random_walk_eq_oracle = RandomWalkEqOracle(alphabet, sul, 5000, reset_after_cex=True) - state_origin_eq_oracle = StatePrefixEqOracle(alphabet, sul, walks_per_state=10, walk_len=50) - tran_cov_eq_oracle = TransitionFocusOracle(alphabet, sul, num_random_walks=200, walk_len=30, - same_state_prob=0.3) - w_method_eq_oracle = WMethodEqOracle(alphabet, sul, max_number_of_states=len(angluin_example.states) + 1) - wp_method_eq_oracle = WpMethodEqOracle(alphabet, sul, max_number_of_states=len(angluin_example.states) + 1) - rwp_method_eq_oracle = RandomWpMethodEqOracle(alphabet, sul) - random_W_method_eq_oracle = RandomWMethodEqOracle(alphabet, sul, walks_per_state=10, walk_len=50) - bf_exploration_eq_oracle = BreadthFirstExplorationEqOracle(alphabet, sul, 4) - random_word_eq_oracle = RandomWordEqOracle(alphabet, sul) - cache_based_eq_oracle = CacheBasedEqOracle(alphabet, sul) - kWayStateCoverageEqOracle = KWayStateCoverageEqOracle(alphabet, sul) - - oracles = [random_walk_eq_oracle, random_word_eq_oracle, - random_W_method_eq_oracle, w_method_eq_oracle, - wp_method_eq_oracle, rwp_method_eq_oracle, - kWayStateCoverageEqOracle, cache_based_eq_oracle, - bf_exploration_eq_oracle, tran_cov_eq_oracle, - state_origin_eq_oracle] - - for oracle in oracles: - sul = AutomatonSUL(angluin_example) - oracle.sul = sul - - learned_model = run_Lstar(alphabet, sul, oracle, automaton_type=automata, - cache_and_non_det_check=True, cex_processing=None, print_level=0) - - is_eq = self.prove_equivalence(learned_model) - if not is_eq: - print(learned_model) - print(oracle, automata) - assert False - - assert True - - def test_all_configuration_combinations(self): - angluin_example = get_Angluin_dfa() - - alphabet = angluin_example.get_input_alphabet() - - automata_type = ['dfa', 'mealy', 'moore'] - closing_strategies = ['shortest_first', 'longest_first', 'single'] - cex_processing = [None, 'longest_prefix', 'rs'] - suffix_closedness = [True, False] - caching = [True, False] - - for automata in automata_type: - for closing in closing_strategies: - for cex in cex_processing: - for suffix in suffix_closedness: - for cache in caching: - sul = AutomatonSUL(angluin_example) - - random_walk_eq_oracle = RandomWalkEqOracle(alphabet, sul, 5000, reset_after_cex=True) - state_origin_eq_oracle = StatePrefixEqOracle(alphabet, sul, walks_per_state=10, walk_len=50) - tran_cov_eq_oracle = TransitionFocusOracle(alphabet, sul, num_random_walks=200, walk_len=30, - same_state_prob=0.3) - w_method_eq_oracle = WMethodEqOracle(alphabet, sul, - max_number_of_states=len(angluin_example.states)) - wp_method_eq_oracle = WpMethodEqOracle(alphabet, sul, - max_number_of_states=len(angluin_example.states)) - rwp_method_eq_oracle = RandomWpMethodEqOracle(alphabet, sul) - random_W_method_eq_oracle = RandomWMethodEqOracle(alphabet, sul, - walks_per_state=10, walk_len=50) - bf_exploration_eq_oracle = BreadthFirstExplorationEqOracle(alphabet, sul, 4) - random_word_eq_oracle = RandomWordEqOracle(alphabet, sul) - cache_based_eq_oracle = CacheBasedEqOracle(alphabet, sul) - - oracles = [random_walk_eq_oracle, random_word_eq_oracle, random_W_method_eq_oracle, - rwp_method_eq_oracle, cache_based_eq_oracle, bf_exploration_eq_oracle, - wp_method_eq_oracle, tran_cov_eq_oracle, w_method_eq_oracle, - state_origin_eq_oracle] - - if not cache: - oracles.remove(cache_based_eq_oracle) - - for oracle in oracles: - sul = AutomatonSUL(angluin_example) - oracle.sul = sul - - learned_model = run_Lstar(alphabet, sul, oracle, automaton_type=automata, - closing_strategy=closing, - cache_and_non_det_check=cache, - cex_processing=cex, - e_set_suffix_closed=suffix, - print_level=0) - - is_eq = self.prove_equivalence(learned_model) - if not is_eq: - print(oracle, automata) - assert False - - assert True diff --git a/tests/test_deterministic_learning_runs.py b/tests/test_deterministic_learning_runs.py deleted file mode 100644 index 2c6364f2211..00000000000 --- a/tests/test_deterministic_learning_runs.py +++ /dev/null @@ -1,70 +0,0 @@ -import pytest - -from aalpy import generate_random_deterministic_automata, bisimilar, AutomatonSUL, run_Lstar, run_KV, \ - RandomWMethodEqOracle -from aalpy.learning_algs import run_Lsharp - -SEEDS = list(range(50)) -MODEL_SIZES = [ - (2, 2, 2), - (2, 2, 3), - (3, 2, 2), - (3, 2, 3), - (3, 3, 2), - (4, 2, 3), - (4, 3, 2), - (5, 3, 3), - (6, 2, 3), - (6, 3, 2), - (10, 2, 3), - (10, 2, 4), - (10, 2, 2), - (10, 2, 3), - (20, 5, 5), - (30, 3, 4), -] - -TEST_CASES = [ - pytest.param( - learning_alg, - automaton_type, - seed_val, - num_states, - input_size, - output_size, - id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}", - ) - for num_states, input_size, output_size in MODEL_SIZES - for seed_val in SEEDS - for automaton_type in ['dfa', 'moore', 'mealy'] - for learning_alg in [run_Lstar, run_Lsharp, run_KV] -] - - -@pytest.mark.parametrize("learning_alg,automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES) -@pytest.mark.timeout(5) -def test_learning_algs(learning_alg: callable, automaton_type, seed_val, num_states, input_size, output_size): - from random import seed - - seed(seed_val) - - model = generate_random_deterministic_automata( - automaton_type, - num_states=num_states, - input_alphabet_size=input_size, - output_alphabet_size=output_size, - ) - - sul = AutomatonSUL(model) - input_alphabet = model.get_input_alphabet() - - eq_oracle = RandomWMethodEqOracle(input_alphabet, sul, walks_per_state=num_states * 10, walk_len=15) - - learned_model = learning_alg(input_alphabet, sul, eq_oracle, automaton_type=automaton_type, print_level = 0) - - assert learned_model.is_minimal() - - print(learned_model) - print(model) - assert bisimilar(model, learned_model) - diff --git a/tests/test_deterministic_passive.py b/tests/test_deterministic_passive.py deleted file mode 100644 index 731a1024efd..00000000000 --- a/tests/test_deterministic_passive.py +++ /dev/null @@ -1,93 +0,0 @@ -import unittest -from itertools import product - -import aalpy -from aalpy.automata import Dfa, MooreMachine, MealyMachine -from aalpy.learning_algs import run_RPNI -from aalpy.utils import load_automaton_from_file -# from aalpy.utils.ModelChecking import bisimilar -from aalpy.utils.ModelChecking import compare_automata - -correct_automata = {Dfa: load_automaton_from_file('../DotModels/SimpleABC/simple_abc_dfa.dot', automaton_type='dfa'), - MooreMachine: load_automaton_from_file('../DotModels/SimpleABC/simple_abc_moore.dot', automaton_type='moore'), - MealyMachine: load_automaton_from_file('../DotModels/SimpleABC/simple_abc_mealy.dot', automaton_type='mealy')} - - -class DeterministicPassiveTest(unittest.TestCase): - - def prove_equivalence(self, learned_automaton): - - correct_automaton = correct_automata[learned_automaton.__class__] - - # only work if correct automaton is already minimal - if len(learned_automaton.states) != len(correct_automaton.states): - print(len(learned_automaton.states), len(correct_automaton.states)) - return False - - return correct_automaton == learned_automaton # bisimilar - - def generate_data(self, ground_truth, depth=5, step=1): - data = [] - if isinstance(ground_truth, aalpy.automata.Dfa) or isinstance(ground_truth, aalpy.automata.MooreMachine): - data.append(((), ground_truth.initial_state.output)) - - alphabet = ground_truth.get_input_alphabet() - for level in range(1, depth + 1, step): - for seq in product(alphabet, repeat=level): - ground_truth.reset_to_initial() - outputs = ground_truth.execute_sequence(ground_truth.initial_state, seq) - data.append((seq, outputs[-1])) - - return data - - def test_all_configuration_combinations(self): - automata_type = {Dfa: 'dfa', MooreMachine: 'moore', MealyMachine: 'mealy'} - algorithms = ['gsm', 'classic'] - - for automata in correct_automata: - correct_automaton = correct_automata[automata] - alphabet = correct_automaton.get_input_alphabet() - data = self.generate_data(correct_automaton, depth=3) - for algorithm in algorithms: - learned_model = run_RPNI(data, - automaton_type=automata_type[automata], - algorithm=algorithm, - print_info=False) - - is_eq = self.prove_equivalence(learned_model) - if not is_eq: - print("Learned:") - print(learned_model) - print(algorithm, automata_type[automata]) - cex = compare_automata(learned_model, correct_automaton) - print(cex) - assert False - - assert True - - def test_all_configuration_combinations_input_incomplete_data(self): - automata_type = {Dfa: 'dfa', MooreMachine: 'moore', MealyMachine: 'mealy'} - algorithms = ['gsm', 'classic'] - - for automata in correct_automata: - correct_automaton = correct_automata[automata] - alphabet = correct_automaton.get_input_alphabet() - data = self.generate_data(correct_automaton, depth=3, step=2) - if automata_type[automata] == 'moore': - data += [(('a', 'a', 'a', 'a'), 1), (('b', 'b', 'b', 'b'), 2), (('c', 'c', 'c', 'c'), 3)] - for algorithm in algorithms: - learned_model = run_RPNI(data, - automaton_type=automata_type[automata], - algorithm=algorithm, - print_info=False) - - is_eq = self.prove_equivalence(learned_model) - if not is_eq: - print("Learned:") - print(learned_model) - print(algorithm, automata_type[automata]) - cex = compare_automata(learned_model, correct_automaton) - print(cex) - assert False - - assert True diff --git a/tests/test_file_operations.py b/tests/test_file_operations.py deleted file mode 100644 index dc98750b765..00000000000 --- a/tests/test_file_operations.py +++ /dev/null @@ -1,38 +0,0 @@ -import unittest - -from aalpy.utils import generate_random_markov_chain, load_automaton_from_file -from aalpy.utils.BenchmarkSULs import * - - -class TestFileHandler(unittest.TestCase): - - def test_saving_loading(self): - try: - type_model_pairs = [ - ("dfa", get_Angluin_dfa()), - ("mealy", load_automaton_from_file('../DotModels/Angluin_Mealy.dot', automaton_type='mealy')), - ("moore", load_automaton_from_file('../DotModels/Angluin_Moore.dot', automaton_type='moore')), - ("onfsm", get_benchmark_ONFSM()), - ("mdp", get_small_pomdp()), - ("mdp", load_automaton_from_file('../DotModels/MDPs/first_grid.dot', automaton_type='mdp')), - ("smm", get_faulty_coffee_machine_SMM()), - ("mc", generate_random_markov_chain(num_states=10)), - ] - - for type, model in type_model_pairs: - model.save() - print(model) - loaded_model = load_automaton_from_file('LearnedModel.dot', type) - loaded_model.save() - loaded_model2 = load_automaton_from_file('LearnedModel.dot', type) - - if type != 'mc': - assert set(model.get_input_alphabet()) == set(loaded_model.get_input_alphabet()) - assert set(model.get_input_alphabet()) == set(loaded_model2.get_input_alphabet()) - - if type in {'dfa', 'moore', 'mealy'}: - assert model.compute_characterization_set() == loaded_model2.compute_characterization_set() - - assert True - except: - assert False diff --git a/tests/test_hW.py b/tests/test_hW.py deleted file mode 100644 index 2baad740a0a..00000000000 --- a/tests/test_hW.py +++ /dev/null @@ -1,156 +0,0 @@ -import pytest - -from aalpy import generate_random_deterministic_automata, bisimilar, run_hW, RandomhWOracle, RandomWphWOracle, \ - AutomatonSUL -from aalpy.SULs import MealySUL - -SEEDS = list(range(50)) -MODEL_SIZES = [ - (2, 2, 2), - (2, 2, 3), - (3, 2, 2), - (3, 2, 3), - (3, 3, 2), - (4, 2, 3), - (4, 3, 2), - (5, 3, 3), - (6, 2, 3), - (6, 3, 2), - (10, 2, 3), - (10, 2, 4), - (10, 2, 2), - (10, 2, 3), - (20, 5, 5), - (30, 3, 4), -] - -TEST_CASES = [ - pytest.param( - automaton_type, - seed_val, - num_states, - input_size, - output_size, - id=f"states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}-automaton_type={automaton_type}", - ) - for num_states, input_size, output_size in MODEL_SIZES - for seed_val in SEEDS - for automaton_type in ['dfa', 'moore', 'mealy'] -] - - -@pytest.mark.parametrize("automaton_type,seed_val,num_states,input_size,output_size", TEST_CASES) -@pytest.mark.timeout(5) -def test_hw_seed(automaton_type, seed_val, num_states, input_size, output_size): - from random import seed - - seed(seed_val) - - model = generate_random_deterministic_automata( - automaton_type, - num_states=num_states, - input_alphabet_size=input_size, - output_alphabet_size=output_size, - ) - if not model.is_minimal(): - pytest.skip(f"seed {seed_val} does not produce a minimal model") - if not model.is_strongly_connected(): - pytest.skip( - f"seed {seed_val} does not produce a strongly connected model " - f"for states={num_states}, inputs={input_size}, outputs={output_size}" - ) - - sul = MealySUL(model) - input_alphabet = model.get_input_alphabet() - - - learned_model = run_hW(input_alphabet, - sul, - RandomhWOracle(num_testing_steps=1000 * num_states, reset_testing_counter=True), - automaton_type=automaton_type, - query_for_initial_state=True) - - assert learned_model.is_minimal() - assert bisimilar(model, learned_model) - - -STRATEGY_TEST_CASES = [ - pytest.param( - oracle_name, - seed_val, - num_states, - input_size, - output_size, - id=f"oracle={oracle_name}-states={num_states}-inputs={input_size}-outputs={output_size}-seed={seed_val}", - ) - for num_states, input_size, output_size in [(3, 2, 2), (5, 3, 3), (10, 2, 3)] - for seed_val in range(10) - for oracle_name in ['random', 'wp'] -] - - -@pytest.mark.parametrize("oracle_name,seed_val,num_states,input_size,output_size", STRATEGY_TEST_CASES) -@pytest.mark.timeout(5) -def test_hw_eq_oracle(oracle_name, seed_val, num_states, input_size, output_size): - from random import seed - - seed(seed_val) - - model = generate_random_deterministic_automata( - 'mealy', - num_states=num_states, - input_alphabet_size=input_size, - output_alphabet_size=output_size, - ) - if not model.is_minimal() or not model.is_strongly_connected(): - pytest.skip(f"seed {seed_val} does not produce a minimal, strongly connected model") - - sul = AutomatonSUL(model) - input_alphabet = model.get_input_alphabet() - - if oracle_name == 'wp': - eq_oracle = RandomWphWOracle(random_walk_length=4 * num_states, - num_test_origin_states=4 * num_states) - else: - eq_oracle = RandomhWOracle(num_testing_steps=1000 * num_states) - - learned_model = run_hW(input_alphabet, - sul, - eq_oracle, - automaton_type='mealy', - query_for_initial_state=True, - print_level=0) - - assert learned_model.is_minimal() - assert bisimilar(model, learned_model) - - -def test_hw_uses_user_provided_h_and_w(): - from aalpy import bisimilar, generate_random_deterministic_automata, run_hW, AutomatonSUL - from random import seed - seed(1) - - model = generate_random_deterministic_automata( - 'mealy', - num_states=100, - input_alphabet_size=4, - output_alphabet_size=4, - ) - - sul = AutomatonSUL(model) - input_alphabet = model.get_input_alphabet() - - char_set = model.compute_characterization_set() - - assert model.is_minimal() and model.is_minimal() - - learned_model = run_hW(input_alphabet, - sul, - RandomhWOracle(num_testing_steps=2000), - automaton_type='mealy', - provided_characterization_set=char_set, - query_for_initial_state=True) - - assert learned_model.is_minimal() - assert bisimilar(model, learned_model) - diff --git a/tests/test_non_deterministic.py b/tests/test_non_deterministic.py deleted file mode 100644 index 0b0cb0ac253..00000000000 --- a/tests/test_non_deterministic.py +++ /dev/null @@ -1,30 +0,0 @@ -import unittest - - -class NonDeterministicTest(unittest.TestCase): - - def test_non_det(self): - - from aalpy.SULs import AutomatonSUL - from aalpy.oracles import RandomWordEqOracle, RandomWalkEqOracle - from aalpy.learning_algs import run_non_det_Lstar - from aalpy.utils import get_benchmark_ONFSM - - onfsm = get_benchmark_ONFSM() - alphabet = onfsm.get_input_alphabet() - - for _ in range(100): - sul = AutomatonSUL(onfsm) - - oracle = RandomWordEqOracle(alphabet, sul, num_walks=500, min_walk_len=2, max_walk_len=5) - - learned_onfsm = run_non_det_Lstar(alphabet, sul, oracle, n_sampling=50, print_level=0) - - eq_oracle = RandomWalkEqOracle(alphabet, sul, num_steps=10000, reset_prob=0.09, - reset_after_cex=True) - - cex = eq_oracle.find_cex(learned_onfsm) - - if cex or len(learned_onfsm.states) != len(onfsm.states): - assert False - assert True diff --git a/tests/test_rwpmethod_oracle.py b/tests/test_rwpmethod_oracle.py deleted file mode 100644 index c99892f4789..00000000000 --- a/tests/test_rwpmethod_oracle.py +++ /dev/null @@ -1,113 +0,0 @@ -import unittest - -try: - from aalpy.automata import MooreMachine, MooreState - from aalpy.learning_algs import run_Lstar - from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle - from aalpy.SULs import AutomatonSUL - from aalpy.utils import visualize_automaton -except ImportError: - import sys - from pathlib import Path - - # if you want to run the test directly from CLI - # either from root or from tests folder - p = Path(__file__).parent.resolve() - sys.path.append(str(p)) - sys.path.append(str(p.parent)) - from aalpy.automata import MooreMachine, MooreState - from aalpy.learning_algs import run_Lstar - from aalpy.oracles.WpMethodEqOracle import RandomWpMethodEqOracle - from aalpy.SULs import AutomatonSUL - from aalpy.utils import visualize_automaton - - -class TestRandomWpMethodOracle(unittest.TestCase): - @staticmethod - def gen_moore_from_state_setup(state_setup) -> MooreMachine: - # state_setup shoud map from state_id to tuple(output and transitions_dict) - - # build states with state_id and output - states = {key: MooreState(key, val[0]) for key, val in state_setup.items()} - - # add transitions to states - for state_id, state in states.items(): - for _input, target_state_id in state_setup[state_id][1].items(): - state.transitions[_input] = states[target_state_id] - - # states to list - states = [state for state in states.values()] - - # build moore machine with first state as starting state - mm = MooreMachine(states[0], states) - - for state in states: - state.prefix = mm.get_shortest_path(mm.initial_state, state) - - return mm - - def generate_real_automata(self) -> MooreMachine: - state_setup = { - "a": ("a", {"x": "b1", "y": "a"}), - "b1": ("b", {"x": "b2", "y": "a"}), - "b2": ("b", {"x": "b3", "y": "a"}), - "b3": ("b", {"x": "b4", "y": "a"}), - "b4": ("b", {"x": "c", "y": "a"}), - "c": ("c", {"x": "a", "y": "a"}), - } - - mm = self.gen_moore_from_state_setup(state_setup) - mm.characterization_set = mm.compute_characterization_set() + [tuple()] - return mm - - def generate_hypothesis(self) -> MooreMachine: - state_setup = { - "a": ("a", {"x": "b", "y": "a"}), - "b": ("b", {"x": "b", "y": "a"}), - } - - mm = self.gen_moore_from_state_setup(state_setup) - # ! computer_characterization_set does not work for Moore machines in general! - # mm.characterization_set = mm.compute_characterization_set() + [tuple()] - mm.characterization_set = [tuple(), ("x",), ("y",)] - return mm - - def test_rwpmethod_oracle(self): - real = self.generate_real_automata() - hyp = self.generate_hypothesis() - # visualize_automaton(real) - # visualize_automaton(hyp) - assert set(real.get_input_alphabet()) == {"x", "y"} - assert set(hyp.get_input_alphabet()) == {"x", "y"} - assert len(real.states) == 6 - assert len(hyp.states) == 2 - alphabet = real.get_input_alphabet() - oracle = RandomWpMethodEqOracle( - alphabet, AutomatonSUL(real) - ) - cex = oracle.find_cex(hyp) - assert cex is not None, "Expected a counterexample, but got None" - - def test_rwpmethod_oracle_with_lstar(self): - real = self.generate_real_automata() - hyp = self.generate_hypothesis() - # visualize_automaton(real) - # visualize_automaton(hyp) - assert real.get_input_alphabet() == ["x", "y"] - assert hyp.get_input_alphabet() == ["x", "y"] - assert len(real.states) == 6 - assert len(hyp.states) == 2 - alphabet = real.get_input_alphabet() - oracle = RandomWpMethodEqOracle( - alphabet, AutomatonSUL(real) - ) - lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore") - # print(lstar_hyp) - # visualize_automaton(lstar_hyp) - assert ( - len(lstar_hyp.states) == 6 - ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_stochastic.py b/tests/test_stochastic.py deleted file mode 100644 index 99b90b1b581..00000000000 --- a/tests/test_stochastic.py +++ /dev/null @@ -1,70 +0,0 @@ -import unittest - -import aalpy.paths -from aalpy.SULs import AutomatonSUL -from aalpy.learning_algs import run_stochastic_Lstar -from aalpy.oracles import RandomWalkEqOracle -from aalpy.utils import load_automaton_from_file - - -class StochasticTest(unittest.TestCase): - - def test_learning_based_on_accuracy_based_stopping(self): - - example = 'first_grid' - mdp = load_automaton_from_file(f'../DotModels/MDPs/{example}.dot', automaton_type='mdp') - - min_rounds = 10 - max_rounds = 500 - - from aalpy.automata import StochasticMealyMachine - from aalpy.utils import model_check_experiment, get_properties_file, \ - get_correct_prop_values - from aalpy.automata.StochasticMealyMachine import smm_to_mdp_conversion - - aalpy.paths.path_to_prism = "C:/Program Files/prism-4.6/bin/prism.bat" - aalpy.paths.path_to_properties = "../Benchmarking/prism_eval_props/" - - stopping_based_on_prop = (get_properties_file(example), get_correct_prop_values(example), 0.02) - - input_alphabet = mdp.get_input_alphabet() - - automaton_type = ['mdp', 'smm'] - similarity_strategy = ['classic', 'normal', 'chi2'] - cex_processing = [None, 'longest_prefix'] - samples_cex_strategy = [None, 'bfs', 'random:200:0.3'] - - for aut_type in automaton_type: - for strategy in similarity_strategy: - for cex in cex_processing: - for sample_cex in samples_cex_strategy: - - sul = AutomatonSUL(mdp) - - eq_oracle = RandomWalkEqOracle(input_alphabet, sul=sul, num_steps=200, - reset_prob=0.25, - reset_after_cex=True) - - learned_model = run_stochastic_Lstar(input_alphabet=input_alphabet, eq_oracle=eq_oracle, - sul=sul, n_c=20, - n_resample=1000, min_rounds=min_rounds, - max_rounds=max_rounds, - automaton_type=aut_type, strategy=strategy, - cex_processing=cex, - samples_cex_strategy=sample_cex, target_unambiguity=0.99, - property_based_stopping=stopping_based_on_prop, - print_level=0) - - if isinstance(learned_model, StochasticMealyMachine): - mdp = smm_to_mdp_conversion(learned_model) - else: - mdp = learned_model - - results, diff = model_check_experiment(get_properties_file(example), - get_correct_prop_values(example), mdp) - - for d in diff.values(): - if d > stopping_based_on_prop[2]: - assert False - - assert True diff --git a/tests/test_wmethod_oracle.py b/tests/test_wmethod_oracle.py deleted file mode 100644 index 97412265dda..00000000000 --- a/tests/test_wmethod_oracle.py +++ /dev/null @@ -1,109 +0,0 @@ -import unittest - -try: - from aalpy.automata import MooreMachine, MooreState - from aalpy.learning_algs import run_Lstar - from aalpy.oracles.WMethodEqOracle import WMethodEqOracle - from aalpy.SULs import AutomatonSUL - from aalpy.utils import visualize_automaton -except ImportError: - import sys - from pathlib import Path - - # if you want to run the test directly from CLI - # either from root or from tests folder - p = Path(__file__).parent.resolve() - sys.path.append(str(p)) - sys.path.append(str(p.parent)) - from aalpy.automata import MooreMachine, MooreState - from aalpy.learning_algs import run_Lstar - from aalpy.oracles.WMethodEqOracle import WMethodEqOracle - from aalpy.SULs import AutomatonSUL - from aalpy.utils import visualize_automaton - - -class TestWMethodOracle(unittest.TestCase): - @staticmethod - def gen_moore_from_state_setup(state_setup) -> MooreMachine: - # state_setup shoud map from state_id to tuple(output and transitions_dict) - - # build states with state_id and output - states = {key: MooreState(key, val[0]) for key, val in state_setup.items()} - - # add transitions to states - for state_id, state in states.items(): - for _input, target_state_id in state_setup[state_id][1].items(): - state.transitions[_input] = states[target_state_id] - - # states to list - states = [state for state in states.values()] - - # build moore machine with first state as starting state - mm = MooreMachine(states[0], states) - - for state in states: - state.prefix = mm.get_shortest_path(mm.initial_state, state) - - return mm - - def generate_real_automata(self) -> MooreMachine: - state_setup = { - "a": ("a", {"x": "b1", "y": "a"}), - "b1": ("b", {"x": "b2", "y": "a"}), - "b2": ("b", {"x": "b3", "y": "a"}), - "b3": ("b", {"x": "b4", "y": "a"}), - "b4": ("b", {"x": "c", "y": "a"}), - "c": ("c", {"x": "a", "y": "a"}), - } - - mm = self.gen_moore_from_state_setup(state_setup) - mm.characterization_set = mm.compute_characterization_set() + [tuple()] - return mm - - def generate_hypothesis(self) -> MooreMachine: - state_setup = { - "a": ("a", {"x": "b", "y": "a"}), - "b": ("b", {"x": "b", "y": "a"}), - } - - mm = self.gen_moore_from_state_setup(state_setup) - # ! computer_characterization_set does not work for Moore machines in general! - # mm.characterization_set = mm.compute_characterization_set() + [tuple()] - mm.characterization_set = [tuple(), ("x",), ("y",)] - return mm - - def test_wmethod_oracle(self): - real = self.generate_real_automata() - hyp = self.generate_hypothesis() - # visualize_automaton(real) - # visualize_automaton(hyp) - assert set(real.get_input_alphabet()) == {"x", "y"} - assert set(hyp.get_input_alphabet()) == {"x", "y"} - assert len(real.states) == 6 - assert len(hyp.states) == 2 - alphabet = real.get_input_alphabet() - oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1) - cex = oracle.find_cex(hyp) - assert cex is not None, "Expected a counterexample, but got None" - - def test_wmethod_oracle_with_lstar(self): - real = self.generate_real_automata() - hyp = self.generate_hypothesis() - # visualize_automaton(real) - # visualize_automaton(hyp) - assert real.get_input_alphabet() == ["x", "y"] - assert hyp.get_input_alphabet() == ["x", "y"] - assert len(real.states) == 6 - assert len(hyp.states) == 2 - alphabet = real.get_input_alphabet() - oracle = WMethodEqOracle(alphabet, AutomatonSUL(real), len(real.states) + 1) - lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore") - # print(lstar_hyp) - # visualize_automaton(lstar_hyp) - assert ( - len(lstar_hyp.states) == 6 - ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_wpmethod_oracle.py b/tests/test_wpmethod_oracle.py deleted file mode 100644 index d1498235914..00000000000 --- a/tests/test_wpmethod_oracle.py +++ /dev/null @@ -1,113 +0,0 @@ -import unittest - -try: - from aalpy.automata import MooreMachine, MooreState - from aalpy.learning_algs import run_Lstar - from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle - from aalpy.SULs import AutomatonSUL - from aalpy.utils import visualize_automaton -except ImportError: - import sys - from pathlib import Path - - # if you want to run the test directly from CLI - # either from root or from tests folder - p = Path(__file__).parent.resolve() - sys.path.append(str(p)) - sys.path.append(str(p.parent)) - from aalpy.automata import MooreMachine, MooreState - from aalpy.learning_algs import run_Lstar - from aalpy.oracles.WpMethodEqOracle import WpMethodEqOracle - from aalpy.SULs import AutomatonSUL - from aalpy.utils import visualize_automaton - - -class TestWpMethodOracle(unittest.TestCase): - @staticmethod - def gen_moore_from_state_setup(state_setup) -> MooreMachine: - # state_setup shoud map from state_id to tuple(output and transitions_dict) - - # build states with state_id and output - states = {key: MooreState(key, val[0]) for key, val in state_setup.items()} - - # add transitions to states - for state_id, state in states.items(): - for _input, target_state_id in state_setup[state_id][1].items(): - state.transitions[_input] = states[target_state_id] - - # states to list - states = [state for state in states.values()] - - # build moore machine with first state as starting state - mm = MooreMachine(states[0], states) - - for state in states: - state.prefix = mm.get_shortest_path(mm.initial_state, state) - - return mm - - def generate_real_automata(self) -> MooreMachine: - state_setup = { - "a": ("a", {"x": "b1", "y": "a"}), - "b1": ("b", {"x": "b2", "y": "a"}), - "b2": ("b", {"x": "b3", "y": "a"}), - "b3": ("b", {"x": "b4", "y": "a"}), - "b4": ("b", {"x": "c", "y": "a"}), - "c": ("c", {"x": "a", "y": "a"}), - } - - mm = self.gen_moore_from_state_setup(state_setup) - mm.characterization_set = mm.compute_characterization_set() + [tuple()] - return mm - - def generate_hypothesis(self) -> MooreMachine: - state_setup = { - "a": ("a", {"x": "b", "y": "a"}), - "b": ("b", {"x": "b", "y": "a"}), - } - - mm = self.gen_moore_from_state_setup(state_setup) - # ! computer_characterization_set does not work for Moore machines in general! - # mm.characterization_set = mm.compute_characterization_set() + [tuple()] - mm.characterization_set = [tuple(), ("x",), ("y",)] - return mm - - def test_wpmethod_oracle(self): - real = self.generate_real_automata() - hyp = self.generate_hypothesis() - # visualize_automaton(real) - # visualize_automaton(hyp) - assert set(real.get_input_alphabet()) == {"x", "y"} - assert set(hyp.get_input_alphabet()) == {"x", "y"} - assert len(real.states) == 6 - assert len(hyp.states) == 2 - alphabet = real.get_input_alphabet() - oracle = WpMethodEqOracle( - alphabet, AutomatonSUL(real), len(real.states) + 1 - ) - cex = oracle.find_cex(hyp) - assert cex is not None, "Expected a counterexample, but got None" - - def test_wpmethod_oracle_with_lstar(self): - real = self.generate_real_automata() - hyp = self.generate_hypothesis() - # visualize_automaton(real) - # visualize_automaton(hyp) - assert real.get_input_alphabet() == ["x", "y"] - assert hyp.get_input_alphabet() == ["x", "y"] - assert len(real.states) == 6 - assert len(hyp.states) == 2 - alphabet = real.get_input_alphabet() - oracle = WpMethodEqOracle( - alphabet, AutomatonSUL(real), len(real.states) + 1 - ) - lstar_hyp = run_Lstar(alphabet, AutomatonSUL(real), oracle, "moore") - # print(lstar_hyp) - # visualize_automaton(lstar_hyp) - assert ( - len(lstar_hyp.states) == 6 - ), f"Expected {6} states got {len(lstar_hyp.states)} in lstar hypothesis" - - -if __name__ == "__main__": - unittest.main() From 4ffadf4c45b1c5f3fdbcddbd524f450cce2c782e Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:26:56 +0200 Subject: [PATCH 17/25] update version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 94a322ff04f..cc2d84b6583 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "aalpy" -version = "1.6.2" +version = "2.0.0" description = "An active automata learning library" readme = "README.md" requires-python = ">=3.10" From 2bf7ab90c6f6f9c645c5ed52bcfb19d80f46429b Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:28:21 +0200 Subject: [PATCH 18/25] Update python version in CI/CD --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 6e100961e6e..e46294793c1 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -16,10 +16,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.10 - name: Install dependencies run: | python -m pip install --upgrade pip From 6894de5035d5f9801fb818a6eb3d31058ab0bac3 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:28:29 +0200 Subject: [PATCH 19/25] Update typing --- aalpy/automata/MooreMachine.py | 5 +- .../stochastic_passive/ActiveAleriga.py | 7 +-- aalpy/utils/BenchmarkSULs.py | 47 ++++++------------- 3 files changed, 20 insertions(+), 39 deletions(-) diff --git a/aalpy/automata/MooreMachine.py b/aalpy/automata/MooreMachine.py index beb086045a2..bf9f10a3d59 100644 --- a/aalpy/automata/MooreMachine.py +++ b/aalpy/automata/MooreMachine.py @@ -2,6 +2,7 @@ from collections.abc import Hashable from typing import Generic +from aalpy.automata.Dfa import Dfa, DfaState from aalpy.base import AutomatonState, DeterministicAutomaton from aalpy.base.Automaton import InputType, OutputType @@ -150,15 +151,13 @@ def from_state_setup(state_setup: dict, **kwargs) -> 'MooreMachine': return mm @staticmethod - def to_dfa(moore_machine: 'MooreMachine') -> 'Dfa': + def to_dfa(moore_machine: 'MooreMachine') -> Dfa: """ Converts a Moore machine with boolean state outputs to a DFA. :param MooreMachine moore_machine: Moore machine to convert. All states must have boolean outputs. :return Dfa: The equivalent DFA. """ - from aalpy.automata.Dfa import Dfa, DfaState - if not all(isinstance(state.output, bool) for state in moore_machine.states): raise ValueError('Only Moore machines with boolean state outputs can be cast to a Dfa.') diff --git a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py index acf4d14d7eb..07e418903cc 100644 --- a/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py +++ b/aalpy/learning_algs/stochastic_passive/ActiveAleriga.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from random import randint, choice +from aalpy.automata import Mdp from aalpy.base import SUL from aalpy.learning_algs import run_Alergia from aalpy.learning_algs.stochastic_passive.CompatibilityChecker import CompatibilityChecker @@ -14,7 +15,7 @@ class Sampler(ABC): """ @abstractmethod - def sample(self, sul: SUL, model: 'Mdp') -> list: + def sample(self, sul: SUL, model: Mdp) -> list: """ Abstract method implementing sampling strategy. @@ -42,7 +43,7 @@ def __init__(self, num_walks: int, min_walk_len: int, max_walk_len: int) -> None self.min_walk_len = min_walk_len self.max_walk_len = max_walk_len - def sample(self, sul: SUL, model: 'Mdp') -> list: + def sample(self, sul: SUL, model: Mdp) -> list: """ Samples num_walks random walks of random length over the current hypothesis' input alphabet. @@ -73,7 +74,7 @@ def sample(self, sul: SUL, model: 'Mdp') -> list: def run_active_Alergia(data: list, sul: SUL, sampler: Sampler, n_iter: int, eps: float | str = 0.05, compatibility_checker: CompatibilityChecker | None = None, automaton_type: str = 'mdp', - print_info: bool = True) -> 'Mdp': + print_info: bool = True) -> Mdp: """ Active version of IOAlergia algorithm. Based on intermediate hypothesis sampling on the system is performed. Sampled data is added to the learning data and more accurate model is learned. diff --git a/aalpy/utils/BenchmarkSULs.py b/aalpy/utils/BenchmarkSULs.py index 393b5bc318b..acbca6e1b62 100644 --- a/aalpy/utils/BenchmarkSULs.py +++ b/aalpy/utils/BenchmarkSULs.py @@ -1,13 +1,14 @@ # Hand-crafted example automata and systems under learning used throughout benchmarks, examples and tests. from typing import Any +from aalpy.automata import Dfa, Onfsm, OnfsmState, Mdp, MdpState, StochasticMealyMachine, StochasticMealyState +from aalpy.base import SUL -def get_Angluin_dfa() -> 'Dfa': + +def get_Angluin_dfa() -> Dfa: """ :return Dfa: The classical DFA example used by Angluin to illustrate L*. """ - from aalpy.automata import Dfa - angluin_dfa = { 'q0': (True, {'a': 'q1', 'b': 'q2'}), 'q1': (False, {'a': 'q0', 'b': 'q3'}), @@ -18,15 +19,13 @@ def get_Angluin_dfa() -> 'Dfa': return Dfa.from_state_setup(angluin_dfa) -def get_benchmark_ONFSM() -> 'Onfsm': +def get_benchmark_ONFSM() -> Onfsm: """ Returns ONFSM presented in 'Learning Finite State Models of Observable Nondeterministic Systems in a Testing Context'. :return Onfsm: The example ONFSM. """ - from aalpy.automata import Onfsm, OnfsmState - a = OnfsmState('q0') b = OnfsmState('q1') c = OnfsmState('g2') @@ -49,14 +48,12 @@ def get_benchmark_ONFSM() -> 'Onfsm': return Onfsm(a, [a, b, c, d]) -def get_ONFSM() -> 'Onfsm': +def get_ONFSM() -> Onfsm: """ Returns example of an ONFSM. :return Onfsm: The example ONFSM. """ - from aalpy.automata import Onfsm, OnfsmState - q0 = OnfsmState('q0') q1 = OnfsmState('q1') q2 = OnfsmState('q2') @@ -97,12 +94,10 @@ def get_ONFSM() -> 'Onfsm': return Onfsm(q0, [q0, q1, q2, q3, q4, q5, q6, q7, q8]) -def get_faulty_coffee_machine_MDP() -> 'Mdp': +def get_faulty_coffee_machine_MDP() -> Mdp: """ :return Mdp: An MDP modeling a coffee machine that occasionally serves coffee without the beep step. """ - from aalpy.automata import Mdp, MdpState - q0 = MdpState("q0", "init") q1 = MdpState("q1", "beep") q2 = MdpState("q2", "coffee") @@ -120,12 +115,10 @@ def get_faulty_coffee_machine_MDP() -> 'Mdp': return mdp -def get_weird_coffee_machine_MDP() -> 'Mdp': +def get_weird_coffee_machine_MDP() -> Mdp: """ :return Mdp: An MDP modeling a coffee machine with an alternate 'koin' input path that can crash the machine. """ - from aalpy.automata import Mdp, MdpState - q0 = MdpState("q0", "init") q1 = MdpState("q1", "beep") q2 = MdpState("q2", "coffee") @@ -171,13 +164,11 @@ def get_weird_coffee_machine_MDP() -> 'Mdp': return mdp -def get_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine': +def get_faulty_coffee_machine_SMM() -> StochasticMealyMachine: """ :return StochasticMealyMachine: A stochastic Mealy machine modeling a coffee machine that occasionally serves coffee without the beep step. """ - from aalpy.automata import StochasticMealyMachine, StochasticMealyState - s0 = StochasticMealyState('q0') s1 = StochasticMealyState('q1') s2 = StochasticMealyState('q2') @@ -195,12 +186,10 @@ def get_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine': return smm -def get_minimal_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine': +def get_minimal_faulty_coffee_machine_SMM() -> StochasticMealyMachine: """ :return StochasticMealyMachine: A minimal 2-state stochastic Mealy machine modeling the faulty coffee machine. """ - from aalpy.automata import StochasticMealyMachine, StochasticMealyState - s0 = StochasticMealyState('q0') s1 = StochasticMealyState('q1') @@ -215,12 +204,10 @@ def get_minimal_faulty_coffee_machine_SMM() -> 'StochasticMealyMachine': return smm -def get_faulty_mqtt_SMM() -> 'StochasticMealyMachine': +def get_faulty_mqtt_SMM() -> StochasticMealyMachine: """ :return StochasticMealyMachine: A stochastic Mealy machine modeling a faulty MQTT broker. """ - from aalpy.automata import StochasticMealyMachine, StochasticMealyState - s0 = StochasticMealyState('q0') s1 = StochasticMealyState('q1') s2 = StochasticMealyState('q2') @@ -250,13 +237,11 @@ def get_faulty_mqtt_SMM() -> 'StochasticMealyMachine': return smm -def get_small_gridworld() -> 'StochasticMealyMachine': +def get_small_gridworld() -> StochasticMealyMachine: """ :return StochasticMealyMachine: A stochastic Mealy machine modeling a small 2x2 gridworld with mud and grass tiles. """ - from aalpy.automata import StochasticMealyMachine, StochasticMealyState - s0 = StochasticMealyState('q0') s1 = StochasticMealyState('q1') s2 = StochasticMealyState('q2') @@ -429,12 +414,10 @@ def is_date_accepted(self, date_string: str) -> bool: return True -def get_small_pomdp() -> 'Mdp': +def get_small_pomdp() -> Mdp: """ :return Mdp: An MDP with partially observable states (a small POMDP-like example). """ - from aalpy.automata import Mdp, MdpState - q0 = MdpState("q0", "init") q1 = MdpState("q1", "beep") q2 = MdpState("q2", "beep") @@ -490,7 +473,7 @@ def is_balanced(test_string: str, call_return_map: dict, allow_empty_string: boo return not stack if allow_empty_string else not stack and len(test_string) > 0 -def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) -> 'SUL': +def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) -> SUL: """ Creates a SUL that accepts balanced strings of call/return symbols. @@ -498,8 +481,6 @@ def get_balanced_string_sul(call_return_map: dict, allow_empty_string: bool) -> :param bool allow_empty_string: Whether an empty string counts as balanced. :return SUL: The constructed SUL. """ - from aalpy.base import SUL - class BalancedStringSUL(SUL): """ System under learning that checks whether the sequence of inputs seen so far is a balanced string. From c4eec084b8a68e64f2e458ff756948166d9926b3 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:30:43 +0200 Subject: [PATCH 20/25] Update python-app.yml --- .github/workflows/python-app.yml | 35 ++++++++++++++------------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e46294793c1..21da6138aee 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,6 +1,3 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - name: Python application on: @@ -11,24 +8,22 @@ on: jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.10 - uses: actions/setup-python@v2 - with: - python-version: 3.10 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \ No newline at end of file From 58cba98fb5b40ba0e3e2c5a7fab7b67f2c70c12c Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:32:26 +0200 Subject: [PATCH 21/25] Update python-app.yml --- .github/workflows/python-app.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 21da6138aee..a3227255afd 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,4 +26,7 @@ jobs: - name: Lint with flake8 run: | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \ No newline at end of file + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests + run: pytest -q \ No newline at end of file From c175c0b313b8f24cbbe56728550b9d000a323240 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:32:26 +0200 Subject: [PATCH 22/25] Update python-app.yml --- .github/workflows/python-app.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 21da6138aee..a3227255afd 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,4 +26,7 @@ jobs: - name: Lint with flake8 run: | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \ No newline at end of file + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests + run: pytest -q \ No newline at end of file From e6cedb96e30a94f4bd8f1b86b41955f028a549ba Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:34:41 +0200 Subject: [PATCH 23/25] Update python-app.yml --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a3227255afd..d51aa775c42 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest + pip install flake8 pytest pydot if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 From 791a63e2800a6d8a282cb3010550e15b0818df6e Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:36:51 +0200 Subject: [PATCH 24/25] Update python-app.yml --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index d51aa775c42..b9c3387e0f5 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest pydot + pip install flake8 pytest pytest-timeout pydot if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 From a6d35773cbe1acb790d65465f4649eb85536632f Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:43:31 +0200 Subject: [PATCH 25/25] Update failing patch in test_user_input_eq_oracle.py --- tests/oracles/test_user_input_eq_oracle.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/oracles/test_user_input_eq_oracle.py b/tests/oracles/test_user_input_eq_oracle.py index 4185fe23856..60ed37a2e98 100644 --- a/tests/oracles/test_user_input_eq_oracle.py +++ b/tests/oracles/test_user_input_eq_oracle.py @@ -1,8 +1,9 @@ +import importlib import unittest from unittest.mock import patch from aalpy.automata import MealyMachine, MealyState -from aalpy.oracles import UserInputEqOracle +from aalpy.oracles.UserInputEqOracle import UserInputEqOracle from aalpy.SULs import AutomatonSUL @@ -22,7 +23,8 @@ def sample_mealy(): class UserInputEqOracleTests(unittest.TestCase): def setUp(self): - self.visualize_patcher = patch('aalpy.oracles.UserInputEqOracle.visualize_automaton') + module = importlib.import_module("aalpy.oracles.UserInputEqOracle") + self.visualize_patcher = patch.object(module, "visualize_automaton") self.visualize_patcher.start() def tearDown(self):