mycorrhizal 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. mycorrhizal/_version.py +1 -1
  2. mycorrhizal/common/__init__.py +15 -3
  3. mycorrhizal/common/cache.py +114 -0
  4. mycorrhizal/common/compilation.py +263 -0
  5. mycorrhizal/common/interface_detection.py +159 -0
  6. mycorrhizal/common/interfaces.py +3 -50
  7. mycorrhizal/common/mermaid.py +124 -0
  8. mycorrhizal/common/wrappers.py +1 -1
  9. mycorrhizal/hypha/core/builder.py +11 -1
  10. mycorrhizal/hypha/core/runtime.py +242 -107
  11. mycorrhizal/mycelium/__init__.py +174 -0
  12. mycorrhizal/mycelium/core.py +619 -0
  13. mycorrhizal/mycelium/exceptions.py +30 -0
  14. mycorrhizal/mycelium/hypha_bridge.py +1143 -0
  15. mycorrhizal/mycelium/instance.py +440 -0
  16. mycorrhizal/mycelium/pn_context.py +276 -0
  17. mycorrhizal/mycelium/runner.py +165 -0
  18. mycorrhizal/mycelium/spores_integration.py +655 -0
  19. mycorrhizal/mycelium/tree_builder.py +102 -0
  20. mycorrhizal/mycelium/tree_spec.py +197 -0
  21. mycorrhizal/rhizomorph/README.md +82 -33
  22. mycorrhizal/rhizomorph/core.py +287 -119
  23. mycorrhizal/septum/TRANSITION_REFERENCE.md +385 -0
  24. mycorrhizal/{enoki → septum}/core.py +326 -100
  25. mycorrhizal/{enoki → septum}/testing_utils.py +7 -7
  26. mycorrhizal/{enoki → septum}/util.py +44 -21
  27. mycorrhizal/spores/__init__.py +3 -3
  28. mycorrhizal/spores/core.py +149 -28
  29. mycorrhizal/spores/dsl/__init__.py +8 -8
  30. mycorrhizal/spores/dsl/hypha.py +3 -15
  31. mycorrhizal/spores/dsl/rhizomorph.py +3 -11
  32. mycorrhizal/spores/dsl/{enoki.py → septum.py} +26 -77
  33. mycorrhizal/spores/encoder/json.py +21 -12
  34. mycorrhizal/spores/extraction.py +14 -11
  35. mycorrhizal/spores/models.py +53 -20
  36. mycorrhizal-0.2.0.dist-info/METADATA +335 -0
  37. mycorrhizal-0.2.0.dist-info/RECORD +54 -0
  38. mycorrhizal-0.1.2.dist-info/METADATA +0 -198
  39. mycorrhizal-0.1.2.dist-info/RECORD +0 -39
  40. /mycorrhizal/{enoki → septum}/__init__.py +0 -0
  41. {mycorrhizal-0.1.2.dist-info → mycorrhizal-0.2.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tree instance - runtime instance of a Mycelium tree.
4
+
5
+ This module provides the TreeInstance class which manages the runtime
6
+ behavior of a tree, including FSM instances for FSM-integrated actions
7
+ and execution of BT nodes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
13
+ from pydantic import BaseModel
14
+
15
+ from ..septum.core import StateMachine, StateSpec
16
+ from ..rhizomorph.core import Status, Runner as BTRunner
17
+ from .tree_spec import MyceliumTreeSpec, FSMIntegration
18
+ from .exceptions import FSMInstantiationError
19
+
20
+ if TYPE_CHECKING:
21
+ pass
22
+
23
+
24
+ class FSMRunner:
25
+ """
26
+ Wrapper around an FSM instance for use in FSM-integrated actions.
27
+
28
+ Attributes:
29
+ fsm: The StateMachine instance
30
+ name: Action name this FSM is associated with
31
+ _initialized: Whether the FSM has been initialized
32
+ """
33
+
34
+ def __init__(self, name: str, fsm: StateMachine):
35
+ self.name = name
36
+ self.fsm = fsm
37
+ self._initialized = False
38
+
39
+ async def _ensure_initialized(self) -> None:
40
+ """Ensure the FSM is initialized (lazy initialization)."""
41
+ if not self._initialized:
42
+ await self.fsm.initialize()
43
+ self._initialized = True
44
+
45
+ @property
46
+ def current_state(self) -> Optional[StateSpec]:
47
+ """Get the current state of the FSM."""
48
+ return self.fsm.current_state
49
+
50
+ def send_message(self, message: Any) -> None:
51
+ """Send a message to the FSM."""
52
+ self.fsm.send_message(message)
53
+
54
+ async def tick(self, timeout: Optional[float] = None) -> Any:
55
+ """Tick the FSM once."""
56
+ await self._ensure_initialized()
57
+ return await self.fsm.tick(timeout=timeout)
58
+
59
+ async def run(self, timeout: Optional[float] = None) -> Any:
60
+ """Run the FSM to completion."""
61
+ await self._ensure_initialized()
62
+ return await self.fsm.run(timeout=timeout)
63
+
64
+
65
+ class FSMAccessor:
66
+ """
67
+ Provides convenient access to FSM runners.
68
+
69
+ Allows both attribute-style and dict-style access:
70
+ accessor.robot
71
+ accessor['robot']
72
+ """
73
+
74
+ def __init__(self, fsm_runners: Dict[str, "FSMRunner"]):
75
+ self._fsm_runners = fsm_runners
76
+
77
+ def __getattr__(self, name: str) -> "FSMRunner":
78
+ if name in self._fsm_runners:
79
+ return self._fsm_runners[name]
80
+ raise AttributeError(f"No FSM named '{name}'")
81
+
82
+ def __getitem__(self, name: str) -> "FSMRunner":
83
+ if name not in self._fsm_runners:
84
+ raise KeyError(f"No FSM named '{name}'")
85
+ return self._fsm_runners[name]
86
+
87
+ def __contains__(self, name: str) -> bool:
88
+ return name in self._fsm_runners
89
+
90
+ def __iter__(self):
91
+ return iter(self._fsm_runners)
92
+
93
+ def __len__(self) -> int:
94
+ return len(self._fsm_runners)
95
+
96
+
97
+ class TreeInstance:
98
+ """
99
+ Runtime instance of a Mycelium tree.
100
+
101
+ Manages FSM instances for FSM-integrated actions, executes BT nodes,
102
+ and coordinates execution.
103
+
104
+ Attributes:
105
+ spec: The tree specification
106
+ bb: The blackboard (shared state)
107
+ tb: The timebase (shared timing)
108
+ bt_runner: The underlying BT runner
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ spec: MyceliumTreeSpec,
114
+ bb: BaseModel,
115
+ tb: Any,
116
+ ):
117
+ self.spec = spec
118
+ self.bb = bb
119
+ self.tb = tb
120
+ self._fsm_runners: Dict[str, FSMRunner] = {}
121
+ self.bt_runner: Optional[BTRunner] = None
122
+
123
+ # Instantiate FSMs for FSM-integrated actions
124
+ self._instantiate_fsms()
125
+
126
+ @property
127
+ def fsms(self) -> Any:
128
+ """
129
+ Access FSM instances.
130
+
131
+ Provides attribute and dict-style access:
132
+ tree_instance.fsms.robot
133
+ tree_instance.fsms['robot']
134
+ """
135
+ return FSMAccessor(self._fsm_runners)
136
+
137
+ def _instantiate_fsms(self) -> None:
138
+ """Instantiate FSMs for all FSM-integrated actions."""
139
+ for action_name, fsm_integration in self.spec.fsm_integrations.items():
140
+ try:
141
+ # Get the initial state spec
142
+ initial = fsm_integration.initial_state
143
+
144
+ # Handle callable states
145
+ if callable(initial) and not isinstance(initial, type):
146
+ initial = initial()
147
+
148
+ # Create common data from blackboard
149
+ common_data = self.bb.model_dump() if hasattr(self.bb, 'model_dump') else dict(self.bb)
150
+
151
+ # Create FSM instance
152
+ fsm = StateMachine(initial_state=initial, common_data=common_data) # type: ignore[arg-type]
153
+
154
+ # Store FSM runner (will be initialized lazily)
155
+ runner = FSMRunner(
156
+ name=action_name,
157
+ fsm=fsm,
158
+ )
159
+ self._fsm_runners[action_name] = runner
160
+
161
+ except Exception as e:
162
+ raise FSMInstantiationError(
163
+ f"Failed to instantiate FSM for action '{action_name}': {e}"
164
+ ) from e
165
+
166
+ def get_fsm_runner(self, action_name: str) -> Optional[FSMRunner]:
167
+ """Get FSM runner for an action."""
168
+ return self._fsm_runners.get(action_name)
169
+
170
+ async def tick(self) -> Status:
171
+ """
172
+ Execute one tick of the tree.
173
+
174
+ This runs the BT tree. For FSM-integrated actions, the tree instance
175
+ is injected onto the blackboard so actions can access their FSM runners.
176
+
177
+ Returns:
178
+ Status from the BT tick
179
+ """
180
+ # Get BT namespace
181
+ tree_namespace = self.spec.bt_namespace
182
+
183
+ # Create BT runner if needed
184
+ if self.bt_runner is None:
185
+ from ..rhizomorph.core import Runner as BTRunner
186
+ if tree_namespace is None:
187
+ raise RuntimeError("BT namespace is None, cannot create BT runner")
188
+ self.bt_runner = BTRunner(tree_namespace, bb=self.bb, tb=self.tb)
189
+
190
+ # Inject tree instance onto blackboard for FSM-integrated actions
191
+ self.bb._mycelium_tree_instance = self # type: ignore[attr-defined]
192
+
193
+ try:
194
+ # Run BT tree
195
+ result = await self.bt_runner.tick()
196
+ finally:
197
+ # Clean up
198
+ if hasattr(self.bb, '_mycelium_tree_instance'):
199
+ delattr(self.bb, '_mycelium_tree_instance')
200
+
201
+ return result
202
+
203
+ def to_mermaid(self) -> str:
204
+ """
205
+ Generate a unified Mermaid diagram showing BT and FSM integrations.
206
+
207
+ Returns:
208
+ Mermaid diagram code
209
+ """
210
+ lines = []
211
+ lines.append("%% Mycelium Unified Diagram")
212
+ lines.append("%% Shows Behavior Tree (BT) and FSM integrations")
213
+ lines.append("graph TD")
214
+
215
+ # Add the BT tree structure and track node ID mappings
216
+ bt_diagram = self._generate_bt_mermaid()
217
+ node_id_to_action = {} # Map N1, N2, etc. to action names
218
+
219
+ if bt_diagram:
220
+ lines.append("")
221
+ lines.append(" %% Behavior Tree Structure")
222
+ # Extract just the node definitions and edges
223
+ for line in bt_diagram.split("\n"):
224
+ stripped = line.strip()
225
+ if stripped and not stripped.startswith("TD") and not stripped.startswith("LR"):
226
+ lines.append(f" {stripped}")
227
+
228
+ # Parse action nodes to map IDs to names
229
+ if "ACTION" in stripped and "<br/>" in stripped:
230
+ parts = stripped.split("((ACTION<br/>")
231
+ if len(parts) == 2:
232
+ node_id = parts[0].strip()
233
+ action_name = parts[1].rstrip("))").strip()
234
+ node_id_to_action[node_id] = action_name
235
+
236
+ # Add FSM integrations as subgraphs
237
+ if self.spec.fsm_integrations:
238
+ lines.append("")
239
+ lines.append(" %% FSM Integrations")
240
+
241
+ for action_name, fsm_integration in self.spec.fsm_integrations.items():
242
+ lines.append("")
243
+ lines.append(f" %% FSM for action: {action_name}")
244
+ lines.append(f" subgraph FSM_{action_name} [\"FSM: {action_name}\"]")
245
+
246
+ # Generate state diagram for this FSM
247
+ fsm_diagram = self._generate_fsm_mermaid(action_name, fsm_integration)
248
+ for line in fsm_diagram.split("\n"):
249
+ if line.strip():
250
+ lines.append(f" {line}")
251
+
252
+ lines.append(" end")
253
+
254
+ # Add connections between BT actions and their FSMs
255
+ if self.spec.fsm_integrations and node_id_to_action:
256
+ lines.append("")
257
+ lines.append(" %% BT-FSM Connections")
258
+
259
+ # Find which BT nodes have FSM integrations
260
+ action_to_node_id = {v: k for k, v in node_id_to_action.items()}
261
+
262
+ for action_name in self.spec.fsm_integrations.keys():
263
+ if action_name in action_to_node_id:
264
+ node_id = action_to_node_id[action_name]
265
+ lines.append(f" {node_id} -.->|ticks| FSM_{action_name}")
266
+
267
+ return "\n".join(lines)
268
+
269
+ def _generate_bt_mermaid(self) -> str:
270
+ """Generate BT structure from the namespace."""
271
+ try:
272
+ # Use the BT's to_mermaid if available
273
+ from ..rhizomorph.util import to_mermaid as bt_to_mermaid
274
+ bt_namespace = self.spec.bt_namespace
275
+ if bt_namespace is None:
276
+ return " Root[\"No BT namespace\"]"
277
+
278
+ diagram = bt_to_mermaid(bt_namespace)
279
+
280
+ # Remove the ```mermaid wrapper if present
281
+ if diagram.startswith("```"):
282
+ lines = diagram.split("\n")
283
+ if lines[0].startswith("```"):
284
+ diagram = "\n".join(lines[1:-1])
285
+
286
+ # Replace flowchart with graph for consistency
287
+ diagram = diagram.replace("flowchart TD", "TD").replace("flowchart LR", "LR")
288
+
289
+ return diagram.strip()
290
+ except Exception:
291
+ # Fallback: simple representation
292
+ return f" Root[\"{self.spec.name}\"]"
293
+
294
+ def _generate_fsm_mermaid(self, action_name: str, fsm_integration: FSMIntegration) -> str:
295
+ """Generate FSM state diagram using shared formatting utilities."""
296
+ from ..common.mermaid import format_state_node, format_transition
297
+
298
+ lines = []
299
+
300
+ # Get the initial state
301
+ initial = fsm_integration.initial_state
302
+ if callable(initial) and not isinstance(initial, type):
303
+ initial = initial()
304
+
305
+ # Try to discover states by following transitions
306
+ from ..septum.core import StateSpec
307
+
308
+ discovered: Dict[str, StateSpec] = {}
309
+ to_visit: List[Any] = [initial] # Can be StateSpec or callable returning StateSpec
310
+ visited: set[str] = set()
311
+
312
+ while to_visit:
313
+ state = to_visit.pop(0)
314
+
315
+ if callable(state) and not isinstance(state, type):
316
+ try:
317
+ state = state()
318
+ except Exception:
319
+ continue
320
+
321
+ if not isinstance(state, StateSpec):
322
+ continue
323
+
324
+ state_name = state.name
325
+
326
+ if state_name in visited:
327
+ continue
328
+
329
+ visited.add(state_name)
330
+ discovered[state_name] = state
331
+
332
+ # Get transitions and add target states
333
+ try:
334
+ transitions = state.get_transitions()
335
+ for transition in transitions:
336
+ target_state = None
337
+
338
+ if hasattr(transition, 'transition'): # type: ignore[attr-defined]
339
+ target_state = transition.transition # type: ignore[attr-defined]
340
+ elif hasattr(transition, 'states'): # Push
341
+ target_state = transition # type: ignore[attr-defined]
342
+ else:
343
+ continue
344
+
345
+ # Skip if no target state
346
+ if target_state is None:
347
+ continue
348
+
349
+ # Skip special transitions
350
+ class_name = target_state.__class__.__name__
351
+ if class_name in ('Again', 'Unhandled', 'Retry', 'Restart', 'Repeat', 'Pop'):
352
+ continue
353
+
354
+ # Handle Push
355
+ if class_name == 'Push':
356
+ for push_state in target_state.states: # type: ignore[attr-defined]
357
+ # Get the name to check if visited
358
+ if hasattr(push_state, 'name'):
359
+ push_name = push_state.name # type: ignore[attr-defined]
360
+ elif callable(push_state):
361
+ # Will be resolved when popped from to_visit
362
+ to_visit.append(push_state)
363
+ continue
364
+ else:
365
+ continue
366
+ if push_name not in visited:
367
+ to_visit.append(push_state)
368
+ else:
369
+ # Regular state transition - only add if it's a StateSpec or callable
370
+ if isinstance(target_state, StateSpec) or callable(target_state):
371
+ # Get the name to check if visited
372
+ if hasattr(target_state, 'name'):
373
+ target_name = target_state.name # type: ignore[attr-defined]
374
+ elif callable(target_state):
375
+ # Will be resolved when popped from to_visit
376
+ to_visit.append(target_state)
377
+ continue
378
+ else:
379
+ continue
380
+ if target_name not in visited:
381
+ to_visit.append(target_state)
382
+
383
+ except Exception:
384
+ pass
385
+
386
+ # Add states
387
+ if not discovered:
388
+ return f" {action_name}_Empty[\"No states discovered\"]"
389
+
390
+ added_states = set()
391
+
392
+ for state_name, state_spec in discovered.items():
393
+ short_name = state_name.split(".")[-1]
394
+ state_id = f"{action_name}_{short_name}"
395
+
396
+ if state_id not in added_states:
397
+ is_initial = state_spec == initial
398
+ lines.append(format_state_node(state_id, short_name, is_initial))
399
+ added_states.add(state_id)
400
+
401
+ # Get transitions from this state
402
+ try:
403
+ transitions = state_spec.get_transitions()
404
+ for transition in transitions:
405
+ target_state = None
406
+ label = ""
407
+
408
+ if hasattr(transition, 'transition'):
409
+ target_state = transition.transition # type: ignore[attr-defined]
410
+ if hasattr(transition, 'label'):
411
+ label = transition.label.name # type: ignore[attr-defined]
412
+ elif hasattr(transition, 'name'):
413
+ target_state = transition
414
+ label = "transition"
415
+
416
+ if target_state is None:
417
+ continue
418
+
419
+ class_name = target_state.__class__.__name__
420
+ if class_name in ('Again', 'Unhandled', 'Retry', 'Restart', 'Repeat'):
421
+ continue
422
+ if class_name == 'Pop':
423
+ continue
424
+
425
+ if class_name == 'Push':
426
+ for push_state in target_state.states: # type: ignore[attr-defined]
427
+ push_name = push_state.name if hasattr(push_state, 'name') else push_state.__name__
428
+ target_short = push_name.split(".")[-1]
429
+ target_id = f"{action_name}_{target_short}"
430
+ lines.append(format_transition(state_id, target_id, "Push"))
431
+ else:
432
+ target_name = target_state.name if hasattr(target_state, 'name') else str(target_state) # type: ignore[attr-defined]
433
+ target_short = target_name.split(".")[-1]
434
+ target_id = f"{action_name}_{target_short}"
435
+ lines.append(format_transition(state_id, target_id, label))
436
+
437
+ except Exception:
438
+ pass
439
+
440
+ return "\n".join(lines)