Coverage for src/dataknobs_fsm/execution/engine.py: 41%
368 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
1"""Execution engine for FSM state machines."""
3import logging
4import time
5from enum import Enum
6from typing import Any, Callable, Dict, List, Tuple
8from dataknobs_fsm.core.arc import ArcDefinition, ArcExecution
9from dataknobs_fsm.core.exceptions import FunctionError
10from dataknobs_fsm.core.fsm import FSM
11from dataknobs_fsm.core.modes import ProcessingMode, TransactionMode
12from dataknobs_fsm.core.network import StateNetwork
13from dataknobs_fsm.core.state import StateType
14from dataknobs_fsm.execution.context import ExecutionContext
15from dataknobs_fsm.functions.base import FunctionContext
16from dataknobs_fsm.execution.common import (
17 NetworkSelector,
18 TransitionSelectionMode
19)
20from dataknobs_fsm.execution.base_engine import BaseExecutionEngine
21from dataknobs_fsm.core.data_wrapper import ensure_dict
23logger = logging.getLogger(__name__)
26class TraversalStrategy(Enum):
27 """Execution traversal strategy."""
28 DEPTH_FIRST = "depth_first"
29 BREADTH_FIRST = "breadth_first"
30 RESOURCE_OPTIMIZED = "resource_optimized"
31 STREAM_OPTIMIZED = "stream_optimized"
34class ExecutionEngine(BaseExecutionEngine):
35 """Execution engine for FSM state machines.
37 This engine handles:
38 - Mode-aware execution (single, batch, stream)
39 - Resource-aware scheduling
40 - Stream processing support
41 - State transitions with validation
42 - Branching and parallel paths
43 - Network push/pop operations
44 - Error handling with retry
45 - Execution hooks and callbacks
46 """
48 def __init__(
49 self,
50 fsm: FSM,
51 strategy: TraversalStrategy = TraversalStrategy.DEPTH_FIRST,
52 max_retries: int = 3,
53 retry_delay: float = 1.0,
54 enable_hooks: bool = True,
55 selection_mode: TransitionSelectionMode = TransitionSelectionMode.HYBRID
56 ):
57 """Initialize execution engine.
59 Args:
60 fsm: FSM instance to execute.
61 strategy: Traversal strategy to use.
62 max_retries: Maximum retry attempts for failures.
63 retry_delay: Delay between retries in seconds.
64 enable_hooks: Enable execution hooks.
65 selection_mode: Transition selection mode (strategy, scoring, or hybrid).
66 """
67 # Initialize base class
68 super().__init__(fsm, strategy, selection_mode, max_retries, retry_delay)
70 self.enable_hooks = enable_hooks
72 # Hooks (specific to sync engine)
73 self._pre_transition_hooks: List[Callable] = []
74 self._post_transition_hooks: List[Callable] = []
75 self._error_hooks: List[Callable] = []
77 def execute(
78 self,
79 context: ExecutionContext,
80 data: Any = None,
81 max_transitions: int = 1000,
82 arc_name: str | None = None
83 ) -> Tuple[bool, Any]:
84 """Execute the FSM with given context.
86 Args:
87 context: Execution context.
88 data: Input data to process.
89 max_transitions: Maximum transitions before stopping.
90 arc_name: Optional specific arc name to follow.
92 Returns:
93 Tuple of (success, result).
94 """
95 self._execution_count += 1
96 # Only override context.data if data was explicitly provided
97 if data is not None:
98 context.data = data
100 # Ensure context has resource_manager from FSM
101 if context.resource_manager is None and self.fsm.resource_manager is not None:
102 context.resource_manager = self.fsm.resource_manager
104 # Initialize state if needed
105 if not context.current_state:
106 initial_state = self._find_initial_state()
107 if not initial_state:
108 return False, "No initial state found"
110 # Use the common state entry method
111 if not self.enter_state(context, initial_state):
112 # Return specific error if available, otherwise generic message
113 error_msg = getattr(context, 'last_error', "Failed to enter initial state")
114 return False, error_msg
116 # Execute based on data mode
117 if context.data_mode == ProcessingMode.SINGLE:
118 return self._execute_single(context, max_transitions, arc_name)
119 elif context.data_mode == ProcessingMode.BATCH:
120 return self._execute_batch(context, max_transitions)
121 elif context.data_mode == ProcessingMode.STREAM:
122 return self._execute_stream(context, max_transitions)
123 else:
124 return False, f"Unknown data mode: {context.data_mode}"
126 def _execute_single(
127 self,
128 context: ExecutionContext,
129 max_transitions: int,
130 arc_name: str | None = None
131 ) -> Tuple[bool, Any]:
132 """Execute in single record mode.
134 Args:
135 context: Execution context.
136 max_transitions: Maximum transitions.
137 arc_name: Optional specific arc name to follow.
139 Returns:
140 Tuple of (success, result).
141 """
142 transitions = 0
143 last_state = None
144 last_data_hash = None
145 stuck_count = 0
146 max_stuck_iterations = 3 # Max times we can be in same state without data changes
148 while transitions < max_transitions:
149 # Check if in final state
150 if self._is_final_state(context.current_state):
151 return True, context.data
153 # Check for stuck state (infinite loop protection)
154 # Only consider it stuck if both state AND data haven't changed
155 from dataknobs_fsm.utils.json_encoder import dumps
156 current_data_hash = dumps(context.data, sort_keys=True) if context.data else ""
158 if context.current_state == last_state and current_data_hash == last_data_hash:
159 stuck_count += 1
160 if stuck_count >= max_stuck_iterations:
161 return False, f"Stuck in state '{context.current_state}' - possible infinite loop"
162 else:
163 stuck_count = 0
164 last_state = context.current_state
165 last_data_hash = current_data_hash
167 # Execute state functions (validators and transforms) before evaluating transitions
168 # This ensures that state functions can update the data that arc conditions depend on
169 self._execute_state_functions(context, context.current_state)
171 # Get available transitions
172 transitions_available = self._get_available_transitions(context, arc_name)
174 if not transitions_available:
175 # No valid transitions - check if this is a final state
176 if self._is_final_state(context.current_state):
177 return True, context.data
178 return False, f"No valid transitions from state: {context.current_state}"
180 # Choose transition based on strategy
181 next_transition = self._choose_transition(
182 transitions_available,
183 context
184 )
186 # Store current state before transition
187 state_before = context.current_state
189 # Execute transition
190 success = self._execute_transition(
191 context,
192 next_transition
193 )
195 if not success:
196 return False, f"Transition failed: {next_transition}"
198 # Only increment if we actually transitioned
199 if context.current_state != state_before:
200 transitions += 1
202 return False, f"Maximum transitions ({max_transitions}) exceeded"
204 def _execute_batch(
205 self,
206 context: ExecutionContext,
207 max_transitions: int
208 ) -> Tuple[bool, Any]:
209 """Execute in batch mode.
211 Args:
212 context: Execution context.
213 max_transitions: Maximum transitions per item.
215 Returns:
216 Tuple of (success, results).
217 """
218 if not context.batch_data:
219 return False, "No batch data to process"
221 total_success = True
223 for i, item in enumerate(context.batch_data):
224 # Create child context for this item
225 item_context = context.create_child_context(f"batch_{i}")
226 item_context.data = item
228 # Reset to initial state for each item
229 initial_state = self._find_initial_state()
230 if initial_state:
231 # Use the common state entry method
232 self.enter_state(item_context, initial_state, run_validators=False)
234 # Execute for this item
235 success, result = self._execute_single(
236 item_context,
237 max_transitions
238 )
240 if success:
241 context.add_batch_result(result)
242 else:
243 context.add_batch_error(i, Exception(result))
244 if context.transaction_mode == TransactionMode.PER_RECORD:
245 # Continue processing other items
246 pass
247 elif context.transaction_mode == TransactionMode.PER_BATCH:
248 # Fail entire batch
249 total_success = False
250 break
252 # Merge child context
253 context.merge_child_context(f"batch_{i}")
255 return total_success, {
256 'results': context.batch_results,
257 'errors': context.batch_errors
258 }
260 def _execute_stream(
261 self,
262 context: ExecutionContext,
263 max_transitions: int
264 ) -> Tuple[bool, Any]:
265 """Execute in stream mode.
267 Args:
268 context: Execution context.
269 max_transitions: Maximum transitions per chunk.
271 Returns:
272 Tuple of (success, stream_stats).
273 """
274 if not context.stream_context:
275 return False, "No stream context provided"
277 chunks_processed = 0
278 total_records = 0
279 errors = []
281 # Process each chunk
282 while True:
283 # Get next chunk from stream
284 chunk = context.stream_context.get_next_chunk()
285 if not chunk:
286 break
288 context.set_stream_chunk(chunk)
290 # Process chunk data
291 for record in chunk.data:
292 record_context = context.create_child_context(
293 f"stream_{chunks_processed}_{total_records}"
294 )
295 record_context.data = record
297 # Reset to initial state
298 initial_state = self._find_initial_state()
299 if initial_state:
300 # Use the common state entry method
301 self.enter_state(record_context, initial_state, run_validators=False)
303 # Execute for this record
304 success, result = self._execute_single(
305 record_context,
306 max_transitions
307 )
309 if not success:
310 errors.append((total_records, result))
312 # Merge context
313 context.merge_child_context(
314 f"stream_{chunks_processed}_{total_records}"
315 )
317 total_records += 1
319 chunks_processed += 1
321 # Check if this was the last chunk
322 if chunk.is_last:
323 break
325 return len(errors) == 0, {
326 'chunks_processed': chunks_processed,
327 'records_processed': total_records,
328 'errors': errors
329 }
331 def enter_state(
332 self,
333 context: ExecutionContext,
334 state_name: str,
335 run_validators: bool = True
336 ) -> bool:
337 """Public method to handle entering a state with all necessary setup.
339 This method handles the complete state entry process including:
340 - Setting the current state
341 - Allocating resources
342 - Running pre-validators (optional)
343 - Executing transforms
344 - Setting up state tracking
346 Args:
347 context: Execution context.
348 state_name: Name of the state to enter.
349 run_validators: Whether to run pre-validators (default True).
351 Returns:
352 True if state entry was successful, False otherwise.
353 """
354 # Set the current state
355 context.set_state(state_name)
357 # Allocate state resources
358 state_resources = self._allocate_state_resources(context, state_name)
360 # Store in context for cleanup tracking
361 context.current_state_resources = state_resources
363 # Execute pre-validators if requested
364 if run_validators:
365 if not self._execute_pre_validators(context, state_name, state_resources):
366 # Clean up resources if validation fails
367 self._release_state_resources(context, state_name, state_resources)
368 # Store error information in context for better error reporting
369 if not hasattr(context, 'last_error'):
370 context.last_error = f"Pre-validation failed for state '{state_name}'"
371 return False
373 # Execute state transforms
374 self._execute_state_transforms(context, state_name, state_resources)
376 return True
378 def exit_state(
379 self,
380 context: ExecutionContext,
381 state_name: str
382 ) -> None:
383 """Public method to handle exiting a state with cleanup.
385 This method handles:
386 - Releasing state resources
387 - Any other cleanup needed when leaving a state
389 Args:
390 context: Execution context.
391 state_name: Name of the state being exited.
392 """
393 if hasattr(context, 'current_state_resources') and context.current_state_resources:
394 self._release_state_resources(context, state_name, context.current_state_resources)
395 context.current_state_resources = {}
397 def _execute_transition(
398 self,
399 context: ExecutionContext,
400 arc: ArcDefinition
401 ) -> bool:
402 """Execute a single transition.
404 Args:
405 context: Execution context.
406 arc: Arc to execute.
408 Returns:
409 True if successful.
410 """
411 # Fire pre-transition hooks
412 if self.enable_hooks:
413 for hook in self._pre_transition_hooks:
414 hook(context, arc)
416 retry_count = 0
417 while retry_count <= self.max_retries:
418 try:
419 # Validate data before processing
420 if context.data is None:
421 # Skip processing for None data
422 return False
424 # Create arc execution (pass current state as source)
425 arc_exec = ArcExecution(
426 arc,
427 source_state=context.current_state or "",
428 function_registry=self.fsm.function_registry
429 )
431 # Execute with resource context
432 result = arc_exec.execute(context, context.data)
434 # If no exception was thrown, the arc execution succeeded
435 # Update data with result
436 if result is not None:
437 context.data = result
439 # Use the common state entry method
440 if not self.enter_state(context, arc.target_state):
441 return False
443 self._transition_count += 1
445 # Fire post-transition hooks
446 if self.enable_hooks:
447 for hook in self._post_transition_hooks:
448 hook(context, arc)
450 return True
452 except (TypeError, AttributeError, ValueError, SyntaxError) as e:
453 # Deterministic errors (code errors, type errors) - don't retry
454 self._error_count += 1
456 # Fire error hooks
457 if self.enable_hooks:
458 for hook in self._error_hooks:
459 hook(context, arc, e)
461 # Return false immediately for deterministic errors
462 return False
464 except FunctionError as e:
465 # Arc transform or pre-test failed - this is a definitive failure
466 self._error_count += 1
468 # Fire error hooks
469 if self.enable_hooks:
470 for hook in self._error_hooks:
471 hook(context, arc, e)
473 # Arc failed, no retry for function errors
474 return False
476 except Exception as e:
477 # Other exceptions - may be recoverable (network, resources, etc.)
478 self._error_count += 1
480 # Fire error hooks
481 if self.enable_hooks:
482 for hook in self._error_hooks:
483 hook(context, arc, e)
485 # Only retry for potentially recoverable errors
486 retry_count += 1
487 if retry_count <= self.max_retries:
488 time.sleep(self.retry_delay * retry_count)
489 else:
490 # Don't raise, just return False to allow graceful failure
491 return False
493 return False
495 def _execute_pre_validators(
496 self,
497 context: ExecutionContext,
498 state_name: str,
499 state_resources: Dict[str, Any] | None = None
500 ) -> bool:
501 """Execute pre-validation functions when entering a state.
503 Args:
504 context: Execution context.
505 state_name: Name of the state.
506 state_resources: Already allocated state resources.
508 Returns:
509 True if validation passes, False otherwise.
510 """
511 state_def = self.fsm.get_state(state_name)
512 if not state_def:
513 return True
515 # Use provided resources or empty dict
516 resources = state_resources if state_resources is not None else {}
518 if hasattr(state_def, 'pre_validation_functions') and state_def.pre_validation_functions:
519 for validator_func in state_def.pre_validation_functions:
520 try:
521 # Execute validator with state resources
522 func_context = FunctionContext(
523 state_name=state_name,
524 function_name=getattr(validator_func, '__name__', 'validate'),
525 metadata={'state': state_name, 'phase': 'pre_validation'},
526 resources=resources, # Pass state resources
527 variables=context.variables # Pass shared variables
528 )
529 result = validator_func(ensure_dict(context.data), func_context)
531 if result is False:
532 return False
533 # Update context.data if result is a dict
534 if isinstance(result, dict):
535 context.data.update(result)
536 except Exception:
537 # Log error and fail validation
538 return False
539 return True
541 def _allocate_state_resources(
542 self,
543 context: ExecutionContext,
544 state_name: str
545 ) -> Dict[str, Any]:
546 """Allocate resources required by a state.
548 Args:
549 context: Execution context.
550 state_name: Name of the state.
552 Returns:
553 Dictionary of allocated resources.
554 """
555 import logging
556 logger = logging.getLogger(__name__)
557 logger.debug(f"_allocate_state_resources called for state: {state_name}")
559 # Start with parent state resources if in subnetwork
560 parent_resources = getattr(context, 'parent_state_resources', None)
561 if parent_resources:
562 resources = dict(parent_resources) # Copy parent resources
563 logger.debug(f"Starting with parent resources: {list(parent_resources.keys())}")
564 else:
565 resources = {}
566 logger.debug("No parent resources")
568 state_def = self.fsm.get_state(state_name)
569 logger.debug(f"State def found: {state_def is not None}")
570 if state_def:
571 logger.debug(f"State has resource_requirements: {state_def.resource_requirements if hasattr(state_def, 'resource_requirements') else 'NO ATTRIBUTE'}")
573 if not state_def or not state_def.resource_requirements:
574 logger.debug("Returning early - no state def or no requirements")
575 return resources
577 resource_manager = getattr(context, 'resource_manager', None)
578 logger.debug(f"Resource manager available: {resource_manager is not None}")
579 if not resource_manager:
580 logger.debug("No resource manager - returning empty")
581 return resources
583 # Generate owner ID for state resource allocation
584 owner_id = f"state_{state_name}_{getattr(context, 'execution_id', 'unknown')}"
586 for resource_config in state_def.resource_requirements:
587 # Skip if resource already inherited from parent
588 if resource_config.name in resources:
589 logger.info(f"Resource '{resource_config.name}' inherited from parent state")
590 continue
592 try:
593 # ResourceConfig from schema has timeout_seconds, not timeout
594 timeout = getattr(resource_config, 'timeout_seconds', 30)
595 resource = resource_manager.acquire(
596 name=resource_config.name,
597 owner_id=owner_id,
598 timeout=timeout
599 )
600 resources[resource_config.name] = resource
601 except Exception as e:
602 # Log error but continue with other resources
603 logger.error(f"Failed to acquire resource {resource_config.name}: {e}")
605 return resources
607 def _release_state_resources(
608 self,
609 context: ExecutionContext,
610 state_name: str,
611 resources: Dict[str, Any]
612 ) -> None:
613 """Release state-allocated resources.
615 Args:
616 context: Execution context.
617 state_name: Name of the state.
618 resources: Resources to release.
619 """
620 resource_manager = getattr(context, 'resource_manager', None)
621 if not resource_manager:
622 return
624 # Get parent resources to avoid releasing them
625 parent_resources = getattr(context, 'parent_state_resources', {})
627 owner_id = f"state_{state_name}_{getattr(context, 'execution_id', 'unknown')}"
629 for resource_name in resources.keys():
630 # Skip releasing if this is a parent-inherited resource
631 if resource_name in parent_resources:
632 logger.info(f"Skipping release of inherited resource '{resource_name}'")
633 continue
635 try:
636 resource_manager.release(resource_name, owner_id)
637 except Exception as e:
638 # Log error but continue
639 logger.error(f"Failed to release resource {resource_name}: {e}")
641 def _execute_state_transforms(
642 self,
643 context: ExecutionContext,
644 state_name: str,
645 state_resources: Dict[str, Any] | None = None
646 ) -> None:
647 """Execute transform functions when entering a state.
649 Args:
650 context: Execution context.
651 state_name: Name of the state being entered.
652 state_resources: Already allocated state resources.
653 """
654 # Get the state definition
655 state_def = self.fsm.get_state(state_name)
656 if not state_def:
657 return
659 # Use provided resources or empty dict
660 resources = state_resources if state_resources is not None else {}
662 # Use base class logic to prepare and execute transforms
663 transform_functions, state_obj = self.prepare_state_transform(state_def, context)
665 for transform_func in transform_functions:
666 try:
667 # Create function context with resources and variables
668 func_context = FunctionContext(
669 state_name=state_name,
670 function_name=getattr(transform_func, '__name__', 'transform'),
671 metadata={'state': state_name},
672 resources=resources, # Pass state resources
673 variables=context.variables # Pass shared variables
674 )
676 # Add parent_state_resources to metadata if available
677 if hasattr(context, 'parent_state_resources') and context.parent_state_resources:
678 func_context.metadata['parent_state_resources'] = context.parent_state_resources
680 # Try calling with state object first (for inline lambdas)
681 try:
682 result = transform_func(state_obj)
683 except (TypeError, AttributeError):
684 # Fall back to calling with data and context
685 result = transform_func(ensure_dict(context.data), func_context)
687 # Process result using base class logic
688 self.process_transform_result(result, context, state_name)
690 except Exception as e:
691 # Handle error using base class logic
692 self.handle_transform_error(e, context, state_name)
694 def _execute_state_functions(
695 self,
696 context: ExecutionContext,
697 state_name: str
698 ) -> None:
699 """Execute all state functions (validators and transforms) for a state.
701 This should be called before evaluating arc conditions to ensure
702 that state functions can update the data that conditions depend on.
704 Args:
705 context: Execution context.
706 state_name: Name of the current state.
707 """
708 # Get the state definition
709 state_def = self.fsm.get_state(state_name)
710 if not state_def:
711 return
713 # Execute validation functions first
714 if hasattr(state_def, 'validation_functions') and state_def.validation_functions:
715 for validator_func in state_def.validation_functions:
716 try:
717 # Create function context with variables
718 func_context = FunctionContext(
719 state_name=state_name,
720 function_name=getattr(validator_func, '__name__', 'validate'),
721 metadata={'state': state_name},
722 resources={},
723 variables=context.variables # Pass shared variables
724 )
726 # Execute the validator
727 # Validators typically return a dict with validation results
728 # Create a wrapper for validators that expect state.data
729 from dataknobs_fsm.core.data_wrapper import wrap_for_lambda
730 state_obj = wrap_for_lambda(context.data)
732 # Try calling with state object first (for inline lambdas)
733 try:
734 result = validator_func(state_obj)
735 except (TypeError, AttributeError):
736 # Fall back to calling with data and context
737 result = validator_func(ensure_dict(context.data), func_context)
739 if result is not None:
740 # Ensure result is a dict and merge into context data
741 result_dict = ensure_dict(result)
742 if isinstance(result_dict, dict):
743 context.data.update(result_dict)
744 except Exception:
745 # Log but don't fail - state validators are optional
746 pass
748 # NOTE: Transform functions are NOT executed here. They are executed
749 # by _execute_state_transforms when entering a state after a transition.
750 # This method (_execute_state_functions) only executes validators before
751 # evaluating transition conditions.
753 def _get_available_transitions(
754 self,
755 context: ExecutionContext,
756 arc_name: str | None = None
757 ) -> List[ArcDefinition]:
758 """Get available transitions from current state.
760 Args:
761 context: Execution context.
762 arc_name: Optional specific arc name to filter by.
764 Returns:
765 List of available arc definitions.
766 """
767 if not context.current_state:
768 return []
770 # Get current network
771 network = self._get_current_network(context)
772 if not network:
773 return []
775 # Get arcs from current state
776 available = []
777 for arc_id, arc in network.arcs.items():
778 # Parse arc_id to get source state
779 if ':' in arc_id:
780 source_state = arc_id.split(':')[0]
781 if source_state == context.current_state:
782 # Filter by arc name if specified
783 if arc_name:
784 arc_actual_name = arc.metadata.get('name')
785 if arc_actual_name != arc_name:
786 continue
788 # Check if pre-test passes
789 if arc.pre_test:
790 if not self._evaluate_pre_test(arc, context):
791 continue
792 available.append(arc)
794 # Sort by priority
795 # Sort by priority (descending) then by definition order (ascending)
796 # This ensures stable ordering when priorities are equal
797 available.sort(key=lambda x: (-x.priority, x.definition_order))
799 return available
801 def _evaluate_pre_test(
802 self,
803 arc: ArcDefinition,
804 context: ExecutionContext
805 ) -> bool:
806 """Evaluate arc pre-test.
808 Args:
809 arc: Arc with pre-test.
810 context: Execution context.
812 Returns:
813 True if pre-test passes.
814 """
815 if not arc.pre_test:
816 return True
818 # Get pre-test function
819 func = self.fsm.function_registry.get_function(arc.pre_test)
820 if not func:
821 return False
823 # Create function context
824 func_context = FunctionContext(
825 state_name=context.current_state or "",
826 function_name=arc.pre_test,
827 metadata=context.metadata
828 )
830 try:
831 # Call the function appropriately based on its interface
832 if hasattr(func, 'test'):
833 # IStateTestFunction - create state-like object
834 from types import SimpleNamespace
835 state = SimpleNamespace(data=context.data)
836 result = func.test(state)
837 elif hasattr(func, 'execute'):
838 # Legacy function with execute method
839 result = func.execute(context.data, func_context)
840 elif callable(func):
841 # Direct callable
842 result = func(context.data, func_context)
843 else:
844 return False
845 # Handle tuple return from test functions (bool, reason)
846 if isinstance(result, tuple):
847 return bool(result[0])
848 return bool(result)
849 except Exception:
850 return False
852 def _choose_transition(
853 self,
854 transitions: List[ArcDefinition],
855 context: ExecutionContext
856 ) -> ArcDefinition | None:
857 """Choose next transition using common transition selector.
859 Args:
860 transitions: Available transitions.
861 context: Execution context.
863 Returns:
864 Chosen arc or None.
865 """
866 return self.transition_selector.select_transition(
867 transitions,
868 context,
869 strategy=self.strategy
870 )
872 def _find_initial_state(self) -> str | None:
873 """Find the initial state in the FSM.
875 Returns:
876 Name of initial state or None.
877 """
878 # Use base class implementation
879 return self.find_initial_state_common()
881 def _is_final_state(self, state_name: str | None) -> bool:
882 """Check if state is a final state.
884 Args:
885 state_name: Name of state to check.
887 Returns:
888 True if final state.
889 """
890 # Use base class implementation
891 return self.is_final_state_common(state_name)
893 def _is_final_state_legacy(self, state_name: str | None) -> bool:
894 """Legacy implementation kept for reference."""
895 if not state_name:
896 return False
898 # Get the main network - could be a string or object
899 main_network_ref = getattr(self.fsm, 'main_network', None)
901 if main_network_ref is None:
902 # If no main network specified, check all networks
903 for network in self.fsm.networks.values():
904 if state_name in network.states:
905 state = network.states[state_name]
906 if state.is_end_state() if hasattr(state, 'is_end_state') else state.type == StateType.END:
907 return True
908 return False
910 # Handle case where main_network is already a network object (FSM wrapper)
911 if hasattr(main_network_ref, 'states'):
912 main_network = main_network_ref
913 # Handle case where main_network is a string (core FSM)
914 elif isinstance(main_network_ref, str) and main_network_ref in self.fsm.networks:
915 main_network = self.fsm.networks[main_network_ref]
916 else:
917 return False
919 # Check if the state exists and is an end state
920 if state_name in main_network.states:
921 state = main_network.states[state_name]
922 return state.is_end_state() if hasattr(state, 'is_end_state') else state.type == StateType.END
924 return False
926 def _get_current_network(
927 self,
928 context: ExecutionContext
929 ) -> StateNetwork | None:
930 """Get the current network from context using common network selector.
932 Args:
933 context: Execution context.
935 Returns:
936 Current network or None.
937 """
938 # Allow intelligent selection to be controlled by selection_mode
939 enable_intelligent = self.selection_mode != TransitionSelectionMode.STRATEGY_BASED
941 return NetworkSelector.get_current_network(
942 self.fsm,
943 context,
944 enable_intelligent_selection=enable_intelligent
945 )
947 def add_pre_transition_hook(self, hook: Callable) -> None:
948 """Add a pre-transition hook.
950 Args:
951 hook: Hook function to add.
952 """
953 self._pre_transition_hooks.append(hook)
955 def add_post_transition_hook(self, hook: Callable) -> None:
956 """Add a post-transition hook.
958 Args:
959 hook: Hook function to add.
960 """
961 self._post_transition_hooks.append(hook)
963 def add_error_hook(self, hook: Callable) -> None:
964 """Add an error hook.
966 Args:
967 hook: Hook function to add.
968 """
969 self._error_hooks.append(hook)
971 def get_execution_stats(self) -> Dict[str, Any]:
972 """Get execution statistics.
974 Returns:
975 Execution statistics.
976 """
977 return {
978 'executions': self._execution_count,
979 'transitions': self._transition_count,
980 'errors': self._error_count,
981 'strategy': self.strategy.value,
982 'hooks_enabled': self.enable_hooks
983 }