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
@@ -8,63 +8,24 @@ Manages token flow, transition firing, and asyncio task coordination.
8
8
 
9
9
  import asyncio
10
10
  from asyncio import Event, Task
11
- from typing import Any, List, Dict, Optional, Set, Tuple, Callable, Deque, Union
12
- from collections import deque
13
- from itertools import product
11
+ from typing import Any, List, Dict, Optional, Set, Tuple, Callable, Deque, Union, Type, get_type_hints
12
+ from collections import deque, OrderedDict
13
+ from itertools import product, combinations
14
14
  import inspect
15
15
  import logging
16
16
 
17
+ from mycorrhizal.common.wrappers import create_view_from_protocol
18
+ from mycorrhizal.common.compilation import (
19
+ _get_compiled_metadata,
20
+ _clear_compilation_cache,
21
+ CompiledMetadata,
22
+ )
23
+ from mycorrhizal.common.cache import InterfaceViewCache
17
24
  from .specs import NetSpec, PlaceSpec, TransitionSpec, ArcSpec, PlaceType, GuardSpec
18
25
 
19
26
  logger = logging.getLogger(__name__)
20
27
 
21
28
 
22
- # ======================================================================================
23
- # Interface Integration Helper
24
- # ======================================================================================
25
-
26
-
27
- def _create_interface_view_if_needed(bb: Any, handler: Callable) -> Any:
28
- """
29
- Create a constrained view if the handler has an interface type hint on its
30
- blackboard parameter (second parameter, typically named 'bb').
31
-
32
- This enables type-safe, constrained access to blackboard state based on
33
- interface definitions created with @blackboard_interface.
34
-
35
- Args:
36
- bb: The blackboard instance
37
- handler: The transition or IO handler function to check for interface type hints
38
-
39
- Returns:
40
- Either the original blackboard or a constrained view based on interface metadata
41
- """
42
- from typing import get_type_hints
43
-
44
- try:
45
- sig = inspect.signature(handler)
46
- params = list(sig.parameters.values())
47
-
48
- # Check second parameter (usually 'bb' at index 1)
49
- # Transition handlers have signature: handler(consumed, bb, timebase, state?)
50
- # IO input handlers have signature: handler(bb, timebase)
51
- if len(params) >= 2:
52
- # For transitions, bb is at index 1
53
- # For IO input with 2 params, bb is at index 0
54
- bb_param_index = 1 if len(params) >= 3 else 0
55
-
56
- if params[bb_param_index].name == 'bb':
57
- bb_type = get_type_hints(handler).get('bb')
58
-
59
- # If type hint exists and has interface metadata
60
- if bb_type and hasattr(bb_type, '_readonly_fields'):
61
- from mycorrhizal.common.wrappers import create_view_from_protocol
62
- return create_view_from_protocol(bb, bb_type)
63
- except Exception:
64
- # If anything goes wrong with type inspection, fall back to original bb
65
- pass
66
-
67
- return bb
68
29
  try:
69
30
  # Library should not configure root logging; be quiet by default
70
31
  logger.addHandler(logging.NullHandler())
@@ -76,12 +37,13 @@ except Exception:
76
37
 
77
38
  class PlaceRuntime:
78
39
  """Runtime execution of a place"""
79
-
80
- def __init__(self, spec: PlaceSpec, bb: Any, timebase: Any, fqn: str):
40
+
41
+ def __init__(self, spec: PlaceSpec, bb: Any, timebase: Any, fqn: str, net: Optional['NetRuntime'] = None):
81
42
  self.spec = spec
82
43
  self.fqn = fqn
83
44
  self.bb = bb
84
45
  self.timebase = timebase
46
+ self.net = net # Reference to NetRuntime for cache access
85
47
  self.state = spec.state_factory() if spec.state_factory else None
86
48
 
87
49
  # tokens can be a bag (list) or queue (deque)
@@ -106,17 +68,36 @@ class PlaceRuntime:
106
68
  self.token_added_event.set()
107
69
 
108
70
  def remove_tokens(self, tokens_to_remove: List[Any]):
109
- """Remove specific tokens from this place"""
71
+ """Remove specific tokens from this place.
72
+
73
+ Note: This operation is idempotent - if a token has already been removed
74
+ (e.g., by a competing transition), it will be safely skipped.
75
+ """
110
76
  if self.spec.place_type == PlaceType.BAG:
111
77
  for token in tokens_to_remove:
112
- self.tokens.remove(token)
78
+ try:
79
+ self.tokens.remove(token)
80
+ except ValueError:
81
+ # Token already removed by another transition (race condition)
82
+ # This is expected when multiple transitions compete
83
+ pass
113
84
  else: # QUEUE
114
- temp = deque()
115
- for token in self.tokens:
116
- if token not in tokens_to_remove:
117
- temp.append(token)
118
- self.tokens = temp
119
-
85
+ # Try to use set for O(1) lookup if tokens are hashable
86
+ try:
87
+ removed_set = set(tokens_to_remove)
88
+ temp = deque()
89
+ for token in self.tokens:
90
+ if token not in removed_set:
91
+ temp.append(token)
92
+ self.tokens = temp
93
+ except TypeError:
94
+ # Fall back to O(n) lookup for unhashable tokens (e.g., Task objects)
95
+ temp = deque()
96
+ for token in self.tokens:
97
+ if token not in tokens_to_remove:
98
+ temp.append(token)
99
+ self.tokens = temp
100
+
120
101
  self.token_removed_event.set()
121
102
  self.token_removed_event.clear()
122
103
 
@@ -133,15 +114,22 @@ class PlaceRuntime:
133
114
  return
134
115
 
135
116
  # Create interface view if handler has interface type hint
136
- bb_to_pass = _create_interface_view_if_needed(self.bb, self.spec.handler)
117
+ if self.net:
118
+ bb_to_pass = self.net._create_interface_view_if_needed(self.bb, self.spec.handler)
119
+ else:
120
+ bb_to_pass = self.bb
137
121
 
138
122
  async def io_input_loop():
139
123
  try:
140
- sig = inspect.signature(self.spec.handler)
124
+ handler = self.spec.handler
125
+ if handler is None:
126
+ return
127
+
128
+ sig = inspect.signature(handler)
141
129
  if len(sig.parameters) == 2:
142
- gen = self.spec.handler(bb_to_pass, self.timebase)
130
+ gen = handler(bb_to_pass, self.timebase)
143
131
  else:
144
- gen = self.spec.handler()
132
+ gen = handler() # type: ignore[call-arg]
145
133
 
146
134
  async for token in gen:
147
135
  self.add_token(token)
@@ -156,7 +144,10 @@ class PlaceRuntime:
156
144
  return
157
145
 
158
146
  # Create interface view if handler has interface type hint
159
- bb_to_pass = _create_interface_view_if_needed(self.bb, self.spec.handler)
147
+ if self.net:
148
+ bb_to_pass = self.net._create_interface_view_if_needed(self.bb, self.spec.handler)
149
+ else:
150
+ bb_to_pass = self.bb
160
151
 
161
152
  sig = inspect.signature(self.spec.handler)
162
153
  if len(sig.parameters) == 3:
@@ -188,6 +179,7 @@ class TransitionRuntime:
188
179
 
189
180
  self.task: Optional[Task] = None
190
181
  self._stop_event = Event()
182
+ self._spurious_wakeup_count = 0
191
183
 
192
184
  def add_input_arc(self, place_parts: Tuple[str, ...], arc: ArcSpec):
193
185
  """Register an input arc"""
@@ -197,40 +189,51 @@ class TransitionRuntime:
197
189
  """Register an output arc"""
198
190
  self.output_arcs.append(arc)
199
191
 
200
- def _generate_token_combinations(self) -> List[Tuple[Tuple[Any, ...], ...]]:
192
+ def _generate_token_combinations(self):
201
193
  """
202
194
  Generate all valid token combinations for input arcs.
203
- Returns list of tuples, where each tuple contains sub-tuples for each arc.
195
+ Returns generator of tuples, where each tuple contains sub-tuples for each arc.
196
+ Using generator instead of list for lazy evaluation - stops after guard selects first valid combination.
204
197
  """
205
198
  arc_tokens = []
206
-
199
+
207
200
  for place_parts, arc in self.input_arcs:
208
201
  place = self.net.places[place_parts]
209
202
  tokens = place.peek_tokens(arc.weight)
210
-
203
+
211
204
  if len(tokens) < arc.weight:
212
- return []
213
-
205
+ return iter([]) # Empty generator if insufficient tokens
206
+
214
207
  if arc.weight == 1:
215
208
  arc_tokens.append([(t,) for t in tokens])
216
209
  else:
217
- from itertools import combinations
218
210
  arc_tokens.append(list(combinations(tokens, arc.weight)))
219
-
211
+
220
212
  if not arc_tokens:
221
- return []
222
-
223
- all_combinations = list(product(*arc_tokens))
224
- return all_combinations
225
-
226
- async def _check_guard(self, combinations: List[Tuple[Tuple[Any, ...], ...]]) -> Optional[Tuple[Tuple[Any, ...], ...]]:
213
+ return iter([]) # Empty generator
214
+
215
+ # Return generator instead of list - lazy evaluation
216
+ return product(*arc_tokens)
217
+
218
+ async def _check_guard(self, combinations_generator) -> Optional[Tuple[Tuple[Any, ...], ...]]:
227
219
  """Check guard and return combination to consume, or None"""
220
+ # If no guard, return first combination from generator
228
221
  if not self.spec.guard:
229
- return combinations[0] if combinations else None
230
-
222
+ try:
223
+ return next(iter(combinations_generator))
224
+ except StopIteration:
225
+ return None
226
+
227
+ # Guard exists - need to build list for guard evaluation
228
+ # Guards expect to see all combinations to choose from
229
+ combinations_list = list(combinations_generator)
230
+
231
+ if not combinations_list:
232
+ return None
233
+
231
234
  guard_func = self.spec.guard.func
232
- guard_result = guard_func(combinations, self.net.bb, self.net.timebase)
233
-
235
+ guard_result = guard_func(combinations_list, self.net.bb, self.net.timebase)
236
+
234
237
  if inspect.isgenerator(guard_result):
235
238
  for result in guard_result:
236
239
  if result is not None:
@@ -239,14 +242,14 @@ class TransitionRuntime:
239
242
  async for result in guard_result:
240
243
  if result is not None:
241
244
  return result
242
-
245
+
243
246
  return None
244
247
 
245
248
  async def _fire_transition(self, consumed_combination: Tuple[Tuple[Any, ...], ...]):
246
249
  """Execute the transition with consumed tokens"""
247
250
  consumed_flat = []
248
251
  tokens_to_remove = {}
249
-
252
+
250
253
  for i, (place_parts, arc) in enumerate(self.input_arcs):
251
254
  arc_tokens = consumed_combination[i]
252
255
  consumed_flat.extend(arc_tokens)
@@ -254,12 +257,13 @@ class TransitionRuntime:
254
257
  if place_parts not in tokens_to_remove:
255
258
  tokens_to_remove[place_parts] = []
256
259
  tokens_to_remove[place_parts].extend(arc_tokens)
257
-
260
+
261
+ # Remove tokens from input places
258
262
  for place_parts, tokens in tokens_to_remove.items():
259
263
  self.net.places[place_parts].remove_tokens(tokens)
260
264
 
261
265
  # Create interface view if handler has interface type hint
262
- bb_to_pass = _create_interface_view_if_needed(self.net.bb, self.spec.handler)
266
+ bb_to_pass = self.net._create_interface_view_if_needed(self.net.bb, self.spec.handler)
263
267
 
264
268
  sig = inspect.signature(self.spec.handler)
265
269
  param_count = len(sig.parameters)
@@ -280,7 +284,7 @@ class TransitionRuntime:
280
284
  result = await results
281
285
  if result is not None:
282
286
  await self._process_yield(result)
283
-
287
+
284
288
  def _resolve_place_ref(self, place_ref) -> Optional[Tuple[str, ...]]:
285
289
  """Resolve a PlaceRef to runtime place parts.
286
290
 
@@ -427,42 +431,94 @@ class TransitionRuntime:
427
431
  """Main transition execution loop"""
428
432
  try:
429
433
  while not self._stop_event.is_set():
434
+ # Generator transitions (no inputs) fire continuously
435
+ if not self.input_arcs:
436
+ # No input places - this is a generator transition
437
+ # Fire continuously with empty consumed list
438
+ logger.debug("[trans] %s is a generator, firing continuously", self.fqn)
439
+ try:
440
+ await self._fire_transition([])
441
+ except Exception as e:
442
+ logger.exception("[%s] Generator transition error", self.fqn)
443
+
444
+ # Small delay to prevent busy-waiting
445
+ await asyncio.sleep(0.01)
446
+ continue
447
+
430
448
  wait_tasks = []
431
449
  for place_fqn, arc in self.input_arcs:
432
450
  place = self.net.places[place_fqn]
433
451
  wait_tasks.append(asyncio.create_task(place.token_added_event.wait()))
434
-
452
+
435
453
  if not wait_tasks:
436
454
  break
437
-
455
+
438
456
  stop_task = asyncio.create_task(self._stop_event.wait())
439
457
  wait_tasks.append(stop_task)
440
-
458
+
441
459
  done, pending = await asyncio.wait(
442
460
  wait_tasks,
443
461
  return_when=asyncio.FIRST_COMPLETED
444
462
  )
445
-
463
+
446
464
  for task in pending:
447
465
  task.cancel()
448
-
466
+
449
467
  if self._stop_event.is_set():
450
468
  break
451
469
  # Debug: indicate transition woke
452
470
  logger.debug("[trans] %s woke; checking inputs", self.fqn)
453
471
 
454
- for place_fqn, arc in self.input_arcs:
455
- place = self.net.places[place_fqn]
456
- place.token_added_event.clear()
457
-
458
472
  combinations = self._generate_token_combinations()
459
- logger.debug("[trans] %s combinations=%d", self.fqn, len(combinations))
460
-
461
- if combinations:
473
+
474
+ # Check if there are any combinations (for generator, peek at first element)
475
+ combinations_iter = iter(combinations)
476
+ try:
477
+ first_combo = next(combinations_iter)
478
+ has_combinations = True
479
+ # Put it back by creating a new generator with first element
480
+ # We need to materialize the first element and chain it with the rest
481
+ def chain_with_first(first, rest_iter):
482
+ yield first
483
+ yield from rest_iter
484
+ combinations = chain_with_first(first_combo, combinations_iter)
485
+ except StopIteration:
486
+ has_combinations = False
487
+
488
+ logger.debug("[trans] %s has_combinations=%s", self.fqn, has_combinations)
489
+
490
+ if has_combinations:
462
491
  to_consume = await self._check_guard(combinations)
463
492
  if to_consume:
464
493
  await self._fire_transition(to_consume)
465
-
494
+
495
+ # Check if there are still tokens to process after firing
496
+ remaining_combinations = self._generate_token_combinations()
497
+ remaining_iter = iter(remaining_combinations)
498
+ try:
499
+ next(remaining_iter)
500
+ has_remaining = True
501
+ except StopIteration:
502
+ has_remaining = False
503
+
504
+ if not has_remaining:
505
+ # No more tokens, clear the event and wait for new tokens
506
+ for place_fqn, arc in self.input_arcs:
507
+ place = self.net.places[place_fqn]
508
+ place.token_added_event.clear()
509
+ # else: there are still tokens, loop again immediately
510
+ else:
511
+ # No combinations available (event was set spuriously or tokens consumed by another transition)
512
+ # Spurious wakeup - clear events and go back to waiting
513
+ self._spurious_wakeup_count += 1
514
+ logger.debug(
515
+ "[trans] %s spurious wakeup #%d, clearing events",
516
+ self.fqn, self._spurious_wakeup_count
517
+ )
518
+ for place_fqn, arc in self.input_arcs:
519
+ place = self.net.places[place_fqn]
520
+ place.token_added_event.clear()
521
+
466
522
  except asyncio.CancelledError:
467
523
  pass
468
524
  except Exception:
@@ -478,10 +534,20 @@ class TransitionRuntime:
478
534
  except asyncio.CancelledError:
479
535
  pass
480
536
 
537
+ def get_spurious_wakeup_count(self) -> int:
538
+ """Get the number of spurious wakeups for this transition.
539
+
540
+ Useful for monitoring and debugging race conditions.
541
+
542
+ Returns:
543
+ Number of spurious wakeups since transition started
544
+ """
545
+ return self._spurious_wakeup_count
546
+
481
547
 
482
548
  class NetRuntime:
483
549
  """Runtime execution of a complete Petri net"""
484
-
550
+
485
551
  def __init__(self, spec: NetSpec, bb: Any, timebase: Any):
486
552
  self.spec = spec
487
553
  self.bb = bb
@@ -490,6 +556,8 @@ class NetRuntime:
490
556
  # Keep as Any to simplify gradual migration (place/transition runtime objects)
491
557
  self.places: Dict[Tuple[str, ...], Any] = {}
492
558
  self.transitions: Dict[Tuple[str, ...], Any] = {}
559
+ # Instance-local cache for interface views (no global state)
560
+ self._interface_cache = InterfaceViewCache(maxsize=256)
493
561
 
494
562
  self._flatten_spec(spec)
495
563
  self._build_runtime()
@@ -515,7 +583,7 @@ class NetRuntime:
515
583
  # Build all place runtimes
516
584
  for place_parts in list(self.places.keys()):
517
585
  place_spec = self._find_place_spec_by_parts(place_parts)
518
- self.places[place_parts] = PlaceRuntime(place_spec, self.bb, self.timebase, '.'.join(place_parts))
586
+ self.places[place_parts] = PlaceRuntime(place_spec, self.bb, self.timebase, '.'.join(place_parts), self)
519
587
 
520
588
  # Build all transition runtimes
521
589
  for trans_parts in list(self.transitions.keys()):
@@ -546,16 +614,77 @@ class NetRuntime:
546
614
  except Exception:
547
615
  in_count = out_count = 0
548
616
  logger.debug("[runtime] %s inputs=%d outputs=%d", tfqn, in_count, out_count)
617
+
618
+ def _create_interface_view_if_needed(self, bb: Any, handler: Callable) -> Any:
619
+ """
620
+ Create a constrained view if the handler has an interface type hint on its
621
+ blackboard parameter.
622
+
623
+ This enables type-safe, constrained access to blackboard state based on
624
+ interface definitions created with @blackboard_interface.
625
+
626
+ The function signature can use an interface type:
627
+ async def my_transition(consumed, bb: MyInterface, timebase):
628
+ # bb is automatically a constrained view
629
+ yield {output: token}
630
+
631
+ Args:
632
+ bb: The blackboard instance
633
+ handler: The transition or IO handler function to check for interface type hints
634
+
635
+ Returns:
636
+ Either the original blackboard or a constrained view based on interface metadata
637
+
638
+ Raises:
639
+ TypeError: If handler is not callable or type hints are malformed
640
+ AttributeError: If type hints reference undefined types
641
+ """
642
+ # Get compiled metadata (uses EAFP pattern internally)
643
+ # Raises specific exceptions if compilation fails
644
+ metadata = _get_compiled_metadata(handler)
645
+
646
+ # If handler has interface type hint, create constrained view
647
+ if metadata.has_interface and metadata.interface_type:
648
+ # Use instance cache for this runtime
649
+ readonly_fields = metadata.readonly_fields if metadata.readonly_fields else None
650
+
651
+ return self._interface_cache.get_or_create(
652
+ bb_id=id(bb),
653
+ interface_type=metadata.interface_type,
654
+ readonly_fields=readonly_fields,
655
+ creator_func=lambda: create_view_from_protocol(
656
+ bb,
657
+ metadata.interface_type,
658
+ readonly_fields=metadata.readonly_fields
659
+ )
660
+ )
661
+
662
+ return bb
663
+
664
+ def get_cache_stats(self) -> Dict[str, Any]:
665
+ """
666
+ Get cache statistics for monitoring and debugging.
667
+
668
+ Returns:
669
+ Dict with current cache size, maxsize, and hit/miss counts
670
+ """
671
+ return self._interface_cache.get_stats()
549
672
 
550
673
  def _find_place_spec(self, fqn: str) -> PlaceSpec:
551
674
  """Find place spec by FQN using hierarchy"""
552
675
  parts = fqn.split('.')
553
- return self._find_in_spec(self.spec, parts, is_place=True)
554
-
676
+ result = self._find_in_spec(self.spec, parts, is_place=True)
677
+ if not isinstance(result, PlaceSpec):
678
+ raise TypeError(f"Expected PlaceSpec, got {type(result)}")
679
+ return result
680
+
555
681
  def _find_transition_spec(self, fqn: str) -> TransitionSpec:
556
682
  """Find transition spec by FQN using hierarchy"""
557
683
  parts = fqn.split('.')
558
- return self._find_in_spec(self.spec, parts, is_place=False)
684
+ result = self._find_in_spec(self.spec, parts, is_place=False)
685
+ if not isinstance(result, TransitionSpec):
686
+ raise TypeError(f"Expected TransitionSpec, got {type(result)}")
687
+ return result
559
688
 
560
689
  def _find_in_spec(self, spec: NetSpec, parts: List[str], is_place: bool):
561
690
  """Recursively find place or transition in spec hierarchy"""
@@ -611,11 +740,17 @@ class NetRuntime:
611
740
 
612
741
  def _find_place_spec_by_parts(self, parts: Tuple[str, ...]) -> PlaceSpec:
613
742
  """Find a PlaceSpec using a tuple of path parts."""
614
- return self._find_in_spec(self.spec, list(parts), is_place=True)
743
+ result = self._find_in_spec(self.spec, list(parts), is_place=True)
744
+ if not isinstance(result, PlaceSpec):
745
+ raise TypeError(f"Expected PlaceSpec, got {type(result)}")
746
+ return result
615
747
 
616
748
  def _find_transition_spec_by_parts(self, parts: Tuple[str, ...]) -> TransitionSpec:
617
749
  """Find a TransitionSpec using a tuple of path parts."""
618
- return self._find_in_spec(self.spec, list(parts), is_place=False)
750
+ result = self._find_in_spec(self.spec, list(parts), is_place=False)
751
+ if not isinstance(result, TransitionSpec):
752
+ raise TypeError(f"Expected TransitionSpec, got {type(result)}")
753
+ return result
619
754
 
620
755
  async def start(self):
621
756
  """Start the Petri net execution"""