mycorrhizal 0.1.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 (37) hide show
  1. mycorrhizal/__init__.py +3 -0
  2. mycorrhizal/common/__init__.py +68 -0
  3. mycorrhizal/common/interface_builder.py +203 -0
  4. mycorrhizal/common/interfaces.py +412 -0
  5. mycorrhizal/common/timebase.py +99 -0
  6. mycorrhizal/common/wrappers.py +532 -0
  7. mycorrhizal/enoki/__init__.py +0 -0
  8. mycorrhizal/enoki/core.py +1545 -0
  9. mycorrhizal/enoki/testing_utils.py +529 -0
  10. mycorrhizal/enoki/util.py +220 -0
  11. mycorrhizal/hypha/__init__.py +0 -0
  12. mycorrhizal/hypha/core/__init__.py +107 -0
  13. mycorrhizal/hypha/core/builder.py +404 -0
  14. mycorrhizal/hypha/core/runtime.py +890 -0
  15. mycorrhizal/hypha/core/specs.py +234 -0
  16. mycorrhizal/hypha/util.py +38 -0
  17. mycorrhizal/rhizomorph/README.md +220 -0
  18. mycorrhizal/rhizomorph/__init__.py +0 -0
  19. mycorrhizal/rhizomorph/core.py +1729 -0
  20. mycorrhizal/rhizomorph/util.py +45 -0
  21. mycorrhizal/spores/__init__.py +124 -0
  22. mycorrhizal/spores/cache.py +208 -0
  23. mycorrhizal/spores/core.py +419 -0
  24. mycorrhizal/spores/dsl/__init__.py +48 -0
  25. mycorrhizal/spores/dsl/enoki.py +514 -0
  26. mycorrhizal/spores/dsl/hypha.py +399 -0
  27. mycorrhizal/spores/dsl/rhizomorph.py +351 -0
  28. mycorrhizal/spores/encoder/__init__.py +11 -0
  29. mycorrhizal/spores/encoder/base.py +42 -0
  30. mycorrhizal/spores/encoder/json.py +159 -0
  31. mycorrhizal/spores/extraction.py +484 -0
  32. mycorrhizal/spores/models.py +288 -0
  33. mycorrhizal/spores/transport/__init__.py +10 -0
  34. mycorrhizal/spores/transport/base.py +46 -0
  35. mycorrhizal-0.1.0.dist-info/METADATA +198 -0
  36. mycorrhizal-0.1.0.dist-info/RECORD +37 -0
  37. mycorrhizal-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,890 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hypha DSL - Runtime Layer
4
+
5
+ Runtime objects that execute the Petri net specification.
6
+ Manages token flow, transition firing, and asyncio task coordination.
7
+ """
8
+
9
+ import asyncio
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
14
+ import inspect
15
+ import logging
16
+
17
+ from .specs import NetSpec, PlaceSpec, TransitionSpec, ArcSpec, PlaceType, GuardSpec
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
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
+ try:
69
+ # Library should not configure root logging; be quiet by default
70
+ logger.addHandler(logging.NullHandler())
71
+ except Exception:
72
+ # NullHandler may not be available in very old Pythons; ignore if so
73
+ pass
74
+ # Library does not configure handlers by default. Callers may configure logging.
75
+
76
+
77
+ class PlaceRuntime:
78
+ """Runtime execution of a place"""
79
+
80
+ def __init__(self, spec: PlaceSpec, bb: Any, timebase: Any, fqn: str):
81
+ self.spec = spec
82
+ self.fqn = fqn
83
+ self.bb = bb
84
+ self.timebase = timebase
85
+ self.state = spec.state_factory() if spec.state_factory else None
86
+
87
+ # tokens can be a bag (list) or queue (deque)
88
+ self.tokens: Union[List[Any], Deque[Any]]
89
+ if spec.place_type == PlaceType.BAG:
90
+ self.tokens = []
91
+ else: # QUEUE
92
+ self.tokens = deque()
93
+
94
+ self.token_added_event = Event()
95
+ self.token_removed_event = Event()
96
+
97
+ self.io_task: Optional[Task] = None
98
+
99
+ def add_token(self, token: Any):
100
+ """Add a token to this place"""
101
+ if self.spec.place_type == PlaceType.BAG:
102
+ self.tokens.append(token)
103
+ else: # QUEUE
104
+ self.tokens.append(token)
105
+
106
+ self.token_added_event.set()
107
+
108
+ def remove_tokens(self, tokens_to_remove: List[Any]):
109
+ """Remove specific tokens from this place"""
110
+ if self.spec.place_type == PlaceType.BAG:
111
+ for token in tokens_to_remove:
112
+ self.tokens.remove(token)
113
+ 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
+
120
+ self.token_removed_event.set()
121
+ self.token_removed_event.clear()
122
+
123
+ def peek_tokens(self, count: int) -> List[Any]:
124
+ """Peek at tokens without removing them"""
125
+ if len(self.tokens) < count:
126
+ return []
127
+ # Normalize to a list slice for both BAG and QUEUE
128
+ return list(self.tokens)[:count]
129
+
130
+ async def start_io_input(self):
131
+ """Start IOInputPlace generator task"""
132
+ if not self.spec.is_io_input or not self.spec.handler:
133
+ return
134
+
135
+ # Create interface view if handler has interface type hint
136
+ bb_to_pass = _create_interface_view_if_needed(self.bb, self.spec.handler)
137
+
138
+ async def io_input_loop():
139
+ try:
140
+ sig = inspect.signature(self.spec.handler)
141
+ if len(sig.parameters) == 2:
142
+ gen = self.spec.handler(bb_to_pass, self.timebase)
143
+ else:
144
+ gen = self.spec.handler()
145
+
146
+ async for token in gen:
147
+ self.add_token(token)
148
+ except asyncio.CancelledError:
149
+ pass
150
+
151
+ self.io_task = asyncio.create_task(io_input_loop())
152
+
153
+ async def handle_io_output(self, token: Any):
154
+ """Handle IOOutputPlace token"""
155
+ if not self.spec.is_io_output or not self.spec.handler:
156
+ return
157
+
158
+ # Create interface view if handler has interface type hint
159
+ bb_to_pass = _create_interface_view_if_needed(self.bb, self.spec.handler)
160
+
161
+ sig = inspect.signature(self.spec.handler)
162
+ if len(sig.parameters) == 3:
163
+ await self.spec.handler(token, bb_to_pass, self.timebase)
164
+ else:
165
+ await self.spec.handler(token)
166
+
167
+ async def cancel(self):
168
+ """Cancel any running IO tasks"""
169
+ if self.io_task:
170
+ self.io_task.cancel()
171
+ try:
172
+ await self.io_task
173
+ except asyncio.CancelledError:
174
+ pass
175
+
176
+
177
+ class TransitionRuntime:
178
+ """Runtime execution of a transition"""
179
+
180
+ def __init__(self, spec: TransitionSpec, net: 'NetRuntime', fqn: str):
181
+ self.spec = spec
182
+ self.fqn = fqn
183
+ self.net = net
184
+ self.state = spec.state_factory() if spec.state_factory else None
185
+ # input_arcs now use canonical parts tuples as keys
186
+ self.input_arcs: List[Tuple[Tuple[str, ...], ArcSpec]] = []
187
+ self.output_arcs: List[ArcSpec] = []
188
+
189
+ self.task: Optional[Task] = None
190
+ self._stop_event = Event()
191
+
192
+ def add_input_arc(self, place_parts: Tuple[str, ...], arc: ArcSpec):
193
+ """Register an input arc"""
194
+ self.input_arcs.append((place_parts, arc))
195
+
196
+ def add_output_arc(self, arc: ArcSpec):
197
+ """Register an output arc"""
198
+ self.output_arcs.append(arc)
199
+
200
+ def _generate_token_combinations(self) -> List[Tuple[Tuple[Any, ...], ...]]:
201
+ """
202
+ Generate all valid token combinations for input arcs.
203
+ Returns list of tuples, where each tuple contains sub-tuples for each arc.
204
+ """
205
+ arc_tokens = []
206
+
207
+ for place_parts, arc in self.input_arcs:
208
+ place = self.net.places[place_parts]
209
+ tokens = place.peek_tokens(arc.weight)
210
+
211
+ if len(tokens) < arc.weight:
212
+ return []
213
+
214
+ if arc.weight == 1:
215
+ arc_tokens.append([(t,) for t in tokens])
216
+ else:
217
+ from itertools import combinations
218
+ arc_tokens.append(list(combinations(tokens, arc.weight)))
219
+
220
+ 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, ...], ...]]:
227
+ """Check guard and return combination to consume, or None"""
228
+ if not self.spec.guard:
229
+ return combinations[0] if combinations else None
230
+
231
+ guard_func = self.spec.guard.func
232
+ guard_result = guard_func(combinations, self.net.bb, self.net.timebase)
233
+
234
+ if inspect.isgenerator(guard_result):
235
+ for result in guard_result:
236
+ if result is not None:
237
+ return result
238
+ elif inspect.isasyncgen(guard_result):
239
+ async for result in guard_result:
240
+ if result is not None:
241
+ return result
242
+
243
+ return None
244
+
245
+ async def _fire_transition(self, consumed_combination: Tuple[Tuple[Any, ...], ...]):
246
+ """Execute the transition with consumed tokens"""
247
+ consumed_flat = []
248
+ tokens_to_remove = {}
249
+
250
+ for i, (place_parts, arc) in enumerate(self.input_arcs):
251
+ arc_tokens = consumed_combination[i]
252
+ consumed_flat.extend(arc_tokens)
253
+
254
+ if place_parts not in tokens_to_remove:
255
+ tokens_to_remove[place_parts] = []
256
+ tokens_to_remove[place_parts].extend(arc_tokens)
257
+
258
+ for place_parts, tokens in tokens_to_remove.items():
259
+ self.net.places[place_parts].remove_tokens(tokens)
260
+
261
+ # Create interface view if handler has interface type hint
262
+ bb_to_pass = _create_interface_view_if_needed(self.net.bb, self.spec.handler)
263
+
264
+ sig = inspect.signature(self.spec.handler)
265
+ param_count = len(sig.parameters)
266
+
267
+ if self.state is not None:
268
+ logger.debug("[fire] %s consumed=%s", self.fqn, consumed_flat)
269
+ results = self.spec.handler(consumed_flat, bb_to_pass, self.net.timebase, self.state)
270
+ else:
271
+ logger.debug("[fire] %s consumed=%s", self.fqn, consumed_flat)
272
+ results = self.spec.handler(consumed_flat, bb_to_pass, self.net.timebase)
273
+ logger.debug("[fire] results_type=%s isasyncgen=%s iscoroutine=%s",
274
+ type(results), inspect.isasyncgen(results), inspect.iscoroutine(results))
275
+
276
+ if inspect.isasyncgen(results):
277
+ async for yielded in results:
278
+ await self._process_yield(yielded)
279
+ elif inspect.iscoroutine(results):
280
+ result = await results
281
+ if result is not None:
282
+ await self._process_yield(result)
283
+
284
+ def _resolve_place_ref(self, place_ref) -> Optional[Tuple[str, ...]]:
285
+ """Resolve a PlaceRef to runtime place parts.
286
+
287
+ Attempts multiple resolution strategies in order:
288
+ 1. Exact match using PlaceRef.get_parts()
289
+ 2. Parent-relative mapping (for subnet instances)
290
+ 3. Suffix fallback (match by local name)
291
+
292
+ Returns:
293
+ Tuple of place parts if found, None otherwise
294
+ """
295
+ # Try to get parts from the PlaceRef API
296
+ try:
297
+ parts = tuple(place_ref.get_parts())
298
+ except Exception:
299
+ parts = None
300
+
301
+ logger.debug("[resolve] place_ref=%r parts=%s", place_ref, parts)
302
+
303
+ if parts:
304
+ key = tuple(parts)
305
+ if key in self.net.places:
306
+ logger.debug("[resolve] exact match parts=%s", parts)
307
+ return key
308
+
309
+ # Map relative to this transition's parent parts
310
+ trans_parts = tuple(self.fqn.split('.'))
311
+ if len(trans_parts) > 1:
312
+ parent_prefix = trans_parts[:-1]
313
+ candidate = tuple(list(parent_prefix) + [place_ref.local_name])
314
+ if candidate in self.net.places:
315
+ logger.debug("[resolve] mapped %s -> %s using parent_prefix", parts, candidate)
316
+ return candidate
317
+
318
+ # Fallback: find any place whose last segment matches local_name
319
+ for p in self.net.places.keys():
320
+ if isinstance(p, tuple):
321
+ segs = list(p)
322
+ else:
323
+ segs = p.split('.')
324
+ if segs and segs[-1] == place_ref.local_name:
325
+ logger.debug("[resolve] suffix match %s -> %s", place_ref.local_name, p)
326
+ return tuple(segs)
327
+
328
+ return None
329
+
330
+ def _normalize_to_parts(self, key) -> Tuple[str, ...]:
331
+ """Normalize a place key to tuple of parts for runtime lookup.
332
+
333
+ Handles various input formats:
334
+ - tuple: returned as-is
335
+ - list: converted to tuple
336
+ - str: split on '.' and converted to tuple
337
+ - other: stringified and split on '.'
338
+ """
339
+ if isinstance(key, tuple):
340
+ return key
341
+ if isinstance(key, list):
342
+ return tuple(key)
343
+ if isinstance(key, str):
344
+ return tuple(key.split('.'))
345
+ # unknown type; try to stringify
346
+ return tuple(str(key).split('.'))
347
+
348
+ async def _add_token_to_place(self, place_key: Tuple[str, ...], token: Any):
349
+ """Add a token to the specified place, handling IO output places.
350
+
351
+ Args:
352
+ place_key: Tuple of parts identifying the place
353
+ token: Token to add
354
+ """
355
+ if place_key in self.net.places:
356
+ place = self.net.places[place_key]
357
+ if place.spec.is_io_output:
358
+ logger.debug("[process_yield] calling io_output on %s token=%r", place_key, token)
359
+ await place.handle_io_output(token)
360
+ else:
361
+ logger.debug("[process_yield] adding token to %s token=%r", place_key, token)
362
+ place.add_token(token)
363
+
364
+ async def _process_yield(self, yielded):
365
+ """Process yielded output from transition"""
366
+ if isinstance(yielded, dict):
367
+ await self._process_dict_yield(yielded)
368
+ else:
369
+ await self._process_single_yield(yielded)
370
+
371
+ async def _process_dict_yield(self, yielded: dict):
372
+ """Process dictionary with place keys and token values.
373
+
374
+ Supports:
375
+ - Explicit place -> token mappings
376
+ - Wildcard ('*') token that goes to all output places
377
+ """
378
+ wildcard_token = yielded.get('*')
379
+ explicit_targets = set()
380
+
381
+ for key, token in yielded.items():
382
+ if key == '*':
383
+ continue
384
+
385
+ place_parts = self._resolve_place_ref(key)
386
+ if place_parts is None:
387
+ continue
388
+
389
+ key_normalized = self._normalize_to_parts(place_parts)
390
+ explicit_targets.add(key_normalized)
391
+ await self._add_token_to_place(key_normalized, token)
392
+
393
+ if wildcard_token is not None:
394
+ await self._expand_wildcard_to_outputs(wildcard_token, explicit_targets)
395
+
396
+ async def _expand_wildcard_to_outputs(self, wildcard_token: Any, explicit_targets: set):
397
+ """Expand a wildcard token to all output places not explicitly targeted.
398
+
399
+ Args:
400
+ wildcard_token: Token to distribute to all output places
401
+ explicit_targets: Set of place keys already explicitly targeted
402
+ """
403
+ for arc in self.output_arcs:
404
+ # normalize target to parts
405
+ try:
406
+ target_parts = tuple(arc.target.get_parts())
407
+ target_key = target_parts
408
+ except Exception:
409
+ # fallback if target is a string
410
+ target_key = self._normalize_to_parts(arc.target)
411
+
412
+ if target_key not in explicit_targets:
413
+ await self._add_token_to_place(target_key, wildcard_token)
414
+
415
+ async def _process_single_yield(self, yielded):
416
+ """Process a single (place_ref, token) tuple yield."""
417
+ place_ref, token = yielded
418
+ place_parts = self._resolve_place_ref(place_ref)
419
+
420
+ if place_parts is None:
421
+ return
422
+
423
+ key = self._normalize_to_parts(place_parts)
424
+ await self._add_token_to_place(key, token)
425
+
426
+ async def run(self):
427
+ """Main transition execution loop"""
428
+ try:
429
+ while not self._stop_event.is_set():
430
+ wait_tasks = []
431
+ for place_fqn, arc in self.input_arcs:
432
+ place = self.net.places[place_fqn]
433
+ wait_tasks.append(asyncio.create_task(place.token_added_event.wait()))
434
+
435
+ if not wait_tasks:
436
+ break
437
+
438
+ stop_task = asyncio.create_task(self._stop_event.wait())
439
+ wait_tasks.append(stop_task)
440
+
441
+ done, pending = await asyncio.wait(
442
+ wait_tasks,
443
+ return_when=asyncio.FIRST_COMPLETED
444
+ )
445
+
446
+ for task in pending:
447
+ task.cancel()
448
+
449
+ if self._stop_event.is_set():
450
+ break
451
+ # Debug: indicate transition woke
452
+ logger.debug("[trans] %s woke; checking inputs", self.fqn)
453
+
454
+ for place_fqn, arc in self.input_arcs:
455
+ place = self.net.places[place_fqn]
456
+ place.token_added_event.clear()
457
+
458
+ combinations = self._generate_token_combinations()
459
+ logger.debug("[trans] %s combinations=%d", self.fqn, len(combinations))
460
+
461
+ if combinations:
462
+ to_consume = await self._check_guard(combinations)
463
+ if to_consume:
464
+ await self._fire_transition(to_consume)
465
+
466
+ except asyncio.CancelledError:
467
+ pass
468
+ except Exception:
469
+ logger.exception("[%s] Transition error", self.fqn)
470
+
471
+ async def cancel(self):
472
+ """Cancel this transition's task"""
473
+ self._stop_event.set()
474
+ if self.task:
475
+ self.task.cancel()
476
+ try:
477
+ await self.task
478
+ except asyncio.CancelledError:
479
+ pass
480
+
481
+
482
+ class NetRuntime:
483
+ """Runtime execution of a complete Petri net"""
484
+
485
+ def __init__(self, spec: NetSpec, bb: Any, timebase: Any):
486
+ self.spec = spec
487
+ self.bb = bb
488
+ self.timebase = timebase
489
+ # Use tuple-of-parts as canonical keys internally
490
+ # Keep as Any to simplify gradual migration (place/transition runtime objects)
491
+ self.places: Dict[Tuple[str, ...], Any] = {}
492
+ self.transitions: Dict[Tuple[str, ...], Any] = {}
493
+
494
+ self._flatten_spec(spec)
495
+ self._build_runtime()
496
+
497
+ def _flatten_spec(self, spec: NetSpec):
498
+ """Flatten nested subnet specs into flat dictionaries by computing FQNs"""
499
+ # Register all places with their computed FQNs
500
+ for place_name, place_spec in spec.places.items():
501
+ place_parts = tuple(spec.get_parts(place_name))
502
+ self.places[place_parts] = None
503
+
504
+ # Register all transitions with their computed FQNs
505
+ for trans_name, trans_spec in spec.transitions.items():
506
+ trans_parts = tuple(spec.get_parts(trans_name))
507
+ self.transitions[trans_parts] = None
508
+
509
+ # Recursively flatten subnets
510
+ for subnet_name, subnet_spec in spec.subnets.items():
511
+ self._flatten_spec(subnet_spec)
512
+
513
+ def _build_runtime(self):
514
+ """Build runtime objects from flattened spec"""
515
+ # Build all place runtimes
516
+ for place_parts in list(self.places.keys()):
517
+ 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))
519
+
520
+ # Build all transition runtimes
521
+ for trans_parts in list(self.transitions.keys()):
522
+ trans_spec = self._find_transition_spec_by_parts(trans_parts)
523
+ self.transitions[trans_parts] = TransitionRuntime(trans_spec, self, '.'.join(trans_parts))
524
+
525
+ # Connect arcs by resolving references
526
+ for arc in self._collect_all_arcs():
527
+ # Use canonical tuple parts computed by ArcSpec
528
+ source_parts = tuple(arc.source_parts)
529
+ target_parts = tuple(arc.target_parts)
530
+
531
+ if target_parts in self.transitions:
532
+ trans = self.transitions[target_parts]
533
+ trans.add_input_arc(source_parts, arc)
534
+
535
+ if source_parts in self.transitions:
536
+ trans = self.transitions[source_parts]
537
+ trans.add_output_arc(arc)
538
+
539
+ # DEBUG: show place keys and transition connectivity
540
+ logger.debug("[runtime] places=%s", list(self.places.keys()))
541
+ # DEBUG: show transition connectivity
542
+ for tfqn, trans in self.transitions.items():
543
+ try:
544
+ in_count = len(trans.input_arcs)
545
+ out_count = len(trans.output_arcs)
546
+ except Exception:
547
+ in_count = out_count = 0
548
+ logger.debug("[runtime] %s inputs=%d outputs=%d", tfqn, in_count, out_count)
549
+
550
+ def _find_place_spec(self, fqn: str) -> PlaceSpec:
551
+ """Find place spec by FQN using hierarchy"""
552
+ parts = fqn.split('.')
553
+ return self._find_in_spec(self.spec, parts, is_place=True)
554
+
555
+ def _find_transition_spec(self, fqn: str) -> TransitionSpec:
556
+ """Find transition spec by FQN using hierarchy"""
557
+ parts = fqn.split('.')
558
+ return self._find_in_spec(self.spec, parts, is_place=False)
559
+
560
+ def _find_in_spec(self, spec: NetSpec, parts: List[str], is_place: bool):
561
+ """Recursively find place or transition in spec hierarchy"""
562
+ # Remove the root name if it matches
563
+ if parts[0] == spec.name:
564
+ parts = parts[1:]
565
+
566
+ if len(parts) == 1:
567
+ # Leaf - look in places or transitions
568
+ name = parts[0]
569
+ if is_place:
570
+ if name in spec.places:
571
+ return spec.places[name]
572
+ else:
573
+ if name in spec.transitions:
574
+ return spec.transitions[name]
575
+ raise KeyError(f"{'Place' if is_place else 'Transition'} {'.'.join([spec.name] + parts)} not found")
576
+
577
+ # Navigate into subnet
578
+ subnet_name = parts[0]
579
+ if subnet_name in spec.subnets:
580
+ return self._find_in_spec(spec.subnets[subnet_name], parts[1:], is_place)
581
+
582
+ raise KeyError(f"Subnet {subnet_name} not found in {spec.name}")
583
+
584
+ def _to_parts(self, key: Any) -> Tuple[str, ...]:
585
+ """Normalize a dotted string, parts list/tuple, or ref into tuple parts."""
586
+ if isinstance(key, tuple):
587
+ return key
588
+ if isinstance(key, list):
589
+ return tuple(key)
590
+ if hasattr(key, 'get_parts'):
591
+ try:
592
+ return tuple(key.get_parts())
593
+ except Exception:
594
+ pass
595
+ if isinstance(key, str):
596
+ return tuple(key.split('.'))
597
+ # fallback
598
+ return tuple(str(key).split('.'))
599
+
600
+ def _collect_all_arcs(self) -> List[ArcSpec]:
601
+ """Collect all arcs from spec and subnets recursively"""
602
+ arcs = []
603
+ self._collect_arcs_from_spec(self.spec, arcs)
604
+ return arcs
605
+
606
+ def _collect_arcs_from_spec(self, spec: NetSpec, arcs: List[ArcSpec]):
607
+ """Recursively collect arcs from spec"""
608
+ arcs.extend(spec.arcs)
609
+ for subnet_spec in spec.subnets.values():
610
+ self._collect_arcs_from_spec(subnet_spec, arcs)
611
+
612
+ def _find_place_spec_by_parts(self, parts: Tuple[str, ...]) -> PlaceSpec:
613
+ """Find a PlaceSpec using a tuple of path parts."""
614
+ return self._find_in_spec(self.spec, list(parts), is_place=True)
615
+
616
+ def _find_transition_spec_by_parts(self, parts: Tuple[str, ...]) -> TransitionSpec:
617
+ """Find a TransitionSpec using a tuple of path parts."""
618
+ return self._find_in_spec(self.spec, list(parts), is_place=False)
619
+
620
+ async def start(self):
621
+ """Start the Petri net execution"""
622
+ for place in self.places.values():
623
+ if place.spec.is_io_input:
624
+ await place.start_io_input()
625
+
626
+ for trans in self.transitions.values():
627
+ trans.task = asyncio.create_task(trans.run())
628
+
629
+ async def stop(self, timeout: float = 5.0):
630
+ """Stop the Petri net execution"""
631
+ for trans in self.transitions.values():
632
+ await trans.cancel()
633
+
634
+ for place in self.places.values():
635
+ await place.cancel()
636
+
637
+ def add_place(self, fqn: str, place_type: PlaceType = PlaceType.BAG,
638
+ state_factory: Optional[Callable] = None) -> PlaceRuntime:
639
+ """Dynamically add a place to the running net"""
640
+ parts = self._to_parts(fqn)
641
+ if parts in self.places:
642
+ raise ValueError(f"Place {fqn} already exists")
643
+
644
+ place_spec = PlaceSpec(fqn, place_type, state_factory=state_factory)
645
+ place_runtime = PlaceRuntime(place_spec, self.bb, self.timebase, fqn)
646
+ self.places[parts] = place_runtime
647
+
648
+ self._add_to_spec(place_spec)
649
+
650
+ return place_runtime
651
+
652
+ def add_transition(self, fqn: str, handler: Callable,
653
+ guard: Optional[GuardSpec] = None,
654
+ state_factory: Optional[Callable] = None) -> TransitionRuntime:
655
+ """Dynamically add a transition to the running net"""
656
+ parts = self._to_parts(fqn)
657
+ if parts in self.transitions:
658
+ raise ValueError(f"Transition {fqn} already exists")
659
+
660
+ trans_spec = TransitionSpec(fqn, handler, guard, state_factory)
661
+ trans_runtime = TransitionRuntime(trans_spec, self, fqn)
662
+ self.transitions[parts] = trans_runtime
663
+
664
+ trans_runtime.task = asyncio.create_task(trans_runtime.run())
665
+
666
+ self._add_to_spec(trans_spec)
667
+
668
+ return trans_runtime
669
+
670
+ def add_arc(self, source_fqn: str, target_fqn: str, weight: int = 1,
671
+ name: Optional[str] = None):
672
+ """Dynamically add an arc to the running net"""
673
+ if source_fqn not in self.places and source_fqn not in self.transitions:
674
+ raise ValueError(f"Source {source_fqn} does not exist")
675
+ if target_fqn not in self.places and target_fqn not in self.transitions:
676
+ raise ValueError(f"Target {target_fqn} does not exist")
677
+
678
+ arc_name = name or f"{source_fqn}->{target_fqn}"
679
+ arc_spec = ArcSpec(source_fqn, target_fqn, weight, arc_name)
680
+
681
+ if target_fqn in self.transitions:
682
+ trans = self.transitions[target_fqn]
683
+ trans.add_input_arc(source_fqn, arc_spec)
684
+
685
+ if source_fqn in self.transitions:
686
+ trans = self.transitions[source_fqn]
687
+ trans.add_output_arc(arc_spec)
688
+
689
+ self._add_arc_to_spec(arc_spec)
690
+
691
+ async def remove_arc(self, source_fqn: str, target_fqn: str):
692
+ """Dynamically remove an arc from the running net"""
693
+ arc_to_remove = None
694
+
695
+ for arc in self._collect_all_arcs():
696
+ if arc.source == source_fqn and arc.target == target_fqn:
697
+ arc_to_remove = arc
698
+ break
699
+
700
+ if not arc_to_remove:
701
+ raise ValueError(f"Arc from {source_fqn} to {target_fqn} does not exist")
702
+
703
+ if target_fqn in self.transitions:
704
+ trans = self.transitions[target_fqn]
705
+
706
+ await trans.cancel()
707
+
708
+ trans.input_arcs = [(p, a) for p, a in trans.input_arcs
709
+ if not (a.source == source_fqn and a.target == target_fqn)]
710
+
711
+ if trans.input_arcs:
712
+ trans._stop_event.clear()
713
+ trans.task = asyncio.create_task(trans.run())
714
+
715
+ if source_fqn in self.transitions:
716
+ trans = self.transitions[source_fqn]
717
+ trans.output_arcs = [a for a in trans.output_arcs
718
+ if not (a.source == source_fqn and a.target == target_fqn)]
719
+
720
+ self._remove_arc_from_spec(arc_to_remove)
721
+
722
+ async def remove(self, fqn: str):
723
+ """Remove a place or transition by fully qualified name"""
724
+ if fqn in self.places:
725
+ place = self.places[fqn]
726
+ await place.cancel()
727
+
728
+ arcs_to_remove = [arc for arc in self._collect_all_arcs()
729
+ if arc.source == fqn or arc.target == fqn]
730
+
731
+ affected_transitions = set()
732
+
733
+ for arc in arcs_to_remove:
734
+ if arc.target in self.transitions:
735
+ trans = self.transitions[arc.target]
736
+ affected_transitions.add(trans)
737
+ trans.input_arcs = [(p, a) for p, a in trans.input_arcs if a != arc]
738
+
739
+ if arc.source in self.transitions:
740
+ trans = self.transitions[arc.source]
741
+ trans.output_arcs = [a for a in trans.output_arcs if a != arc]
742
+
743
+ self._remove_arc_from_spec(arc)
744
+
745
+ for trans in affected_transitions:
746
+ await trans.cancel()
747
+ if trans.input_arcs:
748
+ trans._stop_event.clear()
749
+ trans.task = asyncio.create_task(trans.run())
750
+
751
+ del self.places[fqn]
752
+ self._remove_from_spec(fqn, is_place=True)
753
+
754
+ elif fqn in self.transitions:
755
+ trans = self.transitions[fqn]
756
+ await trans.cancel()
757
+
758
+ arcs_to_remove = [arc for arc in self._collect_all_arcs()
759
+ if arc.source == fqn or arc.target == fqn]
760
+
761
+ for arc in arcs_to_remove:
762
+ self._remove_arc_from_spec(arc)
763
+
764
+ del self.transitions[fqn]
765
+ self._remove_from_spec(fqn, is_place=False)
766
+
767
+ else:
768
+ raise ValueError(f"{fqn} not found in places or transitions")
769
+
770
+ def _add_to_spec(self, spec):
771
+ """Add place or transition spec to appropriate location"""
772
+ if isinstance(spec, PlaceSpec):
773
+ self._add_to_nested_spec(spec.name, spec, is_place=True)
774
+ elif isinstance(spec, TransitionSpec):
775
+ self._add_to_nested_spec(spec.name, spec, is_place=False)
776
+
777
+ def _add_to_nested_spec(self, fqn: str, spec, is_place: bool):
778
+ """Add spec to correct nested location based on FQN"""
779
+ parts = fqn.split('.')
780
+ current_spec = self.spec
781
+
782
+ for i, part in enumerate(parts[1:-1], start=1):
783
+ subnet_fqn = '.'.join(parts[:i+1])
784
+ if subnet_fqn not in current_spec.subnets:
785
+ return
786
+ current_spec = current_spec.subnets[subnet_fqn]
787
+
788
+ if is_place:
789
+ current_spec.places[fqn] = spec
790
+ else:
791
+ current_spec.transitions[fqn] = spec
792
+
793
+ def _add_arc_to_spec(self, arc_spec: ArcSpec):
794
+ """Add arc to appropriate spec level"""
795
+ # arc_spec.source may be a ref or string; use precomputed parts
796
+ parts = list(arc_spec.source_parts)
797
+ current_spec = self.spec
798
+
799
+ for i, part in enumerate(parts[1:-1], start=1):
800
+ subnet_fqn = '.'.join(parts[:i+1])
801
+ if subnet_fqn in current_spec.subnets:
802
+ current_spec = current_spec.subnets[subnet_fqn]
803
+ else:
804
+ break
805
+
806
+ current_spec.arcs.append(arc_spec)
807
+
808
+ def _remove_from_spec(self, fqn: str, is_place: bool):
809
+ """Remove place or transition from spec"""
810
+ parts = fqn.split('.')
811
+ current_spec = self.spec
812
+
813
+ for i, part in enumerate(parts[1:-1], start=1):
814
+ subnet_fqn = '.'.join(parts[:i+1])
815
+ if subnet_fqn not in current_spec.subnets:
816
+ return
817
+ current_spec = current_spec.subnets[subnet_fqn]
818
+
819
+ if is_place and fqn in current_spec.places:
820
+ del current_spec.places[fqn]
821
+ elif not is_place and fqn in current_spec.transitions:
822
+ del current_spec.transitions[fqn]
823
+
824
+ def _remove_arc_from_spec(self, arc: ArcSpec):
825
+ """Remove arc from spec"""
826
+ def remove_from_spec_recursive(spec: NetSpec):
827
+ spec.arcs = [a for a in spec.arcs
828
+ if not (a.source == arc.source and a.target == arc.target)]
829
+ for subnet_spec in spec.subnets.values():
830
+ remove_from_spec_recursive(subnet_spec)
831
+
832
+ remove_from_spec_recursive(self.spec)
833
+
834
+
835
+ class Runner:
836
+ """High-level runner for executing a Petri net"""
837
+
838
+ def __init__(self, net_func: Any, blackboard: Any):
839
+ if not hasattr(net_func, '_spec'):
840
+ raise ValueError(f"{net_func} is not a valid net")
841
+
842
+ self.spec = net_func._spec
843
+ self.blackboard = blackboard
844
+ self.timebase = None
845
+ self.runtime: Optional[NetRuntime] = None
846
+
847
+ async def start(self, timebase: Any):
848
+ """Start the net with given timebase"""
849
+ self.timebase = timebase
850
+ self.runtime = NetRuntime(self.spec, self.blackboard, self.timebase)
851
+ await self.runtime.start()
852
+
853
+ async def stop(self, timeout: float = 5.0):
854
+ """Stop the net"""
855
+ if self.runtime:
856
+ await self.runtime.stop(timeout)
857
+
858
+ def add_place(self, fqn: str, place_type: PlaceType = PlaceType.BAG,
859
+ state_factory: Optional[Callable] = None):
860
+ """Add a place to the running net"""
861
+ if not self.runtime:
862
+ raise RuntimeError("Net is not running. Call start() first.")
863
+ return self.runtime.add_place(fqn, place_type, state_factory)
864
+
865
+ def add_transition(self, fqn: str, handler: Callable,
866
+ guard: Optional[GuardSpec] = None,
867
+ state_factory: Optional[Callable] = None):
868
+ """Add a transition to the running net"""
869
+ if not self.runtime:
870
+ raise RuntimeError("Net is not running. Call start() first.")
871
+ return self.runtime.add_transition(fqn, handler, guard, state_factory)
872
+
873
+ def add_arc(self, source_fqn: str, target_fqn: str, weight: int = 1,
874
+ name: Optional[str] = None):
875
+ """Add an arc to the running net"""
876
+ if not self.runtime:
877
+ raise RuntimeError("Net is not running. Call start() first.")
878
+ self.runtime.add_arc(source_fqn, target_fqn, weight, name)
879
+
880
+ async def remove_arc(self, source_fqn: str, target_fqn: str):
881
+ """Remove an arc from the running net"""
882
+ if not self.runtime:
883
+ raise RuntimeError("Net is not running. Call start() first.")
884
+ await self.runtime.remove_arc(source_fqn, target_fqn)
885
+
886
+ async def remove(self, fqn: str):
887
+ """Remove a place or transition"""
888
+ if not self.runtime:
889
+ raise RuntimeError("Net is not running. Call start() first.")
890
+ await self.runtime.remove(fqn)