mycorrhizal 0.1.2__py3-none-any.whl → 0.2.1__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.
- mycorrhizal/_version.py +1 -1
- mycorrhizal/common/__init__.py +15 -3
- mycorrhizal/common/cache.py +114 -0
- mycorrhizal/common/compilation.py +263 -0
- mycorrhizal/common/interface_detection.py +159 -0
- mycorrhizal/common/interfaces.py +3 -50
- mycorrhizal/common/mermaid.py +124 -0
- mycorrhizal/common/wrappers.py +1 -1
- mycorrhizal/hypha/core/builder.py +11 -1
- mycorrhizal/hypha/core/runtime.py +242 -107
- mycorrhizal/mycelium/__init__.py +174 -0
- mycorrhizal/mycelium/core.py +619 -0
- mycorrhizal/mycelium/exceptions.py +30 -0
- mycorrhizal/mycelium/hypha_bridge.py +1143 -0
- mycorrhizal/mycelium/instance.py +440 -0
- mycorrhizal/mycelium/pn_context.py +276 -0
- mycorrhizal/mycelium/runner.py +165 -0
- mycorrhizal/mycelium/spores_integration.py +655 -0
- mycorrhizal/mycelium/tree_builder.py +102 -0
- mycorrhizal/mycelium/tree_spec.py +197 -0
- mycorrhizal/rhizomorph/README.md +82 -33
- mycorrhizal/rhizomorph/core.py +287 -119
- mycorrhizal/septum/TRANSITION_REFERENCE.md +385 -0
- mycorrhizal/{enoki → septum}/core.py +326 -100
- mycorrhizal/{enoki → septum}/testing_utils.py +7 -7
- mycorrhizal/{enoki → septum}/util.py +44 -21
- mycorrhizal/spores/__init__.py +3 -3
- mycorrhizal/spores/core.py +149 -28
- mycorrhizal/spores/dsl/__init__.py +8 -8
- mycorrhizal/spores/dsl/hypha.py +3 -15
- mycorrhizal/spores/dsl/rhizomorph.py +3 -11
- mycorrhizal/spores/dsl/{enoki.py → septum.py} +26 -77
- mycorrhizal/spores/encoder/json.py +21 -12
- mycorrhizal/spores/extraction.py +14 -11
- mycorrhizal/spores/models.py +53 -20
- mycorrhizal-0.2.1.dist-info/METADATA +335 -0
- mycorrhizal-0.2.1.dist-info/RECORD +54 -0
- mycorrhizal-0.1.2.dist-info/METADATA +0 -198
- mycorrhizal-0.1.2.dist-info/RECORD +0 -39
- /mycorrhizal/{enoki → septum}/__init__.py +0 -0
- {mycorrhizal-0.1.2.dist-info → mycorrhizal-0.2.1.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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
130
|
+
gen = handler(bb_to_pass, self.timebase)
|
|
143
131
|
else:
|
|
144
|
-
gen =
|
|
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
|
-
|
|
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)
|
|
192
|
+
def _generate_token_combinations(self):
|
|
201
193
|
"""
|
|
202
194
|
Generate all valid token combinations for input arcs.
|
|
203
|
-
Returns
|
|
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
|
-
|
|
224
|
-
return
|
|
225
|
-
|
|
226
|
-
async def _check_guard(self,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"""
|