mycorrhizal 0.2.1__tar.gz → 0.2.2__tar.gz
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-0.2.1 → mycorrhizal-0.2.2}/PKG-INFO +1 -1
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/rhizomorph/index.md +40 -0
- mycorrhizal-0.2.2/src/mycorrhizal/_version.py +1 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/runtime.py +42 -30
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/core.py +327 -9
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/__init__.py +8 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/cache.py +92 -30
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/core.py +208 -53
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/encoder/json.py +17 -28
- mycorrhizal-0.2.1/src/mycorrhizal/_version.py +0 -1
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/.github/workflows/python-publish.yml +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/.github/workflows/test.yml +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/.gitignore +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/.readthedocs.yaml +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/README.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/api/common.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/api/hypha.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/api/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/api/rhizomorph.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/api/septum.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/api/spores.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/assets/stylesheets/extra.css +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/getting-started/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/getting-started/installation.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/getting-started/your-first-hypha.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/getting-started/your-first-mycelium.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/getting-started/your-first-rhizomorph.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/getting-started/your-first-septum.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/getting-started/your-first-spores.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/best-practices.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/blackboards.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/composition.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/observability.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/programmatic-hypha.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/septum-pda-guide.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/guides/timebases.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/hypha/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/mycelium/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/septum/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/septum/production.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/septum/troubleshooting.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/docs/spores/index.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/mkdocs.yml +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/pyproject.toml +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/cache.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/compilation.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/interface_builder.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/interface_detection.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/interfaces.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/mermaid.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/timebase.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/common/wrappers.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/builder.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/specs.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/util.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/core.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/exceptions.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/hypha_bridge.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/instance.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/pn_context.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/runner.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/spores_integration.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/tree_builder.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/tree_spec.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/README.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/util.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/TRANSITION_REFERENCE.md +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/core.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/testing_utils.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/util.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/hypha.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/rhizomorph.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/septum.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/encoder/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/encoder/base.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/extraction.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/models.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/transport/__init__.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/transport/base.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/transport/file.py +0 -0
- {mycorrhizal-0.2.1 → mycorrhizal-0.2.2}/uv.lock +0 -0
|
@@ -153,6 +153,46 @@ def my_parallel():
|
|
|
153
153
|
yield task_c
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
### Conditional Wrappers
|
|
157
|
+
|
|
158
|
+
Control whether child nodes execute based on conditions:
|
|
159
|
+
|
|
160
|
+
#### gate
|
|
161
|
+
|
|
162
|
+
Execute child only when condition is true, otherwise return FAILURE:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
@bt.sequence
|
|
166
|
+
def gated_sequence():
|
|
167
|
+
"""Child must pass gate or sequence fails."""
|
|
168
|
+
yield bt.gate(has_battery)(engage_action)
|
|
169
|
+
yield next_step # Only reached if gate passed
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Use `gate` when a condition is **required** for execution.
|
|
173
|
+
|
|
174
|
+
#### when
|
|
175
|
+
|
|
176
|
+
Execute child only when condition is true, otherwise return SUCCESS (skip but continue):
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@bt.sequence
|
|
180
|
+
def feature_flag_sequence():
|
|
181
|
+
"""Optional action - sequence continues if feature is disabled."""
|
|
182
|
+
yield validate_input
|
|
183
|
+
yield bt.when(feature_enabled)(optional_action)
|
|
184
|
+
yield continue_processing # Always reached
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Use `when` for **optional** steps or feature flags.
|
|
188
|
+
|
|
189
|
+
**When vs Gate:**
|
|
190
|
+
|
|
191
|
+
| Wrapper | Condition True | Condition False | Use Case |
|
|
192
|
+
|---------|---------------|-----------------|----------|
|
|
193
|
+
| `gate(cond)(child)` | Execute child | Return FAILURE | Required precondition |
|
|
194
|
+
| `when(cond)(child)` | Execute child | Return SUCCESS | Optional/feature flag |
|
|
195
|
+
|
|
156
196
|
### Root Node
|
|
157
197
|
|
|
158
198
|
Every tree must have a root:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.2.2"
|
|
@@ -285,51 +285,64 @@ class TransitionRuntime:
|
|
|
285
285
|
if result is not None:
|
|
286
286
|
await self._process_yield(result)
|
|
287
287
|
|
|
288
|
-
def _resolve_place_ref(self, place_ref) ->
|
|
288
|
+
def _resolve_place_ref(self, place_ref) -> Tuple[str, ...]:
|
|
289
289
|
"""Resolve a PlaceRef to runtime place parts.
|
|
290
290
|
|
|
291
|
-
|
|
291
|
+
Requires exact match or parent-relative mapping for subnet instances.
|
|
292
|
+
|
|
293
|
+
Resolution strategies in order:
|
|
292
294
|
1. Exact match using PlaceRef.get_parts()
|
|
293
295
|
2. Parent-relative mapping (for subnet instances)
|
|
294
|
-
3. Suffix fallback (match by local name)
|
|
295
296
|
|
|
296
297
|
Returns:
|
|
297
|
-
Tuple of place parts if found
|
|
298
|
+
Tuple of place parts if found
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ValueError: If reference cannot be resolved
|
|
298
302
|
"""
|
|
303
|
+
# Extract local_name from place_ref (handles PlaceRef objects and strings)
|
|
304
|
+
if isinstance(place_ref, str):
|
|
305
|
+
local_name = place_ref
|
|
306
|
+
elif hasattr(place_ref, 'local_name'):
|
|
307
|
+
local_name = place_ref.local_name
|
|
308
|
+
else:
|
|
309
|
+
local_name = str(place_ref)
|
|
310
|
+
|
|
299
311
|
# Try to get parts from the PlaceRef API
|
|
300
312
|
try:
|
|
301
313
|
parts = tuple(place_ref.get_parts())
|
|
302
314
|
except Exception:
|
|
303
315
|
parts = None
|
|
304
316
|
|
|
305
|
-
logger.debug("[resolve] place_ref=%r parts=%s",
|
|
317
|
+
logger.debug("[resolve] place_ref=%r parts=%s local_name=%s",
|
|
318
|
+
place_ref, parts, local_name)
|
|
306
319
|
|
|
320
|
+
# Strategy 1: Exact match
|
|
307
321
|
if parts:
|
|
308
322
|
key = tuple(parts)
|
|
309
323
|
if key in self.net.places:
|
|
310
324
|
logger.debug("[resolve] exact match parts=%s", parts)
|
|
311
325
|
return key
|
|
312
326
|
|
|
313
|
-
#
|
|
327
|
+
# Strategy 2: Parent-relative mapping (for subnet instances)
|
|
314
328
|
trans_parts = tuple(self.fqn.split('.'))
|
|
315
329
|
if len(trans_parts) > 1:
|
|
316
330
|
parent_prefix = trans_parts[:-1]
|
|
317
|
-
candidate = tuple(list(parent_prefix) + [
|
|
331
|
+
candidate = tuple(list(parent_prefix) + [local_name])
|
|
318
332
|
if candidate in self.net.places:
|
|
319
|
-
logger.debug("[resolve] mapped %s -> %s using parent_prefix",
|
|
333
|
+
logger.debug("[resolve] mapped %s -> %s using parent_prefix",
|
|
334
|
+
local_name, candidate)
|
|
320
335
|
return candidate
|
|
321
336
|
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return None
|
|
337
|
+
# No match found - raise helpful error
|
|
338
|
+
available_places = [p[-1] if isinstance(p, tuple) else p.split('.')[-1]
|
|
339
|
+
for p in self.net.places.keys()]
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"Cannot resolve place reference '{local_name}' "
|
|
342
|
+
f"in transition '{self.fqn}'. Available places: {available_places}. "
|
|
343
|
+
f"Attempted resolution: {parts}. "
|
|
344
|
+
f"Use explicit place references (e.g., subnet.place)."
|
|
345
|
+
)
|
|
333
346
|
|
|
334
347
|
def _normalize_to_parts(self, key) -> Tuple[str, ...]:
|
|
335
348
|
"""Normalize a place key to tuple of parts for runtime lookup.
|
|
@@ -387,9 +400,6 @@ class TransitionRuntime:
|
|
|
387
400
|
continue
|
|
388
401
|
|
|
389
402
|
place_parts = self._resolve_place_ref(key)
|
|
390
|
-
if place_parts is None:
|
|
391
|
-
continue
|
|
392
|
-
|
|
393
403
|
key_normalized = self._normalize_to_parts(place_parts)
|
|
394
404
|
explicit_targets.add(key_normalized)
|
|
395
405
|
await self._add_token_to_place(key_normalized, token)
|
|
@@ -420,10 +430,6 @@ class TransitionRuntime:
|
|
|
420
430
|
"""Process a single (place_ref, token) tuple yield."""
|
|
421
431
|
place_ref, token = yielded
|
|
422
432
|
place_parts = self._resolve_place_ref(place_ref)
|
|
423
|
-
|
|
424
|
-
if place_parts is None:
|
|
425
|
-
return
|
|
426
|
-
|
|
427
433
|
key = self._normalize_to_parts(place_parts)
|
|
428
434
|
await self._add_token_to_place(key, token)
|
|
429
435
|
|
|
@@ -521,6 +527,8 @@ class TransitionRuntime:
|
|
|
521
527
|
|
|
522
528
|
except asyncio.CancelledError:
|
|
523
529
|
pass
|
|
530
|
+
except ValueError:
|
|
531
|
+
raise
|
|
524
532
|
except Exception:
|
|
525
533
|
logger.exception("[%s] Transition error", self.fqn)
|
|
526
534
|
|
|
@@ -969,20 +977,24 @@ class NetRuntime:
|
|
|
969
977
|
|
|
970
978
|
class Runner:
|
|
971
979
|
"""High-level runner for executing a Petri net"""
|
|
972
|
-
|
|
980
|
+
|
|
973
981
|
def __init__(self, net_func: Any, blackboard: Any):
|
|
974
982
|
if not hasattr(net_func, '_spec'):
|
|
975
983
|
raise ValueError(f"{net_func} is not a valid net")
|
|
976
|
-
|
|
984
|
+
|
|
977
985
|
self.spec = net_func._spec
|
|
978
986
|
self.blackboard = blackboard
|
|
979
987
|
self.timebase = None
|
|
980
988
|
self.runtime: Optional[NetRuntime] = None
|
|
981
|
-
|
|
989
|
+
|
|
982
990
|
async def start(self, timebase: Any):
|
|
983
991
|
"""Start the net with given timebase"""
|
|
984
992
|
self.timebase = timebase
|
|
985
|
-
self.runtime = NetRuntime(
|
|
993
|
+
self.runtime = NetRuntime(
|
|
994
|
+
self.spec,
|
|
995
|
+
self.blackboard,
|
|
996
|
+
self.timebase
|
|
997
|
+
)
|
|
986
998
|
await self.runtime.start()
|
|
987
999
|
|
|
988
1000
|
async def stop(self, timeout: float = 5.0):
|
|
@@ -452,7 +452,35 @@ class Condition(Action[BB]):
|
|
|
452
452
|
|
|
453
453
|
|
|
454
454
|
class Sequence(Node[BB]):
|
|
455
|
-
"""Sequence (AND): fail/err fast; RUNNING bubbles; all SUCCESS → SUCCESS.
|
|
455
|
+
"""Sequence (AND): fail/err fast; RUNNING bubbles; all SUCCESS → SUCCESS.
|
|
456
|
+
|
|
457
|
+
Memory Behavior:
|
|
458
|
+
When memory=True (default), the sequence remembers its position across ticks.
|
|
459
|
+
This allows the sequence to progress through its children incrementally.
|
|
460
|
+
|
|
461
|
+
When memory=False, the sequence restarts from the beginning on every tick.
|
|
462
|
+
This is useful for reactive sequences that should always start from the first child.
|
|
463
|
+
|
|
464
|
+
Important Note on do_while Loops:
|
|
465
|
+
If a sequence is used as the child of a do_while loop, it typically needs
|
|
466
|
+
memory=True to make progress. Without memory, the sequence will restart from
|
|
467
|
+
its first child on each tick, preventing it from completing all children.
|
|
468
|
+
|
|
469
|
+
Example:
|
|
470
|
+
@bt.sequence(memory=True) # Required for progress
|
|
471
|
+
def image_samples():
|
|
472
|
+
yield bt.subtree(MoveToSample)
|
|
473
|
+
yield send_image_request
|
|
474
|
+
yield bt.subtree(IncrementSampleCounter)
|
|
475
|
+
|
|
476
|
+
@bt.sequence(memory=True)
|
|
477
|
+
def happy_path():
|
|
478
|
+
yield bt.do_while(samples_remain)(image_samples)
|
|
479
|
+
yield set_pod_to_grow
|
|
480
|
+
|
|
481
|
+
Without memory=True on image_samples, the do_while loop would never
|
|
482
|
+
progress past MoveToSample because the sequence would restart each tick.
|
|
483
|
+
"""
|
|
456
484
|
|
|
457
485
|
def __init__(
|
|
458
486
|
self,
|
|
@@ -492,7 +520,20 @@ class Sequence(Node[BB]):
|
|
|
492
520
|
|
|
493
521
|
|
|
494
522
|
class Selector(Node[BB]):
|
|
495
|
-
"""Selector (Fallback): first SUCCESS wins; RUNNING bubbles; else FAILURE.
|
|
523
|
+
"""Selector (Fallback): first SUCCESS wins; RUNNING bubbles; else FAILURE.
|
|
524
|
+
|
|
525
|
+
Memory Behavior:
|
|
526
|
+
When memory=True (default), the selector remembers its position across ticks.
|
|
527
|
+
This allows the selector to continue trying children from where it left off.
|
|
528
|
+
|
|
529
|
+
When memory=False, the selector restarts from the beginning on every tick.
|
|
530
|
+
This is useful for reactive selectors that should always re-evaluate from the first child.
|
|
531
|
+
|
|
532
|
+
Important Note on do_while Loops:
|
|
533
|
+
Similar to Sequence, if a selector is used as the child of a do_while loop,
|
|
534
|
+
it typically needs memory=True to make progress. Without memory, the selector
|
|
535
|
+
will restart from its first child on each tick.
|
|
536
|
+
"""
|
|
496
537
|
|
|
497
538
|
def __init__(
|
|
498
539
|
self,
|
|
@@ -849,20 +890,72 @@ class Gate(Node[BB]):
|
|
|
849
890
|
return await self._finish(bb, st, tb)
|
|
850
891
|
|
|
851
892
|
|
|
893
|
+
class When(Node[BB]):
|
|
894
|
+
"""
|
|
895
|
+
Conditionally execute a child, returning SUCCESS if the condition fails.
|
|
896
|
+
|
|
897
|
+
Unlike gate(), when() does NOT fail the parent when the condition is false.
|
|
898
|
+
This is useful for optional steps or feature flags in sequences.
|
|
899
|
+
|
|
900
|
+
Behavior:
|
|
901
|
+
- If condition returns SUCCESS → execute child, return child's status
|
|
902
|
+
- If condition returns RUNNING → return RUNNING
|
|
903
|
+
- Otherwise → return SUCCESS (skip but don't fail parent)
|
|
904
|
+
|
|
905
|
+
Example:
|
|
906
|
+
yield bt.when(feature_enabled)(optional_action)
|
|
907
|
+
# If feature_enabled is false, returns SUCCESS and sequence continues
|
|
908
|
+
"""
|
|
909
|
+
|
|
910
|
+
def __init__(
|
|
911
|
+
self,
|
|
912
|
+
condition: Node[BB],
|
|
913
|
+
child: Node[BB],
|
|
914
|
+
name: Optional[str] = None,
|
|
915
|
+
exception_policy: ExceptionPolicy = ExceptionPolicy.LOG_AND_CONTINUE,
|
|
916
|
+
) -> None:
|
|
917
|
+
super().__init__(
|
|
918
|
+
name or f"When(cond={_name_of(condition)}, child={_name_of(child)})",
|
|
919
|
+
exception_policy=exception_policy,
|
|
920
|
+
)
|
|
921
|
+
self.condition = condition
|
|
922
|
+
self.child = child
|
|
923
|
+
self.condition.parent = self
|
|
924
|
+
self.child.parent = self
|
|
925
|
+
|
|
926
|
+
def reset(self) -> None:
|
|
927
|
+
super().reset()
|
|
928
|
+
self.condition.reset()
|
|
929
|
+
self.child.reset()
|
|
930
|
+
|
|
931
|
+
async def tick(self, bb: BB, tb: Timebase) -> Status:
|
|
932
|
+
await self._ensure_entered(bb, tb)
|
|
933
|
+
c = await self.condition.tick(bb, tb)
|
|
934
|
+
if c is Status.RUNNING:
|
|
935
|
+
return Status.RUNNING
|
|
936
|
+
if c is not Status.SUCCESS:
|
|
937
|
+
# Condition failed - return SUCCESS to skip but don't block parent
|
|
938
|
+
return await self._finish(bb, Status.SUCCESS, tb)
|
|
939
|
+
st = await self.child.tick(bb, tb)
|
|
940
|
+
if st is Status.RUNNING:
|
|
941
|
+
return Status.RUNNING
|
|
942
|
+
return await self._finish(bb, st, tb)
|
|
943
|
+
|
|
944
|
+
|
|
852
945
|
class Match(Node[BB]):
|
|
853
946
|
"""
|
|
854
947
|
Pattern-matching dispatch node.
|
|
855
|
-
|
|
948
|
+
|
|
856
949
|
Evaluates a key function against the blackboard, then checks each case
|
|
857
950
|
in order. The first matching case's child is executed. If the child
|
|
858
951
|
completes (SUCCESS or FAILURE), that status is returned immediately.
|
|
859
|
-
|
|
952
|
+
|
|
860
953
|
Cases can match by:
|
|
861
954
|
- Type: isinstance(value, case_type)
|
|
862
955
|
- Predicate: case_predicate(value) returns True
|
|
863
956
|
- Value: value == case_value
|
|
864
957
|
- Default: always matches (should be last)
|
|
865
|
-
|
|
958
|
+
|
|
866
959
|
If no case matches and there's no default, returns FAILURE.
|
|
867
960
|
"""
|
|
868
961
|
|
|
@@ -1003,6 +1096,68 @@ class DoWhile(Node[BB]):
|
|
|
1003
1096
|
return await self._finish(bb, Status.FAILURE, tb)
|
|
1004
1097
|
|
|
1005
1098
|
|
|
1099
|
+
class TryCatch(Node[BB]):
|
|
1100
|
+
"""
|
|
1101
|
+
Try-catch error handling node.
|
|
1102
|
+
|
|
1103
|
+
Executes the try block first. If it returns SUCCESS, that status is returned.
|
|
1104
|
+
If the try block returns FAILURE, the catch block is executed instead.
|
|
1105
|
+
Returns SUCCESS if either block succeeds, FAILURE if both fail.
|
|
1106
|
+
|
|
1107
|
+
This is semantically equivalent to a selector with two children, but provides
|
|
1108
|
+
clearer intent and better visualization with labeled edges.
|
|
1109
|
+
"""
|
|
1110
|
+
|
|
1111
|
+
def __init__(
|
|
1112
|
+
self,
|
|
1113
|
+
try_block: Node[BB],
|
|
1114
|
+
catch_block: Node[BB],
|
|
1115
|
+
name: Optional[str] = None,
|
|
1116
|
+
exception_policy: ExceptionPolicy = ExceptionPolicy.LOG_AND_CONTINUE,
|
|
1117
|
+
) -> None:
|
|
1118
|
+
super().__init__(
|
|
1119
|
+
name or f"TryCatch(try={_name_of(try_block)}, catch={_name_of(catch_block)})",
|
|
1120
|
+
exception_policy=exception_policy,
|
|
1121
|
+
)
|
|
1122
|
+
self.try_block = try_block
|
|
1123
|
+
self.catch_block = catch_block
|
|
1124
|
+
self.try_block.parent = self
|
|
1125
|
+
self.catch_block.parent = self
|
|
1126
|
+
self._use_catch = False
|
|
1127
|
+
|
|
1128
|
+
def reset(self) -> None:
|
|
1129
|
+
super().reset()
|
|
1130
|
+
self.try_block.reset()
|
|
1131
|
+
self.catch_block.reset()
|
|
1132
|
+
self._use_catch = False
|
|
1133
|
+
|
|
1134
|
+
async def tick(self, bb: BB, tb: Timebase) -> Status:
|
|
1135
|
+
await self._ensure_entered(bb, tb)
|
|
1136
|
+
|
|
1137
|
+
# If we're in catch mode, continue executing catch block
|
|
1138
|
+
if self._use_catch:
|
|
1139
|
+
st = await self.catch_block.tick(bb, tb)
|
|
1140
|
+
if st is Status.RUNNING:
|
|
1141
|
+
return Status.RUNNING
|
|
1142
|
+
self._use_catch = False
|
|
1143
|
+
return await self._finish(bb, st, tb)
|
|
1144
|
+
|
|
1145
|
+
# Try the try block
|
|
1146
|
+
st = await self.try_block.tick(bb, tb)
|
|
1147
|
+
if st is Status.RUNNING:
|
|
1148
|
+
return Status.RUNNING
|
|
1149
|
+
if st is Status.SUCCESS:
|
|
1150
|
+
return await self._finish(bb, Status.SUCCESS, tb)
|
|
1151
|
+
|
|
1152
|
+
# Try block failed, switch to catch
|
|
1153
|
+
self._use_catch = True
|
|
1154
|
+
st = await self.catch_block.tick(bb, tb)
|
|
1155
|
+
if st is Status.RUNNING:
|
|
1156
|
+
return Status.RUNNING
|
|
1157
|
+
self._use_catch = False
|
|
1158
|
+
return await self._finish(bb, st, tb)
|
|
1159
|
+
|
|
1160
|
+
|
|
1006
1161
|
# ======================================================================================
|
|
1007
1162
|
# Authoring DSL (NodeSpec + bt namespace)
|
|
1008
1163
|
# ======================================================================================
|
|
@@ -1018,6 +1173,7 @@ class NodeSpecKind(Enum):
|
|
|
1018
1173
|
SUBTREE = "subtree"
|
|
1019
1174
|
MATCH = "match"
|
|
1020
1175
|
DO_WHILE = "do_while"
|
|
1176
|
+
TRY_CATCH = "try_catch"
|
|
1021
1177
|
|
|
1022
1178
|
|
|
1023
1179
|
class _DefaultCase:
|
|
@@ -1128,6 +1284,16 @@ class NodeSpec:
|
|
|
1128
1284
|
exception_policy=exception_policy,
|
|
1129
1285
|
)
|
|
1130
1286
|
|
|
1287
|
+
case NodeSpecKind.TRY_CATCH:
|
|
1288
|
+
try_spec = self.payload["try"]
|
|
1289
|
+
catch_spec = self.payload["catch"]
|
|
1290
|
+
return TryCatch(
|
|
1291
|
+
try_spec.to_node(exception_policy),
|
|
1292
|
+
catch_spec.to_node(exception_policy),
|
|
1293
|
+
name=self.name,
|
|
1294
|
+
exception_policy=exception_policy,
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1131
1297
|
case _:
|
|
1132
1298
|
raise ValueError(f"Unknown spec kind: {self.kind}")
|
|
1133
1299
|
|
|
@@ -1271,6 +1437,31 @@ class _WrapperChain:
|
|
|
1271
1437
|
lambda ch: Gate(cond_spec.to_node(), ch),
|
|
1272
1438
|
)
|
|
1273
1439
|
|
|
1440
|
+
def when(
|
|
1441
|
+
self, condition_spec_or_fn: Union["NodeSpec", Callable[[Any], Any]]
|
|
1442
|
+
) -> "_WrapperChain":
|
|
1443
|
+
"""
|
|
1444
|
+
Add a conditional wrapper that executes the child only when condition is true.
|
|
1445
|
+
|
|
1446
|
+
Unlike gate(), when() returns SUCCESS (not FAILURE) when the condition fails.
|
|
1447
|
+
This allows sequences to continue when optional steps are skipped.
|
|
1448
|
+
|
|
1449
|
+
Args:
|
|
1450
|
+
condition_spec_or_fn: A node spec or callable that returns True/False
|
|
1451
|
+
|
|
1452
|
+
Returns:
|
|
1453
|
+
The chain for further wrapping
|
|
1454
|
+
|
|
1455
|
+
Example:
|
|
1456
|
+
yield bt.when(is_enabled)(optional_action)
|
|
1457
|
+
# If is_enabled is False, returns SUCCESS and sequence continues
|
|
1458
|
+
"""
|
|
1459
|
+
cond_spec = bt.as_spec(condition_spec_or_fn)
|
|
1460
|
+
return self._append(
|
|
1461
|
+
f"When(cond={_name_of(cond_spec)})",
|
|
1462
|
+
lambda ch: When(cond_spec.to_node(), ch),
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1274
1465
|
def __call__(self, inner: Union["NodeSpec", Callable[[Any], Any]]) -> "NodeSpec":
|
|
1275
1466
|
"""
|
|
1276
1467
|
Apply the chain to a child spec → nested decorator NodeSpecs.
|
|
@@ -1342,10 +1533,10 @@ class _MatchBuilder:
|
|
|
1342
1533
|
|
|
1343
1534
|
class _DoWhileBuilder:
|
|
1344
1535
|
"""Builder for do_while loops."""
|
|
1345
|
-
|
|
1536
|
+
|
|
1346
1537
|
def __init__(self, condition_spec: NodeSpec) -> None:
|
|
1347
1538
|
self._condition_spec = condition_spec
|
|
1348
|
-
|
|
1539
|
+
|
|
1349
1540
|
def __call__(self, child: Union["NodeSpec", Callable[[Any], Any]]) -> NodeSpec:
|
|
1350
1541
|
child_spec = bt.as_spec(child)
|
|
1351
1542
|
return NodeSpec(
|
|
@@ -1404,6 +1595,33 @@ class _BT:
|
|
|
1404
1595
|
|
|
1405
1596
|
3. Direct call with child nodes:
|
|
1406
1597
|
bt.sequence(action1, action2, action3)
|
|
1598
|
+
|
|
1599
|
+
Memory Parameter:
|
|
1600
|
+
The memory parameter controls whether the sequence remembers its position
|
|
1601
|
+
across ticks:
|
|
1602
|
+
|
|
1603
|
+
- memory=None (default): Use the Runner's memory setting
|
|
1604
|
+
- memory=True: Remember position, allowing incremental progress
|
|
1605
|
+
- memory=False: Restart from beginning each tick (reactive behavior)
|
|
1606
|
+
|
|
1607
|
+
IMPORTANT: If a sequence is inside a do_while loop and needs to execute
|
|
1608
|
+
all its children incrementally, use memory=True. Otherwise, the sequence
|
|
1609
|
+
will restart from the first child on every tick and never complete.
|
|
1610
|
+
|
|
1611
|
+
Example:
|
|
1612
|
+
# CORRECT - sequence progresses through children
|
|
1613
|
+
@bt.sequence(memory=True)
|
|
1614
|
+
def process_samples():
|
|
1615
|
+
yield move_to_sample
|
|
1616
|
+
yield capture_image
|
|
1617
|
+
yield bt.do_while(samples_remain)(process_samples)
|
|
1618
|
+
|
|
1619
|
+
# WRONG - sequence restarts at move_to_sample every tick
|
|
1620
|
+
@bt.sequence(memory=False)
|
|
1621
|
+
def process_samples():
|
|
1622
|
+
yield move_to_sample
|
|
1623
|
+
yield capture_image # Never reached!
|
|
1624
|
+
yield bt.do_while(samples_remain)(process_samples)
|
|
1407
1625
|
"""
|
|
1408
1626
|
# Case 3: Direct call with children - bt.sequence(node1, node2, ...)
|
|
1409
1627
|
# This is detected when we have multiple args, or a single arg that's not a generator function
|
|
@@ -1488,6 +1706,9 @@ class _BT:
|
|
|
1488
1706
|
|
|
1489
1707
|
3. Direct call with child nodes:
|
|
1490
1708
|
bt.selector(option1, option2, option3)
|
|
1709
|
+
|
|
1710
|
+
The memory parameter defaults to None, which means use the Runner's memory setting.
|
|
1711
|
+
Explicitly set to True or False to override the Runner's setting.
|
|
1491
1712
|
"""
|
|
1492
1713
|
# Case 3: Direct call with children - bt.selector(node1, node2, ...)
|
|
1493
1714
|
# This is detected when we have multiple args, or a single arg that's not a generator function
|
|
@@ -1604,6 +1825,28 @@ class _BT:
|
|
|
1604
1825
|
cond_spec = self.as_spec(condition)
|
|
1605
1826
|
return _WrapperChain().gate(cond_spec)
|
|
1606
1827
|
|
|
1828
|
+
def when(self, condition: Union[NodeSpec, Callable[[Any], Any]]) -> _WrapperChain:
|
|
1829
|
+
"""
|
|
1830
|
+
Create a conditional wrapper that executes the child only when condition is true.
|
|
1831
|
+
|
|
1832
|
+
Unlike gate(), when() returns SUCCESS (not FAILURE) when the condition fails.
|
|
1833
|
+
This is useful for optional steps or feature flags in sequences.
|
|
1834
|
+
|
|
1835
|
+
Args:
|
|
1836
|
+
condition: A node spec or callable that returns True/False
|
|
1837
|
+
|
|
1838
|
+
Returns:
|
|
1839
|
+
A wrapper chain that can be applied to a child node
|
|
1840
|
+
|
|
1841
|
+
Example:
|
|
1842
|
+
@bt.sequence
|
|
1843
|
+
def my_sequence():
|
|
1844
|
+
yield bt.when(feature_enabled)(optional_feature)
|
|
1845
|
+
yield next_step # Always reached, even if flag is disabled
|
|
1846
|
+
"""
|
|
1847
|
+
cond_spec = self.as_spec(condition)
|
|
1848
|
+
return _WrapperChain().when(cond_spec)
|
|
1849
|
+
|
|
1607
1850
|
def match(
|
|
1608
1851
|
self, key_fn: Callable[[Any], Any], name: Optional[str] = None
|
|
1609
1852
|
) -> "_MatchBuilder":
|
|
@@ -1685,6 +1928,62 @@ class _BT:
|
|
|
1685
1928
|
cond_spec = self.as_spec(condition)
|
|
1686
1929
|
return _DoWhileBuilder(cond_spec)
|
|
1687
1930
|
|
|
1931
|
+
def try_catch(
|
|
1932
|
+
self, try_block: Union[NodeSpec, Callable[[Any], Any]]
|
|
1933
|
+
) -> Callable[[Union[NodeSpec, Callable[[Any], Any]]], NodeSpec]:
|
|
1934
|
+
"""
|
|
1935
|
+
Create a try-catch error handling pattern.
|
|
1936
|
+
|
|
1937
|
+
Usage:
|
|
1938
|
+
# Define try and catch blocks with explicit memory settings
|
|
1939
|
+
@bt.sequence(memory=True)
|
|
1940
|
+
def try_block():
|
|
1941
|
+
yield action1
|
|
1942
|
+
yield action2
|
|
1943
|
+
|
|
1944
|
+
@bt.sequence(memory=True)
|
|
1945
|
+
def catch_block():
|
|
1946
|
+
yield cleanup
|
|
1947
|
+
|
|
1948
|
+
# Use in the tree
|
|
1949
|
+
@bt.root
|
|
1950
|
+
@bt.sequence
|
|
1951
|
+
def root():
|
|
1952
|
+
yield bt.try_catch(try_block)(catch_block)
|
|
1953
|
+
|
|
1954
|
+
Behavior:
|
|
1955
|
+
1. Execute try block
|
|
1956
|
+
2. If try block returns SUCCESS → return SUCCESS
|
|
1957
|
+
3. If try block returns FAILURE → execute catch block
|
|
1958
|
+
4. Return SUCCESS if either block succeeds, FAILURE if both fail
|
|
1959
|
+
|
|
1960
|
+
This is semantically equivalent to a selector with two children:
|
|
1961
|
+
- First child (try) runs first
|
|
1962
|
+
- Second child (catch) runs only if try fails
|
|
1963
|
+
- Returns SUCCESS if either succeeds
|
|
1964
|
+
|
|
1965
|
+
Args:
|
|
1966
|
+
try_block: The node to try first (pre-defined sequence/selector/etc with explicit memory settings)
|
|
1967
|
+
|
|
1968
|
+
Returns:
|
|
1969
|
+
A callable that accepts a catch block (pre-defined sequence/selector/etc with explicit memory settings)
|
|
1970
|
+
"""
|
|
1971
|
+
try_spec = self.as_spec(try_block)
|
|
1972
|
+
|
|
1973
|
+
def catcher(catch_block: Union[NodeSpec, Callable[[Any], Any]]) -> NodeSpec:
|
|
1974
|
+
catch_spec = self.as_spec(catch_block)
|
|
1975
|
+
return NodeSpec(
|
|
1976
|
+
kind=NodeSpecKind.TRY_CATCH,
|
|
1977
|
+
name=f"TryCatch(try={_name_of(try_spec)}, catch={_name_of(catch_spec)})",
|
|
1978
|
+
payload={
|
|
1979
|
+
"try": try_spec,
|
|
1980
|
+
"catch": catch_spec,
|
|
1981
|
+
},
|
|
1982
|
+
children=[try_spec, catch_spec],
|
|
1983
|
+
)
|
|
1984
|
+
|
|
1985
|
+
return catcher
|
|
1986
|
+
|
|
1688
1987
|
def subtree(self, tree: SimpleNamespace) -> NodeSpec:
|
|
1689
1988
|
"""
|
|
1690
1989
|
Mount another tree's root spec as a subtree.
|
|
@@ -1813,6 +2112,8 @@ def _generate_mermaid(tree: SimpleNamespace) -> str:
|
|
|
1813
2112
|
return f"Match<br/>{spec.name}"
|
|
1814
2113
|
case NodeSpecKind.DO_WHILE:
|
|
1815
2114
|
return f"DoWhile<br/>{spec.name}"
|
|
2115
|
+
case NodeSpecKind.TRY_CATCH:
|
|
2116
|
+
return f"TryCatch<br/>{spec.name}"
|
|
1816
2117
|
case _:
|
|
1817
2118
|
return spec.name
|
|
1818
2119
|
|
|
@@ -1831,6 +2132,9 @@ def _generate_mermaid(tree: SimpleNamespace) -> str:
|
|
|
1831
2132
|
# Children already set (just the body), but we also want to show condition
|
|
1832
2133
|
cond_spec = spec.payload["condition"]
|
|
1833
2134
|
spec.children = [cond_spec] + spec.children
|
|
2135
|
+
case NodeSpecKind.TRY_CATCH:
|
|
2136
|
+
# Children already set in _TryCatchBuilder
|
|
2137
|
+
pass
|
|
1834
2138
|
return spec.children
|
|
1835
2139
|
|
|
1836
2140
|
def walk(spec: NodeSpec) -> None:
|
|
@@ -1860,10 +2164,24 @@ def _generate_mermaid(tree: SimpleNamespace) -> str:
|
|
|
1860
2164
|
body_id = nid(children[1])
|
|
1861
2165
|
lines.append(f' {this_id} -->|"body"| {body_id}')
|
|
1862
2166
|
walk(children[1])
|
|
2167
|
+
elif spec.kind == NodeSpecKind.TRY_CATCH:
|
|
2168
|
+
# First child is try, second is catch
|
|
2169
|
+
try_id = nid(children[0])
|
|
2170
|
+
lines.append(f' {this_id} -->|"try"| {try_id}')
|
|
2171
|
+
walk(children[0])
|
|
2172
|
+
if len(children) > 1:
|
|
2173
|
+
catch_id = nid(children[1])
|
|
2174
|
+
lines.append(f' {this_id} -->|"catch"| {catch_id}')
|
|
2175
|
+
walk(children[1])
|
|
1863
2176
|
else:
|
|
1864
|
-
for child in children:
|
|
2177
|
+
for i, child in enumerate(children, start=1):
|
|
1865
2178
|
child_id = nid(child)
|
|
1866
|
-
|
|
2179
|
+
if spec.kind == NodeSpecKind.PARALLEL:
|
|
2180
|
+
lines.append(f' {this_id} -->|"P"| {child_id}')
|
|
2181
|
+
elif spec.kind in (NodeSpecKind.SEQUENCE, NodeSpecKind.SELECTOR):
|
|
2182
|
+
lines.append(f' {this_id} -->|"{i}"| {child_id}')
|
|
2183
|
+
else:
|
|
2184
|
+
lines.append(f" {this_id} --> {child_id}")
|
|
1867
2185
|
walk(child)
|
|
1868
2186
|
|
|
1869
2187
|
walk(tree.root)
|
|
@@ -82,10 +82,14 @@ from .core import (
|
|
|
82
82
|
configure,
|
|
83
83
|
get_config,
|
|
84
84
|
get_object_cache,
|
|
85
|
+
flush_object_cache,
|
|
86
|
+
get_cache_metrics,
|
|
85
87
|
get_spore_sync,
|
|
86
88
|
get_spore_async,
|
|
87
89
|
spore,
|
|
88
90
|
SporesConfig,
|
|
91
|
+
EvictionPolicy,
|
|
92
|
+
CacheMetrics,
|
|
89
93
|
EventLogger,
|
|
90
94
|
AsyncEventLogger,
|
|
91
95
|
SyncEventLogger,
|
|
@@ -132,6 +136,8 @@ __all__ = [
|
|
|
132
136
|
'configure',
|
|
133
137
|
'get_config',
|
|
134
138
|
'get_object_cache',
|
|
139
|
+
'flush_object_cache',
|
|
140
|
+
'get_cache_metrics',
|
|
135
141
|
'get_spore_sync',
|
|
136
142
|
'get_spore_async',
|
|
137
143
|
'EventLogger',
|
|
@@ -139,6 +145,8 @@ __all__ = [
|
|
|
139
145
|
'SyncEventLogger',
|
|
140
146
|
'spore', # For DSL adapters and @spore.object() decorator
|
|
141
147
|
'SporesConfig',
|
|
148
|
+
'EvictionPolicy',
|
|
149
|
+
'CacheMetrics',
|
|
142
150
|
|
|
143
151
|
# Models
|
|
144
152
|
'Event',
|