diff --git a/evaluation_function/schemas/__init__.py b/evaluation_function/schemas/__init__.py index a4c505b..64e6321 100644 --- a/evaluation_function/schemas/__init__.py +++ b/evaluation_function/schemas/__init__.py @@ -18,7 +18,7 @@ "Params", # Result "Result", - "ValidationResult" + "ValidationResult", "ValidationError", "ElementHighlight", "ErrorCode", diff --git a/evaluation_function/test/test_correction.py b/evaluation_function/test/test_correction.py index 5bb519c..51a0620 100644 --- a/evaluation_function/test/test_correction.py +++ b/evaluation_function/test/test_correction.py @@ -214,7 +214,19 @@ def test_non_minimal_fsa_fails_when_required(self, equivalent_dfa): class TestEpsilonTransitionCorrection: """Test the full correction pipeline with ε-NFA inputs.""" - def test_epsilon_nfa_vs_equivalent_dfa_correct(self): + @pytest.fixture + def nfa_params(self): + """Params that allow NFA/ε-NFA student submissions.""" + return Params( + expected_type="any", + check_completeness=False, + check_minimality=False, + evaluation_mode="lenient", + highlight_errors=True, + feedback_verbosity="detailed", + ) + + def test_epsilon_nfa_vs_equivalent_dfa_correct(self, nfa_params): """ε-NFA student answer equivalent to DFA expected should be correct.""" # ε-NFA accepts exactly "a": q0 --ε--> q1 --a--> q2 student_enfa = make_fsa( @@ -237,11 +249,11 @@ def test_epsilon_nfa_vs_equivalent_dfa_correct(self): initial="s0", accept=["s1"], ) - result = analyze_fsa_correction(student_enfa, expected_dfa) + result = analyze_fsa_correction(student_enfa, expected_dfa, nfa_params) assert isinstance(result, Result) assert result.is_correct is True - def test_epsilon_nfa_vs_different_dfa_incorrect(self): + def test_epsilon_nfa_vs_different_dfa_incorrect(self, nfa_params): """ε-NFA accepting 'a' vs DFA accepting 'b' should be incorrect.""" student_enfa = make_fsa( states=["q0", "q1", "q2"], @@ -262,13 +274,13 @@ def test_epsilon_nfa_vs_different_dfa_incorrect(self): initial="s0", accept=["s1"], ) - result = analyze_fsa_correction(student_enfa, expected_dfa) + result = analyze_fsa_correction(student_enfa, expected_dfa, nfa_params) assert isinstance(result, Result) assert result.is_correct is False assert result.fsa_feedback is not None assert len(result.fsa_feedback.errors) > 0 - def test_multi_epsilon_nfa_vs_dfa_correct(self): + def test_multi_epsilon_nfa_vs_dfa_correct(self, nfa_params): """ε-NFA for (a|b) with branching epsilons should match equivalent DFA.""" student_enfa = make_fsa( states=["q0", "q1", "q2", "q3"], @@ -292,11 +304,11 @@ def test_multi_epsilon_nfa_vs_dfa_correct(self): initial="s0", accept=["s1"], ) - result = analyze_fsa_correction(student_enfa, expected_dfa) + result = analyze_fsa_correction(student_enfa, expected_dfa, nfa_params) assert isinstance(result, Result) assert result.is_correct is True - def test_epsilon_nfa_structural_info_reports_nondeterministic(self): + def test_epsilon_nfa_structural_info_reports_nondeterministic(self, nfa_params): """ε-NFA should have structural info reporting non-deterministic.""" student_enfa = make_fsa( states=["q0", "q1", "q2"], @@ -317,7 +329,7 @@ def test_epsilon_nfa_structural_info_reports_nondeterministic(self): initial="s0", accept=["s1"], ) - result = analyze_fsa_correction(student_enfa, expected_dfa) + result = analyze_fsa_correction(student_enfa, expected_dfa, nfa_params) assert result.fsa_feedback is not None assert result.fsa_feedback.structural is not None assert result.fsa_feedback.structural.is_deterministic is False diff --git a/evaluation_function/test/test_validation.py b/evaluation_function/test/test_validation.py index 9423774..d9a90f5 100644 --- a/evaluation_function/test/test_validation.py +++ b/evaluation_function/test/test_validation.py @@ -314,7 +314,7 @@ def test_isomorphic_dfas(self): initial="s0", accept=["s1"], ) - assert are_isomorphic(fsa_user, fsa_sol) == [] + assert are_isomorphic(fsa_user, fsa_sol).ok class TestEpsilonTransitions: @@ -332,7 +332,7 @@ def test_valid_fsa_with_epsilon_unicode(self): initial="q0", accept=["q2"], ) - assert is_valid_fsa(fsa) == [] + assert is_valid_fsa(fsa).ok def test_valid_fsa_with_epsilon_string(self): """ε-NFA with 'epsilon' string should pass structural validation.""" @@ -346,7 +346,7 @@ def test_valid_fsa_with_epsilon_string(self): initial="q0", accept=["q2"], ) - assert is_valid_fsa(fsa) == [] + assert is_valid_fsa(fsa).ok def test_valid_fsa_with_empty_string_epsilon(self): """ε-NFA with empty string epsilon should pass structural validation.""" @@ -360,7 +360,7 @@ def test_valid_fsa_with_empty_string_epsilon(self): initial="q0", accept=["q2"], ) - assert is_valid_fsa(fsa) == [] + assert is_valid_fsa(fsa).ok def test_epsilon_nfa_is_not_deterministic(self): """ε-NFA should be flagged as non-deterministic.""" @@ -373,9 +373,9 @@ def test_epsilon_nfa_is_not_deterministic(self): initial="q0", accept=["q1"], ) - errors = is_deterministic(fsa) - assert len(errors) > 0 - assert ErrorCode.NOT_DETERMINISTIC in [e.code for e in errors] + result = is_deterministic(fsa) + assert not result.ok + assert ErrorCode.NOT_DETERMINISTIC in [e.code for e in result.errors] def test_accepts_string_via_epsilon_closure(self): """ε-NFA should accept 'a' by following q0 --ε--> q1 --a--> q2.""" @@ -389,7 +389,7 @@ def test_accepts_string_via_epsilon_closure(self): initial="q0", accept=["q2"], ) - assert accepts_string(fsa, "a") == [] + assert accepts_string(fsa, "a").ok def test_rejects_string_with_epsilon_nfa(self): """ε-NFA that accepts 'a' should reject empty string.""" @@ -403,8 +403,8 @@ def test_rejects_string_with_epsilon_nfa(self): initial="q0", accept=["q2"], ) - errors = accepts_string(fsa, "") - assert len(errors) > 0 + result = accepts_string(fsa, "") + assert not result.ok def test_accepts_empty_string_via_epsilon(self): """ε-NFA should accept empty string when initial reaches accept via ε.""" @@ -417,7 +417,7 @@ def test_accepts_empty_string_via_epsilon(self): initial="q0", accept=["q1"], ) - assert accepts_string(fsa, "") == [] + assert accepts_string(fsa, "").ok def test_epsilon_nfa_equivalent_to_dfa(self): """ε-NFA and DFA accepting the same language should be equivalent.""" @@ -440,7 +440,7 @@ def test_epsilon_nfa_equivalent_to_dfa(self): initial="s0", accept=["s1"], ) - assert fsas_accept_same_language(enfa, dfa) == [] + assert fsas_accept_same_language(enfa, dfa).ok def test_epsilon_nfa_not_equivalent_to_different_dfa(self): """ε-NFA and DFA accepting different languages should not be equivalent.""" @@ -463,8 +463,8 @@ def test_epsilon_nfa_not_equivalent_to_different_dfa(self): initial="s0", accept=["s1"], ) - errors = fsas_accept_same_language(enfa, dfa) - assert len(errors) > 0 + result = fsas_accept_same_language(enfa, dfa) + assert not result.ok def test_multi_epsilon_nfa_equivalent_to_dfa(self): """ε-NFA for (a|b) with branching epsilons should match equivalent DFA.""" @@ -491,7 +491,7 @@ def test_multi_epsilon_nfa_equivalent_to_dfa(self): initial="s0", accept=["s1"], ) - assert fsas_accept_same_language(enfa, dfa) == [] + assert fsas_accept_same_language(enfa, dfa).ok if __name__ == "__main__": diff --git a/evaluation_function/validation/validation.py b/evaluation_function/validation/validation.py index 787835c..427b6b6 100644 --- a/evaluation_function/validation/validation.py +++ b/evaluation_function/validation/validation.py @@ -126,13 +126,7 @@ def is_deterministic(fsa: FSA) -> ValidationResult[bool]: return structural errors: List[ValidationError] = [] - seen = set() - - # First check if FSA is structurally valid - structural_errors = is_valid_fsa(fsa) - if structural_errors: - return structural_errors - + # Check for epsilon transitions (makes FSA non-deterministic) for t in fsa.transitions: if t.symbol in ("ε", "epsilon", ""): @@ -151,8 +145,10 @@ def is_deterministic(fsa: FSA) -> ValidationResult[bool]: ) ) if errors: - return errors + return ValidationResult.failure(False, errors) + # Check for multiple transitions on same (state, symbol) + seen: set = set() for t in fsa.transitions: key = (t.from_state, t.symbol) if key in seen: @@ -171,8 +167,6 @@ def is_deterministic(fsa: FSA) -> ValidationResult[bool]: ) seen.add(key) - return errors - return ( ValidationResult.success(True) if not errors @@ -230,7 +224,7 @@ def find_unreachable_states(fsa: FSA) -> ValidationResult[List[str]]: if fsa.initial_state not in set(fsa.states): return ValidationResult.success([]) - visited = set() + visited: Set[str] = set() queue = deque([fsa.initial_state]) while queue: @@ -239,7 +233,7 @@ def find_unreachable_states(fsa: FSA) -> ValidationResult[List[str]]: continue visited.add(state) for t in fsa.transitions: - if t.from_state == state: + if t.from_state == state and t.to_state not in visited: queue.append(t.to_state) unreachable = [s for s in fsa.states if s not in visited] @@ -278,13 +272,14 @@ def find_dead_states(fsa: FSA) -> ValidationResult[List[str]]: reachable_to_accept = set(fsa.accept_states) queue = deque(fsa.accept_states) - predecessors = {s: [] for s in fsa.states} + predecessors: Dict[str, List[str]] = {s: [] for s in fsa.states} for t in fsa.transitions: - predecessors[t.to_state].append(t.from_state) + if t.to_state in predecessors: + predecessors[t.to_state].append(t.from_state) while queue: state = queue.popleft() - for pred in predecessors[state]: + for pred in predecessors.get(state, []): if pred not in reachable_to_accept: reachable_to_accept.add(pred) queue.append(pred) @@ -312,20 +307,11 @@ def find_dead_states(fsa: FSA) -> ValidationResult[List[str]]: # ============================================================================= def accepts_string(fsa: FSA, string: str) -> ValidationResult[bool]: + """Simulate the FSA on a string, with full ε-transition support.""" valid = is_valid_fsa(fsa) if not valid.ok: return valid -def accepts_string(fsa: FSA, string: str) -> List[ValidationError]: - """ - Simulate the FSA on a string, with full ε-transition support. - Returns [] if accepted, else a ValidationError. - """ - # First check if FSA is structurally valid - structural_errors = is_valid_fsa(fsa) - if structural_errors: - return structural_errors - # Build epsilon transition map for ε-closure computation epsilon_trans = build_epsilon_transition_map(fsa.transitions) @@ -340,9 +326,9 @@ def accepts_string(fsa: FSA, string: str) -> List[ValidationError]: code=ErrorCode.INVALID_SYMBOL, severity="error" ) - ] + ]) - next_states = set() + next_states: Set[str] = set() for state in current_states: for t in fsa.transitions: if t.from_state == state and t.symbol == symbol: @@ -352,16 +338,14 @@ def accepts_string(fsa: FSA, string: str) -> List[ValidationError]: current_states = epsilon_closure_set(next_states, epsilon_trans) if not current_states: - return [ + return ValidationResult.failure(False, [ ValidationError( - message=f"String '{string}' rejected: no transition on symbol '{symbol}'", + message=f"String '{string}' rejected: no transition on symbol '{symbol}'.", code=ErrorCode.TEST_CASE_FAILED, severity="error" ) ]) - current_states = next_states - accepted = any(s in fsa.accept_states for s in current_states) return ( ValidationResult.success(True) @@ -391,62 +375,17 @@ def fsas_accept_same_string(fsa1: FSA, fsa2: FSA, string: str) -> ValidationResu code=ErrorCode.LANGUAGE_MISMATCH, severity="error" ) - ] - return [] + ]) + return ValidationResult.success(True) -def fsas_accept_same_language(fsa1: FSA, fsa2: FSA) -> List[ValidationError]: +def fsas_accept_same_language(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: # Convert NFA/ε-NFA to DFA before minimization (Hopcroft requires DFA input) if not is_dfa_check(fsa1): fsa1 = nfa_to_dfa(fsa1) if not is_dfa_check(fsa2): fsa2 = nfa_to_dfa(fsa2) - fsa1_min = hopcroft_minimization(fsa1) - fsa2_min = hopcroft_minimization(fsa2) - return are_isomorphic(fsa1_min, fsa2_min) - # """ - # Approximate check for language equivalence by testing all strings up to max_length. - # Returns [] if equivalent, else a ValidationError. - # """ - # errors = [] - - # if set(fsa1.alphabet) != set(fsa2.alphabet): - # errors.append( - # ValidationError( - # message=f"Alphabets of FSAs differ: FSA1 alphabet = {set(fsa1.alphabet)}, FSA2 alphabet = {set(fsa2.alphabet)}", - # code=ErrorCode.LANGUAGE_MISMATCH, - # severity="error" - # ) - # ) - # return errors - - # alphabet = fsa1.alphabet - - # # Check empty string - # empty_string_error = fsas_accept_same_string(fsa1, fsa2, "") - # if empty_string_error: - # return empty_string_error - - # for length in range(1, max_length + 1): - # for s in product(alphabet, repeat=length): - # string = ''.join(s) - # err = fsas_accept_same_string(fsa1, fsa2, string) - # if err: - # errors.append( - # ValidationError( - # message=f"FSAs differ on string '{string}' of length {length}", - # code=ErrorCode.LANGUAGE_MISMATCH, - # severity="error" - # ) - # ) - # return errors # stop at first counterexample - # return errors - - return ValidationResult.success(True) - - -def fsas_accept_same_language(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: return are_isomorphic( hopcroft_minimization(fsa1), hopcroft_minimization(fsa2), @@ -455,12 +394,12 @@ def fsas_accept_same_language(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: """ - Checks if two DFAs are isomorphic. - Returns a list of ValidationErrors if they differ, otherwise an empty list. + Checks if two DFAs are isomorphic. Assumes DFAs are minimized and complete. """ - errors = [] - # 1. Alphabet Check (Mandatory) + errors: List[ValidationError] = [] + + # 1. Alphabet Check if set(fsa1.alphabet) != set(fsa2.alphabet): errors.append( ValidationError( @@ -471,7 +410,7 @@ def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: ) ) - # 2. Basic Structural Check (State Count) + # 2. State Count Check if len(fsa1.states) != len(fsa2.states): errors.append( ValidationError( @@ -482,12 +421,11 @@ def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: ) ) - # 3. State Mapping Initialization + # 3. State Mapping via BFS mapping: Dict[str, str] = {fsa1.initial_state: fsa2.initial_state} queue = deque([fsa1.initial_state]) visited = {fsa1.initial_state} - # Optimization: Pre-map transitions trans1 = {(t.from_state, t.symbol): t.to_state for t in fsa1.transitions} trans2 = {(t.from_state, t.symbol): t.to_state for t in fsa2.transitions} accept1 = set(fsa1.accept_states) @@ -497,7 +435,7 @@ def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: s1 = queue.popleft() s2 = mapping[s1] - # 4. Check Acceptance Parity + # 4. Acceptance Parity if (s1 in accept1) != (s2 in accept2): expected_type = "accepting" if s2 in accept2 else "non-accepting" errors.append( @@ -510,12 +448,11 @@ def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: ) ) - # 5. Check Transitions for every symbol in the shared alphabet + # 5. Transitions for symbol in fsa1.alphabet: dest1 = trans1.get((s1, symbol)) dest2 = trans2.get((s2, symbol)) - # Missing Transition Check if (dest1 is None) != (dest2 is None): errors.append( ValidationError( @@ -526,15 +463,13 @@ def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: suggestion="Ensure your DFA is complete and follows the transition logic." ) ) - + if dest1 is not None: if dest1 not in mapping: - # New state discovered: check if we've exceeded state count in mapping mapping[dest1] = dest2 visited.add(dest1) queue.append(dest1) else: - # Consistency check: does fsa1 transition to the same logical state as fsa2? if mapping[dest1] != dest2: errors.append( ValidationError( @@ -542,14 +477,15 @@ def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: code=ErrorCode.LANGUAGE_MISMATCH, severity="error", highlight=ElementHighlight( - type="transition", - from_state=s1, - to_state=dest1, + type="transition", + from_state=s1, + to_state=dest1, symbol=symbol ), suggestion="Check if this transition should point to a different state." ) ) + return ( ValidationResult.success(True) if not errors