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

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

2

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

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

5"""

6

7 import time

8 from collections.abc import Callable

9 from contextlib import asynccontextmanager

10 from dataclasses import dataclass, field

11 from enum import Enum

12 from pathlib import Path

13 from typing import Any

14

15 from dataknobs_data import Record

16

17 from ..core.context_factory import ContextFactory

18 from ..core.data_modes import DataHandler, DataHandlingMode

19 from ..core.fsm import FSM

20 from ..core.modes import ProcessingMode

21 from ..core.state import StateInstance

22 from ..core.transactions import TransactionManager, TransactionStrategy

23 from ..execution.async_engine import AsyncExecutionEngine

24 from ..execution.context import ExecutionContext

25 from ..execution.engine import ExecutionEngine, TraversalStrategy

26 from ..execution.history import ExecutionHistory

27 from ..resources.base import IResourceProvider

28 from ..resources.manager import ResourceManager

29 from ..storage.base import IHistoryStorage

30

31

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

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

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

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

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

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