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

1"""Execution engine for FSM state machines.""" 

2 

3import logging 

4import time 

5from enum import Enum 

6from typing import Any, Callable, Dict, List, Tuple 

7 

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 

22 

23logger = logging.getLogger(__name__) 

24 

25 

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" 

32 

33 

34class ExecutionEngine(BaseExecutionEngine): 

35 """Execution engine for FSM state machines. 

36  

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 """ 

47 

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. 

58 

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) 

69 

70 self.enable_hooks = enable_hooks 

71 

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] = [] 

76 

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. 

85  

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. 

91  

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 

99 

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 

103 

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" 

109 

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 

115 

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}" 

125 

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. 

133  

134 Args: 

135 context: Execution context. 

136 max_transitions: Maximum transitions. 

137 arc_name: Optional specific arc name to follow. 

138  

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 

147 

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 

152 

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 "" 

157 

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 

166 

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) 

170 

171 # Get available transitions 

172 transitions_available = self._get_available_transitions(context, arc_name) 

173 

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}" 

179 

180 # Choose transition based on strategy 

181 next_transition = self._choose_transition( 

182 transitions_available, 

183 context 

184 ) 

185 

186 # Store current state before transition 

187 state_before = context.current_state 

188 

189 # Execute transition 

190 success = self._execute_transition( 

191 context, 

192 next_transition 

193 ) 

194 

195 if not success: 

196 return False, f"Transition failed: {next_transition}" 

197 

198 # Only increment if we actually transitioned 

199 if context.current_state != state_before: 

200 transitions += 1 

201 

202 return False, f"Maximum transitions ({max_transitions}) exceeded" 

203 

204 def _execute_batch( 

205 self, 

206 context: ExecutionContext, 

207 max_transitions: int 

208 ) -> Tuple[bool, Any]: 

209 """Execute in batch mode. 

210  

211 Args: 

212 context: Execution context. 

213 max_transitions: Maximum transitions per item. 

214  

215 Returns: 

216 Tuple of (success, results). 

217 """ 

218 if not context.batch_data: 

219 return False, "No batch data to process" 

220 

221 total_success = True 

222 

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 

227 

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) 

233 

234 # Execute for this item 

235 success, result = self._execute_single( 

236 item_context, 

237 max_transitions 

238 ) 

239 

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 

251 

252 # Merge child context 

253 context.merge_child_context(f"batch_{i}") 

254 

255 return total_success, { 

256 'results': context.batch_results, 

257 'errors': context.batch_errors 

258 } 

259 

260 def _execute_stream( 

261 self, 

262 context: ExecutionContext, 

263 max_transitions: int 

264 ) -> Tuple[bool, Any]: 

265 """Execute in stream mode. 

266  

267 Args: 

268 context: Execution context. 

269 max_transitions: Maximum transitions per chunk. 

270  

271 Returns: 

272 Tuple of (success, stream_stats). 

273 """ 

274 if not context.stream_context: 

275 return False, "No stream context provided" 

276 

277 chunks_processed = 0 

278 total_records = 0 

279 errors = [] 

280 

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 

287 

288 context.set_stream_chunk(chunk) 

289 

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 

296 

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) 

302 

303 # Execute for this record 

304 success, result = self._execute_single( 

305 record_context, 

306 max_transitions 

307 ) 

308 

309 if not success: 

310 errors.append((total_records, result)) 

311 

312 # Merge context 

313 context.merge_child_context( 

314 f"stream_{chunks_processed}_{total_records}" 

315 ) 

316 

317 total_records += 1 

318 

319 chunks_processed += 1 

320 

321 # Check if this was the last chunk 

322 if chunk.is_last: 

323 break 

324 

325 return len(errors) == 0, { 

326 'chunks_processed': chunks_processed, 

327 'records_processed': total_records, 

328 'errors': errors 

329 } 

330 

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. 

338 

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 

345 

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). 

350 

351 Returns: 

352 True if state entry was successful, False otherwise. 

353 """ 

354 # Set the current state 

355 context.set_state(state_name) 

356 

357 # Allocate state resources 

358 state_resources = self._allocate_state_resources(context, state_name) 

359 

360 # Store in context for cleanup tracking 

361 context.current_state_resources = state_resources 

362 

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 

372 

373 # Execute state transforms 

374 self._execute_state_transforms(context, state_name, state_resources) 

375 

376 return True 

377 

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. 

384 

385 This method handles: 

386 - Releasing state resources 

387 - Any other cleanup needed when leaving a state 

388 

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 = {} 

396 

397 def _execute_transition( 

398 self, 

399 context: ExecutionContext, 

400 arc: ArcDefinition 

401 ) -> bool: 

402 """Execute a single transition. 

403  

404 Args: 

405 context: Execution context. 

406 arc: Arc to execute. 

407  

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) 

415 

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 

423 

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 ) 

430 

431 # Execute with resource context 

432 result = arc_exec.execute(context, context.data) 

433 

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 

438 

439 # Use the common state entry method 

440 if not self.enter_state(context, arc.target_state): 

441 return False 

442 

443 self._transition_count += 1 

444 

445 # Fire post-transition hooks 

446 if self.enable_hooks: 

447 for hook in self._post_transition_hooks: 

448 hook(context, arc) 

449 

450 return True 

451 

452 except (TypeError, AttributeError, ValueError, SyntaxError) as e: 

453 # Deterministic errors (code errors, type errors) - don't retry 

454 self._error_count += 1 

455 

456 # Fire error hooks 

457 if self.enable_hooks: 

458 for hook in self._error_hooks: 

459 hook(context, arc, e) 

460 

461 # Return false immediately for deterministic errors 

462 return False 

463 

464 except FunctionError as e: 

465 # Arc transform or pre-test failed - this is a definitive failure 

466 self._error_count += 1 

467 

468 # Fire error hooks 

469 if self.enable_hooks: 

470 for hook in self._error_hooks: 

471 hook(context, arc, e) 

472 

473 # Arc failed, no retry for function errors 

474 return False 

475 

476 except Exception as e: 

477 # Other exceptions - may be recoverable (network, resources, etc.) 

478 self._error_count += 1 

479 

480 # Fire error hooks 

481 if self.enable_hooks: 

482 for hook in self._error_hooks: 

483 hook(context, arc, e) 

484 

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 

492 

493 return False 

494 

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. 

502 

503 Args: 

504 context: Execution context. 

505 state_name: Name of the state. 

506 state_resources: Already allocated state resources. 

507 

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 

514 

515 # Use provided resources or empty dict 

516 resources = state_resources if state_resources is not None else {} 

517 

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) 

530 

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 

540 

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. 

547 

548 Args: 

549 context: Execution context. 

550 state_name: Name of the state. 

551 

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}") 

558 

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") 

567 

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'}") 

572 

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 

576 

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 

582 

583 # Generate owner ID for state resource allocation 

584 owner_id = f"state_{state_name}_{getattr(context, 'execution_id', 'unknown')}" 

585 

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 

591 

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}") 

604 

605 return resources 

606 

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. 

614 

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 

623 

624 # Get parent resources to avoid releasing them 

625 parent_resources = getattr(context, 'parent_state_resources', {}) 

626 

627 owner_id = f"state_{state_name}_{getattr(context, 'execution_id', 'unknown')}" 

628 

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 

634 

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}") 

640 

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. 

648 

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 

658 

659 # Use provided resources or empty dict 

660 resources = state_resources if state_resources is not None else {} 

661 

662 # Use base class logic to prepare and execute transforms 

663 transform_functions, state_obj = self.prepare_state_transform(state_def, context) 

664 

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 ) 

675 

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 

679 

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) 

686 

687 # Process result using base class logic 

688 self.process_transform_result(result, context, state_name) 

689 

690 except Exception as e: 

691 # Handle error using base class logic 

692 self.handle_transform_error(e, context, state_name) 

693 

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. 

700  

701 This should be called before evaluating arc conditions to ensure 

702 that state functions can update the data that conditions depend on. 

703  

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 

712 

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 ) 

725 

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) 

731 

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) 

738 

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 

747 

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. 

752 

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. 

759  

760 Args: 

761 context: Execution context. 

762 arc_name: Optional specific arc name to filter by. 

763  

764 Returns: 

765 List of available arc definitions. 

766 """ 

767 if not context.current_state: 

768 return [] 

769 

770 # Get current network 

771 network = self._get_current_network(context) 

772 if not network: 

773 return [] 

774 

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 

787 

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) 

793 

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)) 

798 

799 return available 

800 

801 def _evaluate_pre_test( 

802 self, 

803 arc: ArcDefinition, 

804 context: ExecutionContext 

805 ) -> bool: 

806 """Evaluate arc pre-test. 

807  

808 Args: 

809 arc: Arc with pre-test. 

810 context: Execution context. 

811  

812 Returns: 

813 True if pre-test passes. 

814 """ 

815 if not arc.pre_test: 

816 return True 

817 

818 # Get pre-test function 

819 func = self.fsm.function_registry.get_function(arc.pre_test) 

820 if not func: 

821 return False 

822 

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 ) 

829 

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 

851 

852 def _choose_transition( 

853 self, 

854 transitions: List[ArcDefinition], 

855 context: ExecutionContext 

856 ) -> ArcDefinition | None: 

857 """Choose next transition using common transition selector. 

858  

859 Args: 

860 transitions: Available transitions. 

861 context: Execution context. 

862  

863 Returns: 

864 Chosen arc or None. 

865 """ 

866 return self.transition_selector.select_transition( 

867 transitions, 

868 context, 

869 strategy=self.strategy 

870 ) 

871 

872 def _find_initial_state(self) -> str | None: 

873 """Find the initial state in the FSM. 

874 

875 Returns: 

876 Name of initial state or None. 

877 """ 

878 # Use base class implementation 

879 return self.find_initial_state_common() 

880 

881 def _is_final_state(self, state_name: str | None) -> bool: 

882 """Check if state is a final state. 

883 

884 Args: 

885 state_name: Name of state to check. 

886 

887 Returns: 

888 True if final state. 

889 """ 

890 # Use base class implementation 

891 return self.is_final_state_common(state_name) 

892 

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 

897 

898 # Get the main network - could be a string or object 

899 main_network_ref = getattr(self.fsm, 'main_network', None) 

900 

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 

909 

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 

918 

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 

923 

924 return False 

925 

926 def _get_current_network( 

927 self, 

928 context: ExecutionContext 

929 ) -> StateNetwork | None: 

930 """Get the current network from context using common network selector. 

931  

932 Args: 

933 context: Execution context. 

934  

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 

940 

941 return NetworkSelector.get_current_network( 

942 self.fsm, 

943 context, 

944 enable_intelligent_selection=enable_intelligent 

945 ) 

946 

947 def add_pre_transition_hook(self, hook: Callable) -> None: 

948 """Add a pre-transition hook. 

949  

950 Args: 

951 hook: Hook function to add. 

952 """ 

953 self._pre_transition_hooks.append(hook) 

954 

955 def add_post_transition_hook(self, hook: Callable) -> None: 

956 """Add a post-transition hook. 

957  

958 Args: 

959 hook: Hook function to add. 

960 """ 

961 self._post_transition_hooks.append(hook) 

962 

963 def add_error_hook(self, hook: Callable) -> None: 

964 """Add an error hook. 

965  

966 Args: 

967 hook: Hook function to add. 

968 """ 

969 self._error_hooks.append(hook) 

970 

971 def get_execution_stats(self) -> Dict[str, Any]: 

972 """Get execution statistics. 

973  

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 }