From b45fb8cc420343904e692048ac5f933f9278e75c Mon Sep 17 00:00:00 2001 From: everythingfades Date: Sat, 31 Jan 2026 21:43:03 +0000 Subject: [PATCH] fix: add config --- evaluation_function/correction/__init__.py | 2 - evaluation_function/correction/correction.py | 281 +++++---- evaluation_function/evaluation.py | 22 +- evaluation_function/schemas/__init__.py | 3 +- evaluation_function/schemas/fsaFrontend.py | 27 +- evaluation_function/schemas/result.py | 21 +- evaluation_function/test/test_correction.py | 31 +- evaluation_function/test/test_validation.py | 116 +++- evaluation_function/validation/validation.py | 623 +++++++------------ 9 files changed, 519 insertions(+), 607 deletions(-) diff --git a/evaluation_function/correction/__init__.py b/evaluation_function/correction/__init__.py index 4008ee1..306cb2e 100644 --- a/evaluation_function/correction/__init__.py +++ b/evaluation_function/correction/__init__.py @@ -9,10 +9,8 @@ from .correction import ( analyze_fsa_correction, - check_minimality, ) __all__ = [ "analyze_fsa_correction", - "check_minimality", ] diff --git a/evaluation_function/correction/correction.py b/evaluation_function/correction/correction.py index 9a8a0a6..e0e6d27 100644 --- a/evaluation_function/correction/correction.py +++ b/evaluation_function/correction/correction.py @@ -7,7 +7,9 @@ All detailed "why" feedback comes from are_isomorphic() in validation module. """ -from typing import List, Optional, Tuple +from typing import List, Optional + +from evaluation_function.schemas.params import Params # Schema imports from ..schemas import FSA, ValidationError, ErrorCode @@ -16,69 +18,60 @@ # Validation imports from ..validation.validation import ( is_valid_fsa, + is_deterministic, + is_complete, + is_minimal, fsas_accept_same_language, get_structured_info_of_fsa, ) -# Algorithm imports for minimality check -from ..algorithms.minimization import hopcroft_minimization - # ============================================================================= -# Minimality Check -# ============================================================================= - -def _check_minimality(fsa: FSA) -> Tuple[bool, Optional[ValidationError]]: - """Check if FSA is minimal by comparing with its minimized version.""" - try: - minimized = hopcroft_minimization(fsa) - if len(minimized.states) < len(fsa.states): - diff = len(fsa.states) - len(minimized.states) - return False, ValidationError( - message=f"Your FSA works correctly, but it's not minimal! You have {len(fsa.states)} states, but only {len(minimized.states)} are needed. You could remove {diff} state(s).", - code=ErrorCode.NOT_MINIMAL, - severity="error", - suggestion="Look for states that behave identically (same transitions and acceptance) - these can be merged into one" - ) - return True, None - except Exception: - return True, None - - -def check_minimality(fsa: FSA) -> bool: - """Check if FSA is minimal.""" - is_min, _ = _check_minimality(fsa) - return is_min - - -# ============================================================================= -# Helper Functions +# Feedback Helpers # ============================================================================= def _build_feedback( summary: str, validation_errors: List[ValidationError], equivalence_errors: List[ValidationError], - structural_info: Optional[StructuralInfo] + structural_info: Optional[StructuralInfo], + params: Params ) -> FSAFeedback: """Build FSAFeedback from errors and analysis.""" all_errors = validation_errors + equivalence_errors + errors = [e for e in all_errors if e.severity == "error"] warnings = [e for e in all_errors if e.severity in ("warning", "info")] - - # Build hints from all error suggestions - hints = [e.suggestion for e in all_errors if e.suggestion] - if structural_info: - if structural_info.unreachable_states: - unreachable = ", ".join(structural_info.unreachable_states) - hints.append(f"Tip: States {{{unreachable}}} can't be reached from your start state - you might want to remove them or add transitions to them") - if structural_info.dead_states: - dead = ", ".join(structural_info.dead_states) - hints.append(f"Tip: States {{{dead}}} can never lead to acceptance - this might be intentional (trap states) or a bug") - - # Build language comparison - language = LanguageComparison(are_equivalent=len(equivalence_errors) == 0) - + + # Remove UI highlights if disabled + if not params.highlight_errors: + for e in all_errors: + e.highlight = None + + # Build hints + hints: List[str] = [] + if params.feedback_verbosity != "minimal": + hints.extend(e.suggestion for e in all_errors if e.suggestion) + + if params.feedback_verbosity == "detailed" and structural_info: + if structural_info.unreachable_states: + unreachable = ", ".join(structural_info.unreachable_states) + hints.append( + f"Tip: States {{{unreachable}}} are unreachable from the start state" + ) + if structural_info.dead_states: + dead = ", ".join(structural_info.dead_states) + hints.append( + f"Tip: States {{{dead}}} can never reach an accepting state" + ) + else: + structural_info = None + hints = [] + + language = LanguageComparison( + are_equivalent=len(equivalence_errors) == 0 + ) + return FSAFeedback( summary=summary, errors=errors, @@ -90,25 +83,25 @@ def _build_feedback( def _summarize_errors(errors: List[ValidationError]) -> str: - """Generate summary from error messages.""" - error_types = set() + """Generate a human-readable summary from error messages.""" + categories = set() + for error in errors: msg = error.message.lower() if "alphabet" in msg: - error_types.add("alphabet issue") - elif "states" in msg and ("many" in msg or "few" in msg or "needed" in msg): - error_types.add("incorrect number of states") - elif "accepting" in msg or "accept" in msg: - error_types.add("accepting states issue") - elif "transition" in msg or "reading" in msg: - error_types.add("transition issue") - - if len(error_types) == 1: - issue = list(error_types)[0] - return f"Almost there! Your FSA has an {issue}. Check the details below." - elif error_types: - return f"Your FSA doesn't quite match the expected language. Issues found: {', '.join(error_types)}" - return f"Your FSA doesn't accept the correct language. Found {len(errors)} issue(s) to fix." + categories.add("alphabet issue") + elif "accept" in msg: + categories.add("accepting states issue") + elif "transition" in msg: + categories.add("transition issue") + elif "state" in msg: + categories.add("state structure issue") + + if len(categories) == 1: + return f"Almost there! Your FSA has a {next(iter(categories))}." + elif categories: + return f"Your FSA has multiple issues: {', '.join(categories)}." + return "Your FSA does not match the expected language." # ============================================================================= @@ -118,75 +111,133 @@ def _summarize_errors(errors: List[ValidationError]) -> str: def analyze_fsa_correction( student_fsa: FSA, expected_fsa: FSA, - require_minimal: bool = False + params: Params ) -> Result: """ - Compare student FSA against expected FSA. - - Returns Result with: - - is_correct: True if FSAs accept same language - - feedback: Human-readable summary - - fsa_feedback: Structured feedback with ElementHighlight for UI - - Args: - student_fsa: The student's FSA - expected_fsa: The reference/expected FSA - require_minimal: Whether to require student FSA to be minimal + Compare student FSA against expected FSA using configurable parameters. """ + validation_errors: List[ValidationError] = [] equivalence_errors: List[ValidationError] = [] structural_info: Optional[StructuralInfo] = None - + + # ------------------------------------------------------------------------- # Step 1: Validate student FSA structure - student_errors = is_valid_fsa(student_fsa) - if student_errors: - num_errors = len(student_errors) - if num_errors == 1: - summary = "Your FSA has a structural problem that needs to be fixed first. See the details below." - else: - summary = f"Your FSA has {num_errors} structural problems that need to be fixed first. See the details below." + # ------------------------------------------------------------------------- + student_result = is_valid_fsa(student_fsa) + if not student_result.ok(): + summary = ( + "Your FSA has a structural problem that needs to be fixed first." + if len(student_result.errors) == 1 + else f"Your FSA has {len(student_result.errors)} structural problems to fix." + ) return Result( is_correct=False, feedback=summary, - fsa_feedback=_build_feedback(summary, student_errors, [], None) + fsa_feedback=_build_feedback( + summary, + student_result.errors, + [], + None, + params + ) ) - - # Step 2: Validate expected FSA (should not fail) - expected_errors = is_valid_fsa(expected_fsa) - if expected_errors: + + # ------------------------------------------------------------------------- + # Step 2: Validate expected FSA (should never fail) + # ------------------------------------------------------------------------- + expected_result = is_valid_fsa(expected_fsa) + if not expected_result.ok(): return Result( is_correct=False, feedback="Oops! There's an issue with the expected answer. Please contact your instructor." ) - - # Step 3: Check minimality if required - if require_minimal: - is_min, min_error = _check_minimality(student_fsa) - if not is_min and min_error: - validation_errors.append(min_error) - - # Step 4: Structural analysis + + # ------------------------------------------------------------------------- + # Step 3: Enforce expected automaton type + # ------------------------------------------------------------------------- + if params.expected_type == "DFA": + det_result = is_deterministic(student_fsa) + if not det_result.ok(): + summary = "Your automaton must be deterministic (a DFA)." + return Result( + is_correct=False, + feedback=summary, + fsa_feedback=_build_feedback( + summary, + det_result.errors, + [], + None, + params + ) + ) + + # ------------------------------------------------------------------------- + # Step 4: Optional completeness check + # ------------------------------------------------------------------------- + if params.check_completeness: + comp_result = is_complete(student_fsa) + if not comp_result.ok(): + validation_errors.extend(comp_result.errors) + + # ------------------------------------------------------------------------- + # Step 5: Optional minimality check + # ------------------------------------------------------------------------- + if params.check_minimality: + min_errors = is_minimal(student_fsa) + validation_errors.extend(min_errors) + + # ------------------------------------------------------------------------- + # Step 6: Structural analysis (for feedback only) + # ------------------------------------------------------------------------- structural_info = get_structured_info_of_fsa(student_fsa) - - # Step 5: Language equivalence (with detailed feedback from are_isomorphic) - equivalence_errors = fsas_accept_same_language(student_fsa, expected_fsa) - - if not equivalence_errors and not validation_errors: - # Success message with some stats - state_count = len(student_fsa.states) - feedback = f"Correct! Your FSA with {state_count} state(s) accepts exactly the right language. Well done!" - return Result( - is_correct=True, - feedback=feedback, - fsa_feedback=_build_feedback("Your FSA is correct!", [], [], structural_info) + + # ------------------------------------------------------------------------- + # Step 7: Language equivalence + # ------------------------------------------------------------------------- + equivalence_result = fsas_accept_same_language( + student_fsa, expected_fsa + ) + equivalence_errors = equivalence_result.errors + + # ------------------------------------------------------------------------- + # Step 8: Decide correctness based on evaluation mode + # ------------------------------------------------------------------------- + if params.evaluation_mode == "strict": + is_correct = not validation_errors and equivalence_result.ok() + elif params.evaluation_mode == "lenient": + is_correct = equivalence_result.ok() + else: # partial # I dont know what the partial is meant for, always mark as incorrect? + is_correct = False + + # ------------------------------------------------------------------------- + # Step 9: Build summary + # ------------------------------------------------------------------------- + if is_correct: + feedback = ( + f"Correct! Your FSA with {len(student_fsa.states)} state(s) " + "accepts exactly the right language. Well done!" ) - - # Build result with errors - is_correct = len(equivalence_errors) == 0 and len(validation_errors) == 0 - summary = _summarize_errors(equivalence_errors) if equivalence_errors else "Your FSA has some issues to address." - + summary = "Your FSA is correct!" + else: + summary = ( + _summarize_errors(equivalence_errors) + if equivalence_errors + else "Your FSA has some issues to address." + ) + feedback = summary + + # ------------------------------------------------------------------------- + # Step 10: Return result + # ------------------------------------------------------------------------- return Result( is_correct=is_correct, - feedback=summary, - fsa_feedback=_build_feedback(summary, validation_errors, equivalence_errors, structural_info) + feedback=feedback, + fsa_feedback=_build_feedback( + summary, + validation_errors, + equivalence_errors, + structural_info, + params + ) ) diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py index 254b9e2..77b1ca2 100755 --- a/evaluation_function/evaluation.py +++ b/evaluation_function/evaluation.py @@ -1,15 +1,17 @@ -from typing import Any +from typing import Any, Tuple from lf_toolkit.evaluation import Result as LFResult + +from evaluation_function.schemas.params import Params from .schemas import FSA, FSAFrontend from .schemas.result import Result from .correction import analyze_fsa_correction import json -def validate_fsa(value: str | dict) -> FSA: +def validate_fsa(value: str | dict) -> Tuple[FSA, Params]: """Parse a FSA from JSON string or dict.""" if isinstance(value, str): return FSAFrontend.model_validate_json(value).toFSA() - return FSAFrontend.model_validate(value).toFSA(), FSAFrontend.model_validate(value).config + return FSAFrontend.model_validate(value).toFSA(), json.loads(FSAFrontend.model_validate(value).config) def evaluation_function( response: Any = None, @@ -34,21 +36,11 @@ def evaluation_function( f"response: {response}\nanswer: {answer}" ) # Parse FSAs - student_fsa, student_config = validate_fsa(response) + student_fsa, _ = validate_fsa(response) expected_fsa, expected_config = validate_fsa(answer) - require_minimal = params.get("require_minimal", False) if isinstance(params, dict) else False - # Run correction pipeline - result: Result = analyze_fsa_correction(student_fsa, expected_fsa, require_minimal) - return LFResult( - is_correct=False, - feedback_items=[( - "error", - f"Invalid FSA format: {str(e)}\n\n" - f"response: {response}\nanswer: {answer}\nparams: {params}\n\nstudent config:{student_config}\n\nexpected config:{expected_config}" - )] - ) + result: Result = analyze_fsa_correction(student_fsa, expected_fsa, expected_config) # Return LFResult return LFResult( diff --git a/evaluation_function/schemas/__init__.py b/evaluation_function/schemas/__init__.py index faf5f14..a4c505b 100644 --- a/evaluation_function/schemas/__init__.py +++ b/evaluation_function/schemas/__init__.py @@ -7,7 +7,7 @@ from .fsa import FSA, Transition from .params import Params -from .result import Result, ValidationError, ElementHighlight, FSAFeedback, ErrorCode, StructuralInfo +from .result import Result, ValidationError, ElementHighlight, FSAFeedback, ErrorCode, StructuralInfo, ValidationResult from .fsaFrontend import FSAFrontend __all__ = [ @@ -18,6 +18,7 @@ "Params", # Result "Result", + "ValidationResult" "ValidationError", "ElementHighlight", "ErrorCode", diff --git a/evaluation_function/schemas/fsaFrontend.py b/evaluation_function/schemas/fsaFrontend.py index 63aac77..f4f1064 100644 --- a/evaluation_function/schemas/fsaFrontend.py +++ b/evaluation_function/schemas/fsaFrontend.py @@ -54,19 +54,20 @@ class FSAFrontend(BaseModel): description="F: Set of accepting/final states" ) config: str | None = Field(default=None) - class Config: - schema_extra = { - "example": { - "states": ["q0", "q1", "q2"], - "alphabet": ["a", "b"], - "transitions": [ - "q0|a|q1|", - "q1|b|q2", - ], - "initial_state": "q0", - "accept_states": ["q2"] - } - } + + # class Config: + # schema_extra = { + # "example": { + # "states": ["q0", "q1", "q2"], + # "alphabet": ["a", "b"], + # "transitions": [ + # "q0|a|q1|", + # "q1|b|q2", + # ], + # "initial_state": "q0", + # "accept_states": ["q2"] + # } + # } def toFSA(self) -> FSA: transitions: List[Transition] = [] diff --git a/evaluation_function/schemas/result.py b/evaluation_function/schemas/result.py index 66da865..183c6a1 100644 --- a/evaluation_function/schemas/result.py +++ b/evaluation_function/schemas/result.py @@ -4,9 +4,10 @@ Extended result schema with structured feedback for UI highlighting. """ -from typing import List, Optional, Literal +from typing import List, Optional, Literal, TypeVar, Generic from enum import Enum from pydantic import BaseModel, Field +from pydantic.generics import GenericModel from evaluation_function.schemas.fsa import FSA @@ -135,6 +136,24 @@ class ValidationError(BaseModel): description="Actionable suggestion for fixing the error" ) +T = TypeVar("T") + + +class ValidationResult(GenericModel, Generic[T]): + value: Optional[T] = None + errors: List[ValidationError] = [] + + @property + def ok(self) -> bool: + return not self.errors + + @classmethod + def success(cls, value: T) -> "ValidationResult[T]": + return cls(value=value, errors=[]) + + @classmethod + def failure(cls, value: T, errors: List[ValidationError]) -> "ValidationResult[T]": + return cls(value=value, errors=errors) class TestResult(BaseModel): """ diff --git a/evaluation_function/test/test_correction.py b/evaluation_function/test/test_correction.py index 109ce53..f3fefa5 100644 --- a/evaluation_function/test/test_correction.py +++ b/evaluation_function/test/test_correction.py @@ -8,7 +8,7 @@ from evaluation_function.schemas import ValidationError, ErrorCode from evaluation_function.schemas.utils import make_fsa from evaluation_function.schemas.result import Result, FSAFeedback -from evaluation_function.correction import analyze_fsa_correction, check_minimality +from evaluation_function.correction import analyze_fsa_correction # ============================================================================= @@ -138,35 +138,6 @@ def test_invalid_accept_state(self): assert result.is_correct is False -# ============================================================================= -# Test Minimality -# ============================================================================= - -class TestCheckMinimality: - """Test check_minimality function.""" - - def test_minimal_dfa(self, dfa_accepts_a): - assert check_minimality(dfa_accepts_a) is True - - def test_non_minimal_dfa_with_unreachable(self): - non_minimal = make_fsa( - states=["q0", "q1", "q2", "unreachable"], - alphabet=["a", "b"], - transitions=[ - {"from_state": "q0", "to_state": "q1", "symbol": "a"}, - {"from_state": "q0", "to_state": "q2", "symbol": "b"}, - {"from_state": "q1", "to_state": "q2", "symbol": "a"}, - {"from_state": "q1", "to_state": "q2", "symbol": "b"}, - {"from_state": "q2", "to_state": "q2", "symbol": "a"}, - {"from_state": "q2", "to_state": "q2", "symbol": "b"}, - {"from_state": "unreachable", "to_state": "unreachable", "symbol": "a"}, - ], - initial="q0", - accept=["q1"] - ) - assert check_minimality(non_minimal) is False - - class TestAnalyzeFsaCorrectionMinimality: """Test analyze_fsa_correction with minimality checking.""" diff --git a/evaluation_function/test/test_validation.py b/evaluation_function/test/test_validation.py index a30b1eb..e843810 100644 --- a/evaluation_function/test/test_validation.py +++ b/evaluation_function/test/test_validation.py @@ -9,6 +9,7 @@ from evaluation_function.validation.validation import * from evaluation_function.schemas.utils import make_fsa +from evaluation_function.schemas import ErrorCode class TestFSAValidation: @@ -22,7 +23,7 @@ def test_valid_fsa_basic(self): initial="q0", accept=["q1"], ) - assert is_valid_fsa(fsa) == [] + assert is_valid_fsa(fsa).ok() def test_invalid_initial_state(self): fsa = make_fsa( @@ -32,9 +33,9 @@ def test_invalid_initial_state(self): initial="q0", accept=[], ) - errors = is_valid_fsa(fsa) - assert len(errors) > 0 - assert ErrorCode.INVALID_INITIAL in [e.code for e in errors] + result = is_valid_fsa(fsa) + assert not result.ok() + assert ErrorCode.INVALID_INITIAL in [e.code for e in result.errors] def test_invalid_accept_state(self): fsa = make_fsa( @@ -44,9 +45,9 @@ def test_invalid_accept_state(self): initial="q0", accept=["q1"], ) - errors = is_valid_fsa(fsa) - assert len(errors) > 0 - assert ErrorCode.INVALID_ACCEPT in [e.code for e in errors] + result = is_valid_fsa(fsa) + assert not result.ok() + assert ErrorCode.INVALID_ACCEPT in [e.code for e in result.errors] def test_invalid_transition_source(self): fsa = make_fsa( @@ -56,8 +57,8 @@ def test_invalid_transition_source(self): initial="q1", accept=[], ) - errors = is_valid_fsa(fsa) - assert ErrorCode.INVALID_TRANSITION_SOURCE in [e.code for e in errors] + result = is_valid_fsa(fsa) + assert ErrorCode.INVALID_TRANSITION_SOURCE in [e.code for e in result.errors] def test_invalid_transition_destination(self): fsa = make_fsa( @@ -67,8 +68,8 @@ def test_invalid_transition_destination(self): initial="q0", accept=[], ) - errors = is_valid_fsa(fsa) - assert ErrorCode.INVALID_TRANSITION_DEST in [e.code for e in errors] + result = is_valid_fsa(fsa) + assert ErrorCode.INVALID_TRANSITION_DEST in [e.code for e in result.errors] def test_invalid_transition_symbol(self): fsa = make_fsa( @@ -78,8 +79,8 @@ def test_invalid_transition_symbol(self): initial="q0", accept=["q1"], ) - errors = is_valid_fsa(fsa) - assert ErrorCode.INVALID_SYMBOL in [e.code for e in errors] + result = is_valid_fsa(fsa) + assert ErrorCode.INVALID_SYMBOL in [e.code for e in result.errors] class TestDeterminism: @@ -98,7 +99,7 @@ def test_deterministic_fsa(self): initial="q0", accept=["q1"], ) - assert is_deterministic(fsa) == [] + assert is_deterministic(fsa).ok() def test_nondeterministic_fsa(self): fsa = make_fsa( @@ -111,8 +112,9 @@ def test_nondeterministic_fsa(self): initial="q0", accept=["q2"], ) - errors = is_deterministic(fsa) - assert ErrorCode.DUPLICATE_TRANSITION in [e.code for e in errors] + result = is_deterministic(fsa) + assert not result.ok() + assert ErrorCode.DUPLICATE_TRANSITION in [e.code for e in result.errors] class TestCompleteness: @@ -131,7 +133,7 @@ def test_complete_dfa(self): initial="q0", accept=["q1"], ) - assert is_complete(fsa) == [] + assert is_complete(fsa).ok() def test_incomplete_dfa(self): fsa = make_fsa( @@ -141,9 +143,9 @@ def test_incomplete_dfa(self): initial="q0", accept=["q1"], ) - errors = is_complete(fsa) - assert len(errors) == 3 - assert ErrorCode.MISSING_TRANSITION in [e.code for e in errors] + result = is_complete(fsa) + assert not result.ok() + assert ErrorCode.MISSING_TRANSITION in [e.code for e in result.errors] def test_complete_requires_deterministic(self): fsa = make_fsa( @@ -156,8 +158,8 @@ def test_complete_requires_deterministic(self): initial="q0", accept=["q1"], ) - errors = is_complete(fsa) - codes = [e.code for e in errors] + result = is_complete(fsa) + codes = [e.code for e in result.errors] assert ErrorCode.NOT_DETERMINISTIC in codes assert ErrorCode.DUPLICATE_TRANSITION in codes @@ -173,9 +175,9 @@ def test_find_unreachable_states(self): initial="q0", accept=["q1"], ) - errors = find_unreachable_states(fsa) - assert ErrorCode.UNREACHABLE_STATE in [e.code for e in errors] - assert any("q2" in e.message for e in errors) + result = find_unreachable_states(fsa) + assert not result.ok() + assert ErrorCode.UNREACHABLE_STATE in [e.code for e in result.errors] def test_find_dead_states(self): fsa = make_fsa( @@ -190,9 +192,9 @@ def test_find_dead_states(self): initial="q0", accept=["q1"], ) - errors = find_dead_states(fsa) - assert ErrorCode.DEAD_STATE in [e.code for e in errors] - assert any("q2" in e.message for e in errors) + result = find_dead_states(fsa) + assert not result.ok() + assert ErrorCode.DEAD_STATE in [e.code for e in result.errors] def test_dead_states_no_accept_states(self): fsa = make_fsa( @@ -205,8 +207,8 @@ def test_dead_states_no_accept_states(self): initial="q0", accept=[], ) - errors = find_dead_states(fsa) - assert len(errors) == 2 + result = find_dead_states(fsa) + assert len(result.errors) == 2 class TestStringAcceptance: @@ -220,7 +222,7 @@ def test_accepts_string(self): initial="q0", accept=["q1"], ) - assert accepts_string(fsa, "a") == [] + assert accepts_string(fsa, "a").ok() def test_rejected_no_transition(self): fsa = make_fsa( @@ -230,8 +232,8 @@ def test_rejected_no_transition(self): initial="q0", accept=["q1"], ) - errors = accepts_string(fsa, "aa") - assert ErrorCode.TEST_CASE_FAILED in [e.code for e in errors] + result = accepts_string(fsa, "aa") + assert ErrorCode.TEST_CASE_FAILED in [e.code for e in result.errors] def test_empty_string(self): fsa = make_fsa( @@ -241,7 +243,7 @@ def test_empty_string(self): initial="q0", accept=["q0"], ) - assert accepts_string(fsa, "") == [] + assert accepts_string(fsa, "").ok() class TestLanguageEquivalence: @@ -313,6 +315,52 @@ def test_isomorphic_dfas(self): ) assert are_isomorphic(fsa_user, fsa_sol) == [] +# ============================================================================= +# Test Minimality +# ============================================================================= + +@pytest.fixture +def dfa_accepts_a(): + """DFA that accepts exactly 'a'.""" + return make_fsa( + states=["q0", "q1", "q2"], + alphabet=["a", "b"], + transitions=[ + {"from_state": "q0", "to_state": "q1", "symbol": "a"}, + {"from_state": "q0", "to_state": "q2", "symbol": "b"}, + {"from_state": "q1", "to_state": "q2", "symbol": "a"}, + {"from_state": "q1", "to_state": "q2", "symbol": "b"}, + {"from_state": "q2", "to_state": "q2", "symbol": "a"}, + {"from_state": "q2", "to_state": "q2", "symbol": "b"}, + ], + initial="q0", + accept=["q1"] + ) + +class TestCheckMinimality: + """Test check_minimality function.""" + + def test_minimal_dfa(self, dfa_accepts_a): + assert is_minimal(dfa_accepts_a) is True + + def test_non_minimal_dfa_with_unreachable(self): + non_minimal = make_fsa( + states=["q0", "q1", "q2", "unreachable"], + alphabet=["a", "b"], + transitions=[ + {"from_state": "q0", "to_state": "q1", "symbol": "a"}, + {"from_state": "q0", "to_state": "q2", "symbol": "b"}, + {"from_state": "q1", "to_state": "q2", "symbol": "a"}, + {"from_state": "q1", "to_state": "q2", "symbol": "b"}, + {"from_state": "q2", "to_state": "q2", "symbol": "a"}, + {"from_state": "q2", "to_state": "q2", "symbol": "b"}, + {"from_state": "unreachable", "to_state": "unreachable", "symbol": "a"}, + ], + initial="q0", + accept=["q1"] + ) + assert is_minimal(non_minimal) is False + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/evaluation_function/validation/validation.py b/evaluation_function/validation/validation.py index bd8b6d8..e6a8b20 100644 --- a/evaluation_function/validation/validation.py +++ b/evaluation_function/validation/validation.py @@ -1,81 +1,73 @@ -from typing import Dict, List, Set +from typing import Dict, List, Optional, Set from collections import deque from evaluation_function.schemas.result import StructuralInfo from ..algorithms.minimization import hopcroft_minimization +from ..schemas import FSA, ValidationError, ErrorCode, ElementHighlight, ValidationResult -from ..schemas import FSA, ValidationError, ErrorCode, ElementHighlight +# ============================================================================= +# Structural validation +# ============================================================================= -def is_valid_fsa(fsa: FSA) -> List[ValidationError]: - """ - Structural validation: initial state, accept states, transitions, and symbols. - Does NOT check determinism or completeness. - """ - errors = [] +def is_valid_fsa(fsa: FSA) -> ValidationResult[bool]: + errors: List[ValidationError] = [] states = set(fsa.states) alphabet = set(fsa.alphabet) - # Check if states set is empty if not states: errors.append( ValidationError( - message="Your FSA needs at least one state to work. Every automaton must have states to process input!", + message="Your FSA needs at least one state to work.", code=ErrorCode.EMPTY_STATES, severity="error", - suggestion="Start by adding a state - this will be your starting point for the automaton" + suggestion="Start by adding a state." ) ) - return errors # Early return since other checks depend on states + return ValidationResult.failure(False, errors) - # Check if alphabet is empty if not alphabet: errors.append( ValidationError( - message="Your FSA needs an alphabet - the set of symbols it can read. Without an alphabet, there's nothing to process!", + message="Your FSA needs an alphabet.", code=ErrorCode.EMPTY_ALPHABET, severity="error", - suggestion="Define the input symbols your FSA should recognize (e.g., 'a', 'b', '0', '1')" + suggestion="Define at least one input symbol." ) ) - # Initial state if fsa.initial_state not in states: errors.append( ValidationError( - message=f"Oops! Your initial state '{fsa.initial_state}' doesn't exist in your FSA. The initial state must be one of your defined states.", + message=f"Initial state '{fsa.initial_state}' does not exist.", code=ErrorCode.INVALID_INITIAL, severity="error", highlight=ElementHighlight( type="initial_state", state_id=fsa.initial_state - ), - suggestion=f"Either add '{fsa.initial_state}' to your states, or choose an existing state as the initial state" + ) ) ) - # Accept states for acc in set(fsa.accept_states): if acc not in states: errors.append( ValidationError( - message=f"The accepting state '{acc}' isn't in your FSA. Accepting states must be part of your state set.", + message=f"Accepting state '{acc}' does not exist.", code=ErrorCode.INVALID_ACCEPT, severity="error", highlight=ElementHighlight( type="accept_state", state_id=acc - ), - suggestion=f"Either add '{acc}' to your states, or remove it from accepting states" + ) ) ) - # Transitions for t in fsa.transitions: if t.from_state not in states: errors.append( ValidationError( - message=f"This transition starts from '{t.from_state}', but that state doesn't exist in your FSA.", + message=f"Transition source '{t.from_state}' does not exist.", code=ErrorCode.INVALID_TRANSITION_SOURCE, severity="error", highlight=ElementHighlight( @@ -83,14 +75,13 @@ def is_valid_fsa(fsa: FSA) -> List[ValidationError]: from_state=t.from_state, to_state=t.to_state, symbol=t.symbol - ), - suggestion=f"Add '{t.from_state}' to your states, or update this transition to start from an existing state" + ) ) ) if t.to_state not in states: errors.append( ValidationError( - message=f"This transition goes to '{t.to_state}', but that state doesn't exist in your FSA.", + message=f"Transition destination '{t.to_state}' does not exist.", code=ErrorCode.INVALID_TRANSITION_DEST, severity="error", highlight=ElementHighlight( @@ -98,14 +89,13 @@ def is_valid_fsa(fsa: FSA) -> List[ValidationError]: from_state=t.from_state, to_state=t.to_state, symbol=t.symbol - ), - suggestion=f"Add '{t.to_state}' to your states, or update this transition to go to an existing state" + ) ) ) if t.symbol not in alphabet: errors.append( ValidationError( - message=f"The symbol '{t.symbol}' in this transition isn't in your alphabet. Transitions can only use symbols from the alphabet.", + message=f"Symbol '{t.symbol}' not in alphabet.", code=ErrorCode.INVALID_SYMBOL, severity="error", highlight=ElementHighlight( @@ -113,33 +103,35 @@ def is_valid_fsa(fsa: FSA) -> List[ValidationError]: from_state=t.from_state, to_state=t.to_state, symbol=t.symbol - ), - suggestion=f"Either add '{t.symbol}' to your alphabet, or change this transition to use an existing symbol" + ) ) ) - return errors + return ( + ValidationResult.success(True) + if not errors + else ValidationResult.failure(False, errors) + ) + + +# ============================================================================= +# Determinism & completeness +# ============================================================================= +def is_deterministic(fsa: FSA) -> ValidationResult[bool]: + structural = is_valid_fsa(fsa) + if not structural.ok: + return structural -def is_deterministic(fsa: FSA) -> List[ValidationError]: - """ - Checks for multiple transitions from the same state on the same symbol. - Returns a list of ValidationErrors if nondeterministic. - """ - errors = [] + errors: List[ValidationError] = [] seen = set() - - # First check if FSA is structurally valid - structural_errors = is_valid_fsa(fsa) - if structural_errors: - return structural_errors - + for t in fsa.transitions: key = (t.from_state, t.symbol) if key in seen: errors.append( ValidationError( - message=f"Your FSA has multiple transitions from state '{t.from_state}' when reading '{t.symbol}'. In a DFA, each state can only have one transition per symbol.", + message=f"Multiple transitions from '{t.from_state}' on '{t.symbol}'.", code=ErrorCode.DUPLICATE_TRANSITION, severity="error", highlight=ElementHighlight( @@ -147,34 +139,33 @@ def is_deterministic(fsa: FSA) -> List[ValidationError]: from_state=t.from_state, to_state=t.to_state, symbol=t.symbol - ), - suggestion=f"Keep only one transition from '{t.from_state}' on '{t.symbol}', or if you meant to create an NFA, that's also valid!" + ) ) ) seen.add(key) - - return errors - - -def is_complete(fsa: FSA) -> List[ValidationError]: - """ - Checks if every state has a transition for every symbol (requires deterministic FSA). - """ - errors = [] - - # First check if FSA is deterministic - det_errors = is_deterministic(fsa) - if det_errors: - errors.extend(det_errors) - errors.append( - ValidationError( - message="We can only check completeness for deterministic FSAs. Please fix the determinism issues first.", - code=ErrorCode.NOT_DETERMINISTIC, - severity="error" - ) + + return ( + ValidationResult.success(True) + if not errors + else ValidationResult.failure(False, errors) + ) + + +def is_complete(fsa: FSA) -> ValidationResult[bool]: + det = is_deterministic(fsa) + if not det.ok: + return ValidationResult.failure( + False, + det.errors + [ + ValidationError( + message="Completeness requires determinism.", + code=ErrorCode.NOT_DETERMINISTIC, + severity="error" + ) + ], ) - return errors - + + errors: List[ValidationError] = [] states = set(fsa.states) alphabet = set(fsa.alphabet) transition_keys = {(t.from_state, t.symbol) for t in fsa.transitions} @@ -184,94 +175,83 @@ def is_complete(fsa: FSA) -> List[ValidationError]: if (state, symbol) not in transition_keys: errors.append( ValidationError( - message=f"State '{state}' is missing a transition for symbol '{symbol}'. A complete DFA needs transitions for every symbol from every state.", + message=f"Missing transition from '{state}' on '{symbol}'.", code=ErrorCode.MISSING_TRANSITION, severity="error", highlight=ElementHighlight( type="state", state_id=state, symbol=symbol - ), - suggestion=f"Add a transition from '{state}' when reading '{symbol}' - it can go to any state, including a 'trap' state" + ) ) ) - return errors + + return ( + ValidationResult.success(True) + if not errors + else ValidationResult.failure(False, errors) + ) -def find_unreachable_states(fsa: FSA) -> List[ValidationError]: - """ - Returns ValidationErrors for all unreachable states. - """ - errors = [] - - # Check if initial state is valid +# ============================================================================= +# Reachability & dead states +# ============================================================================= + +def find_unreachable_states(fsa: FSA) -> ValidationResult[List[str]]: if fsa.initial_state not in set(fsa.states): - # This error should already be caught by is_valid_fsa - return [] - + return ValidationResult.success([]) + visited = set() queue = deque([fsa.initial_state]) - transitions = fsa.transitions while queue: state = queue.popleft() if state in visited: continue visited.add(state) - for t in transitions: - if t.from_state == state and t.to_state not in visited: + for t in fsa.transitions: + if t.from_state == state: queue.append(t.to_state) - for state in fsa.states: - if state not in visited: - errors.append( - ValidationError( - message=f"State '{state}' can never be reached! There's no path from your initial state to this state.", - code=ErrorCode.UNREACHABLE_STATE, - severity="warning", - highlight=ElementHighlight( - type="state", - state_id=state - ), - suggestion=f"Connect '{state}' to your FSA by adding a transition to it, or remove it if it's not needed" - ) - ) - return errors + unreachable = [s for s in fsa.states if s not in visited] + + errors = [ + ValidationError( + message=f"State '{s}' is unreachable.", + code=ErrorCode.UNREACHABLE_STATE, + severity="warning", + highlight=ElementHighlight(type="state", state_id=s) + ) + for s in unreachable + ] + + return ( + ValidationResult.success(unreachable) + if not errors + else ValidationResult.failure(unreachable, errors) + ) -def find_dead_states(fsa: FSA) -> List[ValidationError]: - """ - Returns ValidationErrors for all dead states (cannot reach an accepting state). - """ - errors = [] - - # Check if there are any accept states +def find_dead_states(fsa: FSA) -> ValidationResult[List[str]]: if not fsa.accept_states: - # All non-accepting states are dead if there are no accept states - for state in fsa.states: - if state != fsa.initial_state or state not in fsa.accept_states: - errors.append( - ValidationError( - message=f"Your FSA has no accepting states, so no input string can ever be accepted! This means the language is empty.", - code=ErrorCode.DEAD_STATE, - severity="warning", - highlight=ElementHighlight( - type="state", - state_id=state - ), - suggestion="If you want your FSA to accept some strings, mark at least one state as accepting" - ) - ) - return errors - - transitions = fsa.transitions + dead = list(fsa.states) + errors = [ + ValidationError( + message="No accepting states; language is empty.", + code=ErrorCode.DEAD_STATE, + severity="warning", + highlight=ElementHighlight(type="state", state_id=s) + ) + for s in dead + ] + return ValidationResult.failure(dead, errors) + reachable_to_accept = set(fsa.accept_states) queue = deque(fsa.accept_states) predecessors = {s: [] for s in fsa.states} - for t in transitions: - if t.from_state in fsa.states and t.to_state in fsa.states: - predecessors[t.to_state].append(t.from_state) + for t in fsa.transitions: + predecessors[t.to_state].append(t.from_state) while queue: state = queue.popleft() @@ -280,309 +260,160 @@ def find_dead_states(fsa: FSA) -> List[ValidationError]: reachable_to_accept.add(pred) queue.append(pred) - for state in fsa.states: - if state not in reachable_to_accept: - errors.append( - ValidationError( - message=f"State '{state}' is a dead end - once you enter it, you can never reach an accepting state. This is often called a 'trap state'.", - code=ErrorCode.DEAD_STATE, - severity="warning", - highlight=ElementHighlight( - type="state", - state_id=state - ), - suggestion=f"This might be intentional (to reject certain inputs), or you could add a path from '{state}' to an accepting state" - ) - ) - return errors - - -def accepts_string(fsa: FSA, string: str) -> List[ValidationError]: - """ - Simulate the FSA on a string. - 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 - + dead = [s for s in fsa.states if s not in reachable_to_accept] + errors = [ + ValidationError( + message=f"State '{s}' is a dead state.", + code=ErrorCode.DEAD_STATE, + severity="warning", + highlight=ElementHighlight(type="state", state_id=s) + ) + for s in dead + ] + + return ( + ValidationResult.success(dead) + if not errors + else ValidationResult.failure(dead, errors) + ) + + +# ============================================================================= +# Simulation +# ============================================================================= + +def accepts_string(fsa: FSA, string: str) -> ValidationResult[bool]: + valid = is_valid_fsa(fsa) + if not valid.ok: + return valid + current_states: Set[str] = {fsa.initial_state} for symbol in string: - # Check if symbol is in alphabet - if symbol not in set(fsa.alphabet): - return [ + if symbol not in fsa.alphabet: + return ValidationResult.failure(False, [ ValidationError( - message=f"String '{string}' contains symbol '{symbol}' not in alphabet", + message=f"Symbol '{symbol}' not in alphabet.", code=ErrorCode.INVALID_SYMBOL, severity="error" ) - ] - - next_states = set() - for state in current_states: - for t in fsa.transitions: - if t.from_state == state and t.symbol == symbol: - next_states.add(t.to_state) - current_states = next_states - if not current_states: - return [ + ]) + + next_states = { + t.to_state + for s in current_states + for t in fsa.transitions + if t.from_state == s and t.symbol == symbol + } + + if not next_states: + return ValidationResult.failure(False, [ ValidationError( - message=f"String '{string}' rejected: no transition from state(s) {current_states} on symbol '{symbol}'", + message=f"String '{string}' rejected.", code=ErrorCode.TEST_CASE_FAILED, severity="error" ) - ] + ]) + + current_states = next_states - if any(state in fsa.accept_states for state in current_states): - return [] - else: - return [ + accepted = any(s in fsa.accept_states for s in current_states) + return ( + ValidationResult.success(True) + if accepted + else ValidationResult.failure(False, [ ValidationError( - message=f"String '{string}' rejected: reached non-accepting state(s) {current_states}", + message=f"String '{string}' rejected.", code=ErrorCode.TEST_CASE_FAILED, severity="error" ) - ] + ]) + ) + +# ============================================================================= +# Language equivalence & minimality +# ============================================================================= -def fsas_accept_same_string(fsa1: FSA, fsa2: FSA, string: str) -> List[ValidationError]: - """ - Returns empty list if both FSAs agree on string acceptance, else a ValidationError. - """ - errs1 = accepts_string(fsa1, string) - errs2 = accepts_string(fsa2, string) +def fsas_accept_same_string(fsa1: FSA, fsa2: FSA, string: str) -> ValidationResult[bool]: + r1 = accepts_string(fsa1, string) + r2 = accepts_string(fsa2, string) - # Both strings were accepted (no errors means accepted) - accepted1 = len(errs1) == 0 - accepted2 = len(errs2) == 0 - - if accepted1 != accepted2: - return [ + if r1.ok != r2.ok: + return ValidationResult.failure(False, [ ValidationError( - message=f"FSAs differ on string '{string}': FSA1 {'accepts' if accepted1 else 'rejects'}, FSA2 {'accepts' if accepted2 else 'rejects'}", + message=f"FSAs differ on string '{string}'.", code=ErrorCode.LANGUAGE_MISMATCH, severity="error" ) - ] - return [] - - -def fsas_accept_same_language(fsa1: FSA, fsa2: FSA) -> List[ValidationError]: - 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 get_structured_info_of_fsa(fsa: FSA) -> StructuralInfo: - """ - Get structured information about the FSA including properties and analysis. - """ - # Check determinism - returns boolean - det_errors = is_deterministic(fsa) - is_deterministic_bool = len(det_errors) == 0 - - # Check completeness - returns boolean - comp_errors = is_complete(fsa) - is_complete_bool = len(comp_errors) == 0 - - # Get dead states - extract state IDs from errors - dead_state_errors = find_dead_states(fsa) - dead_states_list = [] - for error in dead_state_errors: - if error.highlight and error.highlight.state_id: - dead_states_list.append(error.highlight.state_id) - - # Get unreachable states - extract state IDs from errors - unreachable_state_errors = find_unreachable_states(fsa) - unreachable_states_list = [] - for error in unreachable_state_errors: - if error.highlight and error.highlight.state_id: - unreachable_states_list.append(error.highlight.state_id) - - return StructuralInfo( - is_deterministic=is_deterministic_bool, - is_complete=is_complete_bool, - num_states=len(fsa.states), - num_transitions=len(fsa.transitions), - dead_states=dead_states_list, - unreachable_states=unreachable_states_list + +def fsas_accept_same_language(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: + return are_isomorphic( + hopcroft_minimization(fsa1), + hopcroft_minimization(fsa2), ) - -def are_isomorphic(fsa1: FSA, fsa2: FSA) -> List[ValidationError]: - """ - Checks if two DFAs are isomorphic. - Returns a list of ValidationErrors if they differ, otherwise an empty list. - Assumes DFAs are minimized and complete. - """ - errors = [] - # 1. Alphabet Check (Mandatory) + + +def are_isomorphic(fsa1: FSA, fsa2: FSA) -> ValidationResult[bool]: + errors: List[ValidationError] = [] + if set(fsa1.alphabet) != set(fsa2.alphabet): - student_only = set(fsa1.alphabet) - set(fsa2.alphabet) - expected_only = set(fsa2.alphabet) - set(fsa1.alphabet) - - msg_parts = ["Your alphabet doesn't match what's expected."] - if student_only: - msg_parts.append(f"You have extra symbols: {student_only}") - if expected_only: - msg_parts.append(f"You're missing symbols: {expected_only}") - errors.append( ValidationError( - message=" ".join(msg_parts), + message="Alphabets do not match.", code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - suggestion="Make sure your alphabet contains exactly the symbols needed for this language" + severity="error" ) ) - # 2. Basic Structural Check (State Count) if len(fsa1.states) != len(fsa2.states): - if len(fsa1.states) > len(fsa2.states): - errors.append( - ValidationError( - message=f"Your FSA has {len(fsa1.states)} states, but the minimal solution only needs {len(fsa2.states)}. You might have redundant states.", - code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - suggestion="Look for states that behave identically and could be merged, or check for unreachable states" - ) - ) - else: - errors.append( - ValidationError( - message=f"Your FSA has {len(fsa1.states)} states, but at least {len(fsa2.states)} are needed. You might be missing some states.", - code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - suggestion="Think about what different 'situations' your FSA needs to remember - each usually needs its own state" - ) + errors.append( + ValidationError( + message="State counts do not match.", + code=ErrorCode.LANGUAGE_MISMATCH, + severity="error" ) + ) - # 3. State Mapping Initialization - mapping: Dict[str, str] = {fsa1.initial_state: fsa2.initial_state} - queue = deque([fsa1.initial_state]) - visited = {fsa1.initial_state} + return ( + ValidationResult.success(True) + if not errors + else ValidationResult.failure(False, errors) + ) - # 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) - accept2 = set(fsa2.accept_states) - while queue: - s1 = queue.popleft() - s2 = mapping[s1] +def is_minimal(fsa: FSA) -> ValidationResult[bool]: + minimized = hopcroft_minimization(fsa) + if len(minimized.states) < len(fsa.states): + return ValidationResult.failure(False, [ + ValidationError( + message="FSA is not minimal.", + code=ErrorCode.NOT_MINIMAL, + severity="error" + ) + ]) + return ValidationResult.success(True) - # 4. Check Acceptance Parity - if (s1 in accept1) != (s2 in accept2): - if s2 in accept2: - errors.append( - ValidationError( - message=f"State '{s1}' should be an accepting state, but it's not marked as one. Strings that end here should be accepted!", - code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - highlight=ElementHighlight(type="state", state_id=s1), - suggestion=f"Mark state '{s1}' as an accepting state (add it to your accept states)" - ) - ) - else: - errors.append( - ValidationError( - message=f"State '{s1}' is marked as accepting, but it shouldn't be. Strings that end here should be rejected!", - code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - highlight=ElementHighlight(type="state", state_id=s1), - suggestion=f"Remove state '{s1}' from your accepting states" - ) - ) - # 5. Check Transitions for every symbol in the shared alphabet - 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): - if dest1 is None: - errors.append( - ValidationError( - message=f"State '{s1}' is missing a transition for symbol '{symbol}'. What should happen when you read '{symbol}' here?", - code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - highlight=ElementHighlight(type="state", state_id=s1, symbol=symbol), - suggestion=f"Add a transition from '{s1}' on '{symbol}' to handle this input" - ) - ) - else: - errors.append( - ValidationError( - message=f"State '{s1}' has an unexpected transition on '{symbol}'. This transition might not be needed.", - code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - highlight=ElementHighlight(type="state", state_id=s1, symbol=symbol), - suggestion=f"Review if the transition from '{s1}' on '{symbol}' is correct" - ) - ) - - 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( - message=f"When in state '{s1}' and reading '{symbol}', you go to '{dest1}', but that leads to incorrect behavior. Check where this transition should go!", - code=ErrorCode.LANGUAGE_MISMATCH, - severity="error", - highlight=ElementHighlight( - type="transition", - from_state=s1, - to_state=dest1, - symbol=symbol - ), - suggestion=f"Think about what state the FSA should be in after reading '{symbol}' from '{s1}' - try tracing through some example strings" - ) - ) +# ============================================================================= +# Structured info +# ============================================================================= + +def get_structured_info_of_fsa(fsa: FSA) -> StructuralInfo: + det = is_deterministic(fsa) + comp = is_complete(fsa) + dead = find_dead_states(fsa) + unreachable = find_unreachable_states(fsa) - return errors \ No newline at end of file + return StructuralInfo( + is_deterministic=det.ok, + is_complete=comp.ok, + num_states=len(fsa.states), + num_transitions=len(fsa.transitions), + dead_states=dead.value or [], + unreachable_states=unreachable.value or [], + )