Coverage for src/dataknobs_fsm/execution/engine.py: 41%

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

2

3 import logging

4 import time

5 from enum import Enum

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

7

8 from dataknobs_fsm.core.arc import ArcDefinition, ArcExecution

9 from dataknobs_fsm.core.exceptions import FunctionError

10 from dataknobs_fsm.core.fsm import FSM

11 from dataknobs_fsm.core.modes import ProcessingMode, TransactionMode

12 from dataknobs_fsm.core.network import StateNetwork

13 from dataknobs_fsm.core.state import StateType

14 from dataknobs_fsm.execution.context import ExecutionContext

15 from dataknobs_fsm.functions.base import FunctionContext

16 from dataknobs_fsm.execution.common import (

17 NetworkSelector,

18 TransitionSelectionMode

19)

20 from dataknobs_fsm.execution.base_engine import BaseExecutionEngine

21 from dataknobs_fsm.core.data_wrapper import ensure_dict

22

23 logger = logging.getLogger(__name__)

24

25

26 class 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

34 class 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 }