Coverage for src/dataknobs_fsm/api/advanced.py: 22%

544 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-20 16:46 -0600

1"""Advanced API for full control over FSM execution. 

2 

3This module provides advanced interfaces for users who need fine-grained 

4control over FSM execution, resource management, and monitoring. 

5""" 

6 

7import time 

8from collections.abc import Callable 

9from contextlib import asynccontextmanager 

10from dataclasses import dataclass, field 

11from enum import Enum 

12from pathlib import Path 

13from typing import Any 

14 

15from dataknobs_data import Record 

16 

17from ..core.context_factory import ContextFactory 

18from ..core.data_modes import DataHandler, DataHandlingMode 

19from ..core.fsm import FSM 

20from ..core.modes import ProcessingMode 

21from ..core.state import StateInstance 

22from ..core.transactions import TransactionManager, TransactionStrategy 

23from ..execution.async_engine import AsyncExecutionEngine 

24from ..execution.context import ExecutionContext 

25from ..execution.engine import ExecutionEngine, TraversalStrategy 

26from ..execution.history import ExecutionHistory 

27from ..resources.base import IResourceProvider 

28from ..resources.manager import ResourceManager 

29from ..storage.base import IHistoryStorage 

30 

31 

32class ExecutionMode(Enum): 

33 """Advanced execution modes.""" 

34 STEP_BY_STEP = "step" # Execute one transition at a time 

35 BREAKPOINT = "breakpoint" # Stop at specific states 

36 TRACE = "trace" # Full execution tracing 

37 PROFILE = "profile" # Performance profiling 

38 DEBUG = "debug" # Debug mode with detailed logging 

39 

40 

41@dataclass 

42class ExecutionHook: 

43 """Hook for monitoring execution events.""" 

44 on_state_enter: Callable | None = None 

45 on_state_exit: Callable | None = None 

46 on_arc_execute: Callable | None = None 

47 on_error: Callable | None = None 

48 on_resource_acquire: Callable | None = None 

49 on_resource_release: Callable | None = None 

50 on_transaction_begin: Callable | None = None 

51 on_transaction_commit: Callable | None = None 

52 on_transaction_rollback: Callable | None = None 

53 

54 

55@dataclass 

56class StepResult: 

57 """Result from a single step execution.""" 

58 from_state: str 

59 to_state: str 

60 transition: str 

61 data_before: dict[str, Any] = field(default_factory=dict) 

62 data_after: dict[str, Any] = field(default_factory=dict) 

63 duration: float = 0.0 

64 success: bool = True 

65 error: str | None = None 

66 at_breakpoint: bool = False 

67 is_complete: bool = False 

68 

69 

70class AdvancedFSM: 

71 """Advanced FSM interface with full control capabilities.""" 

72 

73 def __init__( 

74 self, 

75 fsm: FSM, 

76 execution_mode: ExecutionMode = ExecutionMode.STEP_BY_STEP, 

77 custom_functions: dict[str, Callable] | None = None 

78 ): 

79 """Initialize AdvancedFSM. 

80  

81 Args: 

82 fsm: Core FSM instance 

83 execution_mode: Execution mode for advanced control 

84 custom_functions: Optional custom functions to register 

85 """ 

86 self.fsm = fsm 

87 self.execution_mode = execution_mode 

88 self._engine = ExecutionEngine(fsm) 

89 self._async_engine = AsyncExecutionEngine(fsm) 

90 self._resource_manager = ResourceManager() 

91 self._transaction_manager = None 

92 self._history = None 

93 self._storage = None 

94 self._hooks = ExecutionHook() 

95 self._breakpoints = set() 

96 self._trace_buffer = [] 

97 self._profile_data = {} 

98 self._custom_functions = custom_functions or {} 

99 

100 def set_execution_strategy(self, strategy: TraversalStrategy) -> None: 

101 """Set custom execution strategy. 

102  

103 Args: 

104 strategy: Execution strategy to use 

105 """ 

106 self._engine.strategy = strategy 

107 

108 def set_data_handler(self, handler: DataHandler) -> None: 

109 """Set custom data handler. 

110  

111 Args: 

112 handler: Data handler implementation 

113 """ 

114 self._engine.data_handler = handler 

115 

116 def configure_transactions( 

117 self, 

118 strategy: TransactionStrategy, 

119 **config 

120 ) -> None: 

121 """Configure transaction management. 

122  

123 Args: 

124 strategy: Transaction strategy to use 

125 **config: Strategy-specific configuration 

126 """ 

127 self._transaction_manager = TransactionManager.create(strategy, **config) 

128 

129 def register_resource( 

130 self, 

131 name: str, 

132 resource: IResourceProvider | dict[str, Any] 

133 ) -> None: 

134 """Register a custom resource. 

135  

136 Args: 

137 name: Resource name 

138 resource: Resource instance or configuration 

139 """ 

140 if isinstance(resource, dict): 

141 # Use ResourceManager factory method 

142 self._resource_manager.register_from_dict(name, resource) 

143 else: 

144 # Assume it's already a provider 

145 self._resource_manager.register_provider(name, resource) 

146 

147 def set_hooks(self, hooks: ExecutionHook) -> None: 

148 """Set execution hooks for monitoring. 

149  

150 Args: 

151 hooks: Execution hooks configuration 

152 """ 

153 self._hooks = hooks 

154 

155 def add_breakpoint(self, state_name: str) -> None: 

156 """Add a breakpoint at a specific state. 

157  

158 Args: 

159 state_name: Name of state to break at 

160 """ 

161 self._breakpoints.add(state_name) 

162 

163 def remove_breakpoint(self, state_name: str) -> None: 

164 """Remove a breakpoint. 

165  

166 Args: 

167 state_name: Name of state to remove breakpoint from 

168 """ 

169 self._breakpoints.discard(state_name) 

170 

171 def clear_breakpoints(self) -> None: 

172 """Clear all breakpoints.""" 

173 self._breakpoints.clear() 

174 

175 @property 

176 def breakpoints(self) -> set: 

177 """Get the current breakpoints.""" 

178 return self._breakpoints.copy() 

179 

180 @property 

181 def hooks(self) -> ExecutionHook: 

182 """Get the current execution hooks.""" 

183 return self._hooks 

184 

185 @property 

186 def history_enabled(self) -> bool: 

187 """Check if history tracking is enabled.""" 

188 return self._history is not None 

189 

190 @property 

191 def max_history_depth(self) -> int: 

192 """Get the maximum history depth.""" 

193 return self._history.max_depth if self._history else 0 

194 

195 @property 

196 def execution_history(self) -> list: 

197 """Get the execution history steps.""" 

198 return self._history.steps if self._history else [] 

199 

200 def enable_history( 

201 self, 

202 storage: IHistoryStorage | None = None, 

203 max_depth: int = 100 

204 ) -> None: 

205 """Enable execution history tracking. 

206  

207 Args: 

208 storage: Optional storage backend for history 

209 max_depth: Maximum history depth to track 

210 """ 

211 import uuid 

212 

213 from dataknobs_fsm.core.data_modes import DataHandlingMode 

214 

215 # Get FSM name from the FSM object 

216 fsm_name = getattr(self.fsm, 'name', 'unnamed_fsm') 

217 

218 # Generate a unique execution ID 

219 execution_id = str(uuid.uuid4()) 

220 

221 self._history = ExecutionHistory( 

222 fsm_name=fsm_name, 

223 execution_id=execution_id, 

224 data_mode=DataHandlingMode.COPY, # Default data mode 

225 max_depth=max_depth 

226 ) 

227 self._storage = storage 

228 

229 def disable_history(self) -> None: 

230 """Disable history tracking.""" 

231 self._history = None 

232 self._storage = None 

233 

234 def create_context( 

235 self, 

236 data: dict[str, Any] | Record, 

237 data_mode: DataHandlingMode = DataHandlingMode.COPY, 

238 initial_state: str | None = None 

239 ) -> ExecutionContext: 

240 """Create an execution context for manual control (synchronous). 

241 

242 Args: 

243 data: Initial data 

244 data_mode: Data handling mode 

245 initial_state: Starting state name 

246 

247 Returns: 

248 ExecutionContext for manual execution 

249 """ 

250 # Create context with appropriate data handling 

251 # Use SINGLE processing mode as default 

252 processing_mode = ProcessingMode.SINGLE 

253 

254 context = ContextFactory.create_context( 

255 self.fsm, 

256 data, 

257 data_mode=processing_mode 

258 ) 

259 

260 # Set initial state if provided 

261 if initial_state: 

262 context.set_state(initial_state) 

263 else: 

264 # Find and set initial state using shared helper 

265 initial_state = self._find_initial_state() 

266 if initial_state: 

267 context.set_state(initial_state) 

268 

269 # Update state instance using shared helper 

270 if context.current_state: 

271 self._update_state_instance(context, context.current_state) 

272 

273 # Register custom functions if any 

274 if self._custom_functions: 

275 if not hasattr(self.fsm, 'function_registry'): 

276 self.fsm.function_registry = {} 

277 self.fsm.function_registry.update(self._custom_functions) 

278 

279 return context 

280 

281 @asynccontextmanager 

282 async def execution_context( 

283 self, 

284 data: dict[str, Any] | Record, 

285 data_mode: DataHandlingMode = DataHandlingMode.COPY, 

286 initial_state: str | None = None 

287 ): 

288 """Create an execution context for manual control. 

289 

290 Args: 

291 data: Initial data 

292 data_mode: Data handling mode 

293 initial_state: Starting state name 

294 

295 Yields: 

296 ExecutionContext for manual execution 

297 """ 

298 # Create context using factory 

299 context = ContextFactory.create_context( 

300 fsm=self.fsm, 

301 data=data, 

302 initial_state=initial_state, 

303 data_mode=ProcessingMode.SINGLE, 

304 resource_manager=self._resource_manager 

305 ) 

306 

307 # Set transaction manager if configured 

308 if self._transaction_manager: 

309 context.transaction_manager = self._transaction_manager # type: ignore[unreachable] 

310 

311 # Get the state instance for the hook 

312 state_instance = context.current_state_instance 

313 if not state_instance: 

314 # Create state instance if not set by factory 

315 state_instance = self.fsm.create_state_instance( 

316 context.current_state, # type: ignore 

317 context.data.copy() if isinstance(context.data, dict) else {} 

318 ) 

319 context.current_state_instance = state_instance 

320 

321 # Call hook with StateInstance 

322 if self._hooks.on_state_enter: 

323 await self._hooks.on_state_enter(state_instance) 

324 

325 try: 

326 yield context 

327 finally: 

328 # Cleanup 

329 if self._hooks.on_state_exit: 

330 await self._hooks.on_state_exit(state_instance) 

331 await self._resource_manager.cleanup() 

332 

333 async def step( 

334 self, 

335 context: ExecutionContext, 

336 arc_name: str | None = None 

337 ) -> StateInstance | None: 

338 """Execute a single transition step. 

339 

340 Args: 

341 context: Execution context 

342 arc_name: Optional specific arc to follow 

343 

344 Returns: 

345 New state instance or None if no transition 

346 """ 

347 # Store the current state before the transition 

348 state_before = context.current_state 

349 

350 # Use the async execution engine to execute one step 

351 # This ensures consistent execution logic across all FSM types 

352 _success, _result = await self._async_engine.execute( 

353 context=context, 

354 data=None, # Don't override context data 

355 max_transitions=1, # Execute exactly one transition 

356 arc_name=arc_name # Pass arc_name for filtering 

357 ) 

358 

359 # Check if we actually transitioned to a new state 

360 if context.current_state != state_before and context.current_state is not None: 

361 # Update state instance using shared helper 

362 self._update_state_instance(context, context.current_state) 

363 new_state = context.current_state_instance 

364 

365 # Track in history using shared helper 

366 if context.current_state is not None: 

367 self._record_history_step(context.current_state, arc_name, context) 

368 

369 # Add to trace using shared helper (we need to adjust the helper slightly) 

370 if self.execution_mode == ExecutionMode.TRACE: 

371 self._trace_buffer.append({ 

372 'from': state_before, 

373 'to': context.current_state, 

374 'arc': arc_name or 'transition', 

375 'data': context.data 

376 }) 

377 

378 # Call state enter hook (async version) 

379 if self._hooks.on_state_enter: 

380 await self._hooks.on_state_enter(new_state) 

381 

382 return new_state 

383 

384 # No transition occurred 

385 return None 

386 

387 async def run_until_breakpoint( 

388 self, 

389 context: ExecutionContext, 

390 max_steps: int = 1000 

391 ) -> StateInstance | None: 

392 """Run execution until a breakpoint is hit. 

393 

394 Args: 

395 context: Execution context 

396 max_steps: Maximum steps to execute (safety limit) 

397 

398 Returns: 

399 State instance where execution stopped 

400 """ 

401 for _ in range(max_steps): 

402 # Check if current state is a breakpoint 

403 if context.current_state in self._breakpoints: 

404 return context.current_state_instance 

405 

406 # Step to next state 

407 new_state = await self.step(context) 

408 

409 # Check if we reached an end state or no transition occurred 

410 if not new_state or self._is_at_end_state(context): 

411 return context.current_state_instance 

412 

413 # Hit max steps limit 

414 return context.current_state_instance 

415 

416 async def trace_execution( 

417 self, 

418 data: dict[str, Any] | Record, 

419 initial_state: str | None = None 

420 ) -> list[dict[str, Any]]: 

421 """Execute with full tracing enabled. 

422  

423 Args: 

424 data: Input data 

425 initial_state: Optional starting state 

426  

427 Returns: 

428 List of trace entries 

429 """ 

430 self.execution_mode = ExecutionMode.TRACE 

431 self._trace_buffer.clear() 

432 

433 async with self.execution_context(data, initial_state=initial_state) as context: 

434 # Run to completion 

435 while True: 

436 new_state = await self.step(context) 

437 if not new_state or new_state.definition.is_end: 

438 break 

439 

440 return self._trace_buffer 

441 

442 async def profile_execution( 

443 self, 

444 data: dict[str, Any] | Record, 

445 initial_state: str | None = None 

446 ) -> dict[str, Any]: 

447 """Execute with performance profiling. 

448  

449 Args: 

450 data: Input data 

451 initial_state: Optional starting state 

452  

453 Returns: 

454 Profiling data 

455 """ 

456 import time 

457 

458 self.execution_mode = ExecutionMode.PROFILE 

459 self._profile_data.clear() 

460 

461 async with self.execution_context(data, initial_state=initial_state) as context: 

462 start_time = time.time() 

463 transitions = 0 

464 

465 # Track per-state timing 

466 state_times = {} 

467 state_start = time.time() 

468 

469 while True: 

470 # Get current state name 

471 if isinstance(context.current_state, str): 

472 current_state_name = context.current_state 

473 else: 

474 current_state_name = context.current_state if context.current_state else "unknown" 

475 

476 # Step 

477 new_state = await self.step(context) 

478 

479 # Record state timing 

480 state_duration = time.time() - state_start 

481 if current_state_name not in state_times: 

482 state_times[current_state_name] = [] 

483 state_times[current_state_name].append(state_duration) 

484 

485 if not new_state or (hasattr(new_state, 'definition') and new_state.definition.is_end): 

486 break 

487 

488 transitions += 1 

489 state_start = time.time() 

490 

491 total_time = time.time() - start_time 

492 

493 # Compute statistics 

494 self._profile_data = { 

495 'total_time': total_time, 

496 'transitions': transitions, 

497 'avg_transition_time': total_time / transitions if transitions > 0 else 0, 

498 'state_times': { 

499 state: { 

500 'count': len(times), 

501 'total': sum(times), 

502 'avg': sum(times) / len(times), 

503 'min': min(times), 

504 'max': max(times) 

505 } 

506 for state, times in state_times.items() 

507 } 

508 } 

509 

510 return self._profile_data 

511 

512 def get_available_transitions( 

513 self, 

514 state_name: str 

515 ) -> list[dict[str, Any]]: 

516 """Get available transitions from a state. 

517  

518 Args: 

519 state_name: Name of state 

520  

521 Returns: 

522 List of available transition information 

523 """ 

524 arcs = self.fsm.get_outgoing_arcs(state_name) 

525 return [ 

526 { 

527 'name': arc.name, 

528 'target': arc.target_state, 

529 'has_pre_test': arc.pre_test is not None, 

530 'has_transform': arc.transform is not None 

531 } 

532 for arc in arcs 

533 ] 

534 

535 def inspect_state(self, state_name: str) -> dict[str, Any]: 

536 """Inspect a state's configuration. 

537  

538 Args: 

539 state_name: Name of state to inspect 

540  

541 Returns: 

542 State configuration details 

543 """ 

544 state = self.fsm.get_state(state_name) 

545 if not state: 

546 return {'error': f'State {state_name} not found'} 

547 

548 return { 

549 'name': state.name, 

550 'is_start': self.fsm.is_start_state(state_name), 

551 'is_end': self.fsm.is_end_state(state_name), 

552 'has_transform': len(state.transform_functions) > 0, 

553 'has_validator': len(state.validation_functions) > 0, 

554 'resources': [r.name for r in state.resource_requirements] if state.resource_requirements else [], 

555 'metadata': state.metadata, 

556 'arcs': state.arcs 

557 } 

558 

559 def visualize_fsm(self) -> str: 

560 """Generate a visual representation of the FSM. 

561  

562 Returns: 

563 GraphViz DOT format string 

564 """ 

565 lines = ['digraph FSM {'] 

566 lines.append(' rankdir=LR;') 

567 lines.append(' node [shape=circle];') 

568 

569 # Add states 

570 for state in self.fsm.states.values(): 

571 attrs = [] 

572 if state.is_start: 

573 attrs.append('style=filled') 

574 attrs.append('fillcolor=green') 

575 elif state.is_end: 

576 attrs.append('shape=doublecircle') 

577 attrs.append('style=filled') 

578 attrs.append('fillcolor=red') 

579 

580 if attrs: 

581 lines.append(f' {state.name} [{",".join(attrs)}];') 

582 else: 

583 lines.append(f' {state.name};') 

584 

585 # Add arcs 

586 for state_name in self.fsm.states: 

587 for arc in self.fsm.get_outgoing_arcs(state_name): 

588 label = arc.name if arc.name else "" 

589 lines.append(f' {state_name} -> {arc.target_state} [label="{label}"];') 

590 

591 lines.append('}') 

592 return '\n'.join(lines) 

593 

594 async def validate_network(self) -> dict[str, Any]: 

595 """Validate the FSM network for consistency. 

596  

597 Returns: 

598 Validation results 

599 """ 

600 issues = [] 

601 

602 # Check for unreachable states 

603 reachable = set() 

604 to_visit = [s.name for s in self.fsm.states.values() if s.is_start] 

605 

606 while to_visit: 

607 state = to_visit.pop(0) 

608 if state in reachable: 

609 continue 

610 reachable.add(state) 

611 

612 arcs = self.fsm.get_outgoing_arcs(state) 

613 for arc in arcs: 

614 if arc.target_state not in reachable: 

615 to_visit.append(arc.target_state) 

616 

617 unreachable = set(self.fsm.states.keys()) - reachable 

618 if unreachable: 

619 issues.append({ 

620 'type': 'unreachable_states', 

621 'states': list(unreachable) 

622 }) 

623 

624 # Check for dead ends (non-end states with no outgoing arcs) 

625 for state_name, state in self.fsm.states.items(): 

626 if not state.is_end: 

627 arcs = self.fsm.get_outgoing_arcs(state_name) 

628 if not arcs: 

629 issues.append({ 

630 'type': 'dead_end', 

631 'state': state_name 

632 }) 

633 

634 return { 

635 'valid': len(issues) == 0, 

636 'issues': issues, 

637 'stats': { 

638 'total_states': len(self.fsm.states), 

639 'reachable_states': len(reachable), 

640 'unreachable_states': len(unreachable), 

641 'start_states': sum(1 for s in self.fsm.states.values() if s.is_start), # type: ignore 

642 'end_states': sum(1 for s in self.fsm.states.values() if s.is_end) # type: ignore 

643 } 

644 } 

645 

646 def get_history(self) -> ExecutionHistory | None: 

647 """Get execution history if enabled. 

648  

649 Returns: 

650 Execution history or None 

651 """ 

652 return self._history 

653 

654 async def save_history(self) -> bool: 

655 """Save execution history to storage. 

656  

657 Returns: 

658 True if saved successfully 

659 """ 

660 if self._history and self._storage: # type: ignore[unreachable] 

661 return await self._storage.save(self._history) # type: ignore[unreachable] 

662 return False 

663 

664 async def load_history(self, history_id: str) -> bool: 

665 """Load execution history from storage. 

666  

667 Args: 

668 history_id: History identifier 

669  

670 Returns: 

671 True if loaded successfully 

672 """ 

673 if self._storage: 

674 history = await self._storage.load(history_id) # type: ignore[unreachable] 

675 if history: 

676 self._history = history 

677 return True 

678 return False 

679 

680 # ========== Shared Helper Methods ========== 

681 # These methods contain logic shared between sync and async implementations 

682 

683 def _get_available_transitions( 

684 self, 

685 context: ExecutionContext, 

686 arc_name: str | None = None 

687 ) -> list: 

688 """Get available transitions from current state (shared logic). 

689 

690 Args: 

691 context: Execution context 

692 arc_name: Optional specific arc to filter for 

693 

694 Returns: 

695 List of available arcs 

696 """ 

697 transitions = [] 

698 if not context.current_state: 

699 return transitions 

700 

701 # Get arcs from current state 

702 arcs = self.fsm.get_outgoing_arcs(context.current_state) 

703 

704 for arc in arcs: 

705 # Filter by arc name if specified 

706 if arc_name and arc.name != arc_name: 

707 continue 

708 

709 # Check arc condition 

710 if arc.pre_test: 

711 # Get function registry 

712 registry = getattr(self.fsm, 'function_registry', {}) 

713 if hasattr(registry, 'functions'): 

714 functions = registry.functions 

715 else: 

716 functions = registry 

717 

718 # Check in registry and custom functions 

719 test_func = functions.get(arc.pre_test) or self._custom_functions.get(arc.pre_test) 

720 if test_func: 

721 try: 

722 if test_func(context.data, context): 

723 transitions.append(arc) 

724 except Exception: 

725 pass 

726 else: 

727 # No condition, arc is always available 

728 transitions.append(arc) 

729 

730 return transitions 

731 

732 def _execute_arc_transform( 

733 self, 

734 arc, 

735 context: ExecutionContext 

736 ) -> tuple[bool, Any]: 

737 """Execute arc transform function (shared logic). 

738 

739 Args: 

740 arc: Arc with potential transform 

741 context: Execution context 

742 

743 Returns: 

744 Tuple of (success, result_or_error) 

745 """ 

746 if not arc.transform: 

747 return True, context.data 

748 

749 # Get function registry 

750 registry = getattr(self.fsm, 'function_registry', {}) 

751 if hasattr(registry, 'functions'): 

752 functions = registry.functions 

753 else: 

754 functions = registry 

755 

756 # Look for transform in registry or custom functions 

757 transform_func = functions.get(arc.transform) or self._custom_functions.get(arc.transform) 

758 

759 if transform_func: 

760 try: 

761 result = transform_func(context.data, context) 

762 return True, result 

763 except Exception as e: 

764 return False, str(e) 

765 

766 return True, context.data 

767 

768 def _update_state_instance( 

769 self, 

770 context: ExecutionContext, 

771 state_name: str 

772 ) -> None: 

773 """Update the current state instance in context (shared logic). 

774 

775 Args: 

776 context: Execution context 

777 state_name: Name of the new state 

778 """ 

779 state_def = self.fsm.states.get(state_name) 

780 if state_def: 

781 context.current_state_instance = StateInstance( 

782 definition=state_def, 

783 data=context.data 

784 ) 

785 # Mark if it's an end state 

786 context.metadata['is_end_state'] = state_def.is_end 

787 

788 def _is_at_end_state(self, context: ExecutionContext) -> bool: 

789 """Check if context is at an end state (shared logic). 

790 

791 Args: 

792 context: Execution context 

793 

794 Returns: 

795 True if at an end state 

796 """ 

797 if not context.current_state: 

798 return False 

799 

800 state = self.fsm.states.get(context.current_state) 

801 if state: 

802 return state.is_end 

803 

804 return context.metadata.get('is_end_state', False) 

805 

806 def _record_trace_entry( 

807 self, 

808 from_state: str, 

809 to_state: str, 

810 arc_name: str | None, 

811 context: ExecutionContext 

812 ) -> None: 

813 """Record a trace entry if in trace mode (shared logic). 

814 

815 Args: 

816 from_state: State transitioning from 

817 to_state: State transitioning to 

818 arc_name: Name of arc taken 

819 context: Execution context 

820 """ 

821 if self.execution_mode == ExecutionMode.TRACE: 

822 self._trace_buffer.append({ 

823 'from_state': from_state, 

824 'to_state': to_state, 

825 'transition': arc_name or f"{from_state}->{to_state}", 

826 'data': context.get_data_snapshot(), 

827 'timestamp': time.time() 

828 }) 

829 

830 def _record_history_step( 

831 self, 

832 state_name: str, 

833 arc_name: str | None, 

834 context: ExecutionContext 

835 ) -> None: 

836 """Record a history step if history is enabled (shared logic). 

837 

838 Args: 

839 state_name: Current state name 

840 arc_name: Arc taken 

841 context: Execution context 

842 """ 

843 if self._history: 

844 step = self._history.add_step( # type: ignore[unreachable] 

845 state_name=state_name, 

846 network_name=getattr(context, 'network_name', 'main'), 

847 data=context.data 

848 ) 

849 step.complete(arc_taken=arc_name or 'transition') 

850 

851 def _call_hook_sync( 

852 self, 

853 hook_name: str, 

854 *args: Any 

855 ) -> None: 

856 """Call a hook synchronously if it exists (shared logic). 

857 

858 Args: 

859 hook_name: Name of hook attribute 

860 args: Arguments to pass to hook 

861 """ 

862 hook = getattr(self._hooks, hook_name, None) 

863 if hook: 

864 try: 

865 hook(*args) 

866 except Exception: 

867 pass # Silently ignore hook errors 

868 

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

870 """Find the initial state in the FSM (shared logic). 

871 

872 Returns: 

873 Name of initial state or None 

874 """ 

875 for state_name, state in self.fsm.states.items(): 

876 if state.is_start: 

877 return state_name 

878 return None 

879 

880 # ========== Synchronous Execution Methods ========== 

881 

882 def execute_step_sync( 

883 self, 

884 context: ExecutionContext, 

885 arc_name: str | None = None 

886 ) -> StepResult: 

887 """Execute a single transition step synchronously. 

888 

889 Args: 

890 context: Execution context 

891 arc_name: Optional specific arc to follow 

892 

893 Returns: 

894 StepResult with transition details 

895 """ 

896 start_time = time.time() 

897 from_state = context.current_state or "initial" 

898 data_before = context.get_data_snapshot() 

899 

900 try: 

901 # Initialize state if needed 

902 if not context.current_state: 

903 initial_state = self._find_initial_state() 

904 if initial_state: 

905 context.set_state(initial_state) 

906 self._update_state_instance(context, initial_state) 

907 # Execute transforms for initial state 

908 if hasattr(self._engine, '_execute_state_transforms'): 

909 self._engine._execute_state_transforms(context, initial_state) 

910 else: 

911 return StepResult( 

912 from_state=from_state, 

913 to_state=from_state, 

914 transition="error", 

915 data_before=data_before, 

916 data_after=context.get_data_snapshot(), 

917 duration=time.time() - start_time, 

918 success=False, 

919 error="No initial state found" 

920 ) 

921 

922 # Use shared logic to get transitions 

923 transitions = self._get_available_transitions(context, arc_name) 

924 

925 if not transitions: 

926 # No transitions available 

927 return StepResult( 

928 from_state=from_state, 

929 to_state=from_state, 

930 transition="none", 

931 data_before=data_before, 

932 data_after=context.get_data_snapshot(), 

933 duration=time.time() - start_time, 

934 success=True, 

935 is_complete=self._is_at_end_state(context) 

936 ) 

937 

938 # Take first valid transition (could be enhanced with strategy selection) 

939 arc = transitions[0] 

940 

941 # Execute arc transform using shared logic 

942 success, result = self._execute_arc_transform(arc, context) 

943 if success: 

944 context.data = result 

945 else: 

946 return StepResult( 

947 from_state=from_state, 

948 to_state=from_state, 

949 transition=arc.name or "error", 

950 data_before=data_before, 

951 data_after=context.get_data_snapshot(), 

952 duration=time.time() - start_time, 

953 success=False, 

954 error=result 

955 ) 

956 

957 # Update state 

958 context.set_state(arc.target_state) 

959 self._update_state_instance(context, arc.target_state) 

960 

961 # Execute state transforms when entering the new state 

962 # This is critical for sync execution to match async behavior 

963 if hasattr(self._engine, '_execute_state_transforms'): 

964 self._engine._execute_state_transforms(context, arc.target_state) 

965 

966 # Check if we hit a breakpoint 

967 at_breakpoint = arc.target_state in self._breakpoints 

968 

969 # Record in trace buffer if in trace mode 

970 self._record_trace_entry(from_state, arc.target_state, arc.name, context) 

971 

972 # Record in history if enabled 

973 self._record_history_step(arc.target_state, arc.name, context) 

974 

975 # Call hooks if configured 

976 self._call_hook_sync('on_state_exit', from_state) 

977 self._call_hook_sync('on_state_enter', arc.target_state) 

978 

979 return StepResult( 

980 from_state=from_state, 

981 to_state=arc.target_state, 

982 transition=arc.name or f"{from_state}->{arc.target_state}", 

983 data_before=data_before, 

984 data_after=context.get_data_snapshot(), 

985 duration=time.time() - start_time, 

986 success=True, 

987 at_breakpoint=at_breakpoint, 

988 is_complete=self._is_at_end_state(context) 

989 ) 

990 

991 except Exception as e: 

992 self._call_hook_sync('on_error', e) 

993 

994 return StepResult( 

995 from_state=from_state, 

996 to_state=from_state, 

997 transition="error", 

998 data_before=data_before, 

999 data_after=context.get_data_snapshot(), 

1000 duration=time.time() - start_time, 

1001 success=False, 

1002 error=str(e) 

1003 ) 

1004 

1005 def run_until_breakpoint_sync( 

1006 self, 

1007 context: ExecutionContext, 

1008 max_steps: int = 1000 

1009 ) -> StateInstance | None: 

1010 """Run execution until a breakpoint is hit (synchronous). 

1011 

1012 Args: 

1013 context: Execution context 

1014 max_steps: Maximum steps to execute 

1015 

1016 Returns: 

1017 State instance where execution stopped 

1018 """ 

1019 for _ in range(max_steps): 

1020 # Check if at breakpoint 

1021 if context.current_state in self._breakpoints: 

1022 return context.current_state_instance 

1023 

1024 # Execute step 

1025 result = self.execute_step_sync(context) 

1026 

1027 # Check for completion or error 

1028 if not result.success or result.is_complete: 

1029 return context.current_state_instance 

1030 

1031 # Check if stuck 

1032 if result.from_state == result.to_state and result.transition == "none": 

1033 return context.current_state_instance 

1034 

1035 return context.current_state_instance 

1036 

1037 def trace_execution_sync( 

1038 self, 

1039 data: dict[str, Any] | Record, 

1040 initial_state: str | None = None, 

1041 max_steps: int = 1000 

1042 ) -> list[dict[str, Any]]: 

1043 """Execute with full tracing enabled (synchronous). 

1044 

1045 Args: 

1046 data: Input data 

1047 initial_state: Optional starting state 

1048 max_steps: Maximum steps to execute 

1049 

1050 Returns: 

1051 List of trace entries 

1052 """ 

1053 self.execution_mode = ExecutionMode.TRACE 

1054 self._trace_buffer.clear() 

1055 

1056 context = self.create_context(data, initial_state=initial_state) 

1057 

1058 for _ in range(max_steps): 

1059 # Execute step (trace recording happens inside execute_step_sync) 

1060 result = self.execute_step_sync(context) 

1061 

1062 # Check termination conditions 

1063 if not result.success or result.is_complete: 

1064 break 

1065 

1066 if result.from_state == result.to_state and result.transition == "none": 

1067 break 

1068 

1069 return self._trace_buffer 

1070 

1071 def profile_execution_sync( 

1072 self, 

1073 data: dict[str, Any] | Record, 

1074 initial_state: str | None = None, 

1075 max_steps: int = 1000 

1076 ) -> dict[str, Any]: 

1077 """Execute with performance profiling (synchronous). 

1078 

1079 Args: 

1080 data: Input data 

1081 initial_state: Optional starting state 

1082 max_steps: Maximum steps to execute 

1083 

1084 Returns: 

1085 Profiling data 

1086 """ 

1087 self.execution_mode = ExecutionMode.PROFILE 

1088 self._profile_data.clear() 

1089 

1090 context = self.create_context(data, initial_state=initial_state) 

1091 

1092 start_time = time.time() 

1093 transitions = 0 

1094 state_times = {} 

1095 transition_times = [] 

1096 

1097 for _ in range(max_steps): 

1098 state_start = time.time() 

1099 current_state = context.current_state 

1100 

1101 # Execute step 

1102 result = self.execute_step_sync(context) 

1103 

1104 # Record timings 

1105 if current_state: 

1106 if current_state not in state_times: 

1107 state_times[current_state] = [] 

1108 state_times[current_state].append(time.time() - state_start) 

1109 

1110 if result.success and result.from_state != result.to_state: 

1111 transition_times.append(result.duration) 

1112 transitions += 1 

1113 

1114 # Check termination 

1115 if not result.success or result.is_complete: 

1116 break 

1117 

1118 if result.from_state == result.to_state and result.transition == "none": 

1119 break 

1120 

1121 # Calculate statistics 

1122 self._profile_data = { 

1123 'total_time': time.time() - start_time, 

1124 'transitions': transitions, 

1125 'states_visited': len(state_times), 

1126 'avg_transition_time': sum(transition_times) / len(transition_times) if transition_times else 0, 

1127 'state_times': { 

1128 state: { 

1129 'count': len(times), 

1130 'total': sum(times), 

1131 'avg': sum(times) / len(times), 

1132 'min': min(times), 

1133 'max': max(times) 

1134 } 

1135 for state, times in state_times.items() 

1136 }, 

1137 'final_state': context.current_state, 

1138 'final_data': context.get_data_snapshot() 

1139 } 

1140 

1141 return self._profile_data 

1142 

1143 

1144class FSMDebugger: 

1145 """Interactive debugger for FSM execution (fully synchronous).""" 

1146 

1147 def __init__(self, fsm: AdvancedFSM): 

1148 """Initialize debugger. 

1149 

1150 Args: 

1151 fsm: Advanced FSM instance to debug 

1152 """ 

1153 self.fsm = fsm 

1154 self.context: ExecutionContext | None = None 

1155 self.watch_vars: dict[str, Any] = {} 

1156 self.command_history: list[str] = [] 

1157 self.step_count: int = 0 

1158 self.execution_history: list[StepResult] = [] 

1159 

1160 @property 

1161 def current_state(self) -> str | None: 

1162 """Get the current state name.""" 

1163 if not self.context: 

1164 return None 

1165 return self.context.get_current_state() 

1166 

1167 @property 

1168 def watches(self) -> dict[str, Any]: 

1169 """Get current watch variable values.""" 

1170 return self.watch_vars.copy() 

1171 

1172 def start( 

1173 self, 

1174 data: dict[str, Any] | Record, 

1175 initial_state: str | None = None 

1176 ) -> None: 

1177 """Start debugging session (synchronous). 

1178 

1179 Args: 

1180 data: Initial data 

1181 initial_state: Optional starting state 

1182 """ 

1183 self.context = self.fsm.create_context(data, initial_state=initial_state) 

1184 self.step_count = 0 

1185 self.execution_history.clear() 

1186 

1187 print(f"Debugger started at state: {self.context.current_state or 'initial'}") 

1188 print(f"Data: {self.context.get_data_snapshot()}") 

1189 

1190 def step(self) -> StepResult: 

1191 """Execute single step and return detailed result. 

1192 

1193 Returns: 

1194 StepResult with transition details 

1195 """ 

1196 if not self.context: 

1197 print("No active debugging session. Call start() first.") 

1198 return StepResult( 

1199 from_state="none", 

1200 to_state="none", 

1201 transition="error", 

1202 success=False, 

1203 error="No active debugging session" 

1204 ) 

1205 

1206 result = self.fsm.execute_step_sync(self.context) 

1207 self.step_count += 1 

1208 self.execution_history.append(result) 

1209 

1210 # Print step information 

1211 if result.success: 

1212 if result.from_state == result.to_state and result.transition == "none": 

1213 print(f"Step {self.step_count}: No transition available from '{result.from_state}'") 

1214 else: 

1215 print(f"Step {self.step_count}: {result.from_state} -> {result.to_state} via '{result.transition}'") 

1216 

1217 if result.at_breakpoint: 

1218 print("*** Hit breakpoint ***") 

1219 

1220 if result.is_complete: 

1221 print("*** Reached end state ***") 

1222 else: 

1223 print(f"Step {self.step_count}: Error - {result.error}") 

1224 

1225 # Check watches 

1226 self._check_watches() 

1227 

1228 return result 

1229 

1230 def continue_to_breakpoint(self) -> StateInstance | None: 

1231 """Continue execution until a breakpoint is hit. 

1232 

1233 Returns: 

1234 State instance where execution stopped 

1235 """ 

1236 if not self.context: 

1237 print("No active debugging session") 

1238 return None 

1239 

1240 print(f"Continuing from state: {self.context.current_state}") 

1241 final_state = self.fsm.run_until_breakpoint_sync(self.context) 

1242 

1243 if final_state: 

1244 print(f"Stopped at: {self.context.current_state}") 

1245 if self.context.current_state in self.fsm._breakpoints: 

1246 print("*** At breakpoint ***") 

1247 if self.context.is_complete(): 

1248 print("*** Execution complete ***") 

1249 

1250 return final_state 

1251 

1252 def inspect(self, path: str = "") -> Any: 

1253 """Inspect data at path. 

1254 

1255 Args: 

1256 path: Dot-separated path to data field (empty for all data) 

1257 

1258 Returns: 

1259 Value at path 

1260 """ 

1261 if not self.context: 

1262 print("No active debugging session") 

1263 return None 

1264 

1265 data = self.context.data 

1266 

1267 if not path: 

1268 return data 

1269 

1270 # Navigate path 

1271 for key in path.split('.'): 

1272 if isinstance(data, dict): 

1273 data = data.get(key) 

1274 elif hasattr(data, key): 

1275 data = getattr(data, key) 

1276 else: 

1277 return None 

1278 return data 

1279 

1280 def watch(self, name: str, path: str) -> None: 

1281 """Add a watch expression. 

1282 

1283 Args: 

1284 name: Watch name 

1285 path: Data path to watch 

1286 """ 

1287 self.watch_vars[name] = path 

1288 value = self.inspect(path) 

1289 print(f"Watch '{name}' added: {path} = {value}") 

1290 

1291 def unwatch(self, name: str) -> None: 

1292 """Remove a watch expression. 

1293 

1294 Args: 

1295 name: Watch name to remove 

1296 """ 

1297 if name in self.watch_vars: 

1298 del self.watch_vars[name] 

1299 print(f"Watch '{name}' removed") 

1300 

1301 def _check_watches(self) -> None: 

1302 """Check and print changed watch values.""" 

1303 if not self.watch_vars: 

1304 return 

1305 

1306 for name, path in self.watch_vars.items(): 

1307 value = self.inspect(path) 

1308 print(f" Watch '{name}': {path} = {value}") 

1309 

1310 def print_watches(self) -> None: 

1311 """Print all watch values.""" 

1312 if not self.watch_vars: 

1313 print("No watches set") 

1314 return 

1315 

1316 for name, path in self.watch_vars.items(): 

1317 value = self.inspect(path) 

1318 print(f"{name}: {path} = {value}") 

1319 

1320 def print_state(self) -> None: 

1321 """Print current state information.""" 

1322 if not self.context: 

1323 print("No active debugging session") 

1324 return 

1325 

1326 print("\n=== State Information ===") 

1327 print(f"Current State: {self.context.current_state}") 

1328 print(f"Previous State: {self.context.previous_state}") 

1329 print(f"Is Complete: {self.context.is_complete()}") 

1330 print("\nData:") 

1331 data = self.context.get_data_snapshot() 

1332 for key, value in data.items(): 

1333 print(f" {key}: {value}") 

1334 

1335 # Print available transitions 

1336 transitions = self.fsm._get_available_transitions(self.context) 

1337 if transitions: 

1338 print("\nAvailable Transitions:") 

1339 for arc in transitions: 

1340 print(f" - {arc.name or 'unnamed'} -> {arc.target_state}") 

1341 else: 

1342 if self.context.is_complete(): 

1343 print("\nNo transitions (end state)") 

1344 else: 

1345 print("\nNo available transitions") 

1346 

1347 def inspect_current_state(self) -> dict[str, Any]: 

1348 """Get detailed information about current state. 

1349 

1350 Returns: 

1351 Dictionary with state details 

1352 """ 

1353 if not self.context: 

1354 return {"error": "No active debugging session"} 

1355 

1356 return { 

1357 'state': self.context.current_state, 

1358 'previous_state': self.context.previous_state, 

1359 'data': self.context.get_data_snapshot(), 

1360 'is_complete': self.context.is_complete(), 

1361 'step_count': self.step_count, 

1362 'at_breakpoint': self.context.current_state in self.fsm._breakpoints, 

1363 'available_transitions': [ 

1364 {'name': arc.name, 'target': arc.target_state} 

1365 for arc in self.fsm._get_available_transitions(self.context) 

1366 ] 

1367 } 

1368 

1369 def get_history(self, limit: int = 10) -> list[StepResult]: 

1370 """Get recent execution history. 

1371 

1372 Args: 

1373 limit: Maximum number of steps to return 

1374 

1375 Returns: 

1376 List of recent step results 

1377 """ 

1378 return self.execution_history[-limit:] 

1379 

1380 def reset(self, data: dict[str, Any] | Record | None = None) -> None: 

1381 """Reset debugger with new data. 

1382 

1383 Args: 

1384 data: New data (uses current data if None) 

1385 """ 

1386 if data is None and self.context: 

1387 data = self.context.data 

1388 

1389 if data is None: 

1390 print("No data available for reset") 

1391 return 

1392 

1393 self.start(data) 

1394 

1395 

1396def create_advanced_fsm( 

1397 config: str | Path | dict[str, Any] | FSM, 

1398 custom_functions: dict[str, Callable] | None = None, 

1399 **kwargs 

1400) -> AdvancedFSM: 

1401 """Factory function to create an AdvancedFSM instance. 

1402  

1403 Args: 

1404 config: Configuration, FSM instance, or path 

1405 custom_functions: Optional custom functions to register 

1406 **kwargs: Additional arguments 

1407  

1408 Returns: 

1409 Configured AdvancedFSM instance 

1410 """ 

1411 if isinstance(config, FSM): 

1412 fsm = config 

1413 else: 

1414 from ..config.builder import FSMBuilder 

1415 from ..config.loader import ConfigLoader 

1416 

1417 loader = ConfigLoader() 

1418 

1419 if isinstance(config, (str, Path)): 

1420 config_obj = loader.load_from_file(str(config)) 

1421 else: 

1422 # Load from dict 

1423 config_obj = loader.load_from_dict(config) 

1424 

1425 builder = FSMBuilder() 

1426 

1427 # Register custom functions if provided 

1428 if custom_functions: 

1429 for name, func in custom_functions.items(): 

1430 builder.register_function(name, func) 

1431 

1432 fsm = builder.build(config_obj) 

1433 

1434 return AdvancedFSM(fsm, **kwargs)