mycorrhizal 0.2.0__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.
Files changed (91) hide show
  1. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/PKG-INFO +1 -1
  2. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/rhizomorph/index.md +40 -0
  3. mycorrhizal-0.2.2/src/mycorrhizal/_version.py +1 -0
  4. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/runtime.py +42 -30
  5. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/core.py +329 -11
  6. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/__init__.py +8 -0
  7. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/cache.py +92 -30
  8. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/core.py +208 -53
  9. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/encoder/json.py +17 -28
  10. mycorrhizal-0.2.0/src/mycorrhizal/_version.py +0 -1
  11. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/.github/workflows/python-publish.yml +0 -0
  12. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/.github/workflows/test.yml +0 -0
  13. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/.gitignore +0 -0
  14. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/.readthedocs.yaml +0 -0
  15. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/README.md +0 -0
  16. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/api/common.md +0 -0
  17. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/api/hypha.md +0 -0
  18. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/api/index.md +0 -0
  19. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/api/rhizomorph.md +0 -0
  20. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/api/septum.md +0 -0
  21. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/api/spores.md +0 -0
  22. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/assets/stylesheets/extra.css +0 -0
  23. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/getting-started/index.md +0 -0
  24. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/getting-started/installation.md +0 -0
  25. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/getting-started/your-first-hypha.md +0 -0
  26. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/getting-started/your-first-mycelium.md +0 -0
  27. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/getting-started/your-first-rhizomorph.md +0 -0
  28. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/getting-started/your-first-septum.md +0 -0
  29. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/getting-started/your-first-spores.md +0 -0
  30. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/best-practices.md +0 -0
  31. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/blackboards.md +0 -0
  32. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/composition.md +0 -0
  33. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/index.md +0 -0
  34. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/observability.md +0 -0
  35. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/programmatic-hypha.md +0 -0
  36. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/septum-pda-guide.md +0 -0
  37. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/guides/timebases.md +0 -0
  38. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/hypha/index.md +0 -0
  39. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/index.md +0 -0
  40. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/mycelium/index.md +0 -0
  41. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/septum/index.md +0 -0
  42. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/septum/production.md +0 -0
  43. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/septum/troubleshooting.md +0 -0
  44. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/docs/spores/index.md +0 -0
  45. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/mkdocs.yml +0 -0
  46. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/pyproject.toml +0 -0
  47. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/__init__.py +0 -0
  48. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/__init__.py +0 -0
  49. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/cache.py +0 -0
  50. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/compilation.py +0 -0
  51. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/interface_builder.py +0 -0
  52. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/interface_detection.py +0 -0
  53. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/interfaces.py +0 -0
  54. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/mermaid.py +0 -0
  55. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/timebase.py +0 -0
  56. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/common/wrappers.py +0 -0
  57. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/__init__.py +0 -0
  58. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/__init__.py +0 -0
  59. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/builder.py +0 -0
  60. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/core/specs.py +0 -0
  61. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/hypha/util.py +0 -0
  62. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/__init__.py +0 -0
  63. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/core.py +0 -0
  64. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/exceptions.py +0 -0
  65. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/hypha_bridge.py +0 -0
  66. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/instance.py +0 -0
  67. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/pn_context.py +0 -0
  68. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/runner.py +0 -0
  69. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/spores_integration.py +0 -0
  70. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/tree_builder.py +0 -0
  71. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/mycelium/tree_spec.py +0 -0
  72. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/README.md +0 -0
  73. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/__init__.py +0 -0
  74. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/rhizomorph/util.py +0 -0
  75. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/TRANSITION_REFERENCE.md +0 -0
  76. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/__init__.py +0 -0
  77. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/core.py +0 -0
  78. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/testing_utils.py +0 -0
  79. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/septum/util.py +0 -0
  80. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/__init__.py +0 -0
  81. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/hypha.py +0 -0
  82. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/rhizomorph.py +0 -0
  83. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/dsl/septum.py +0 -0
  84. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/encoder/__init__.py +0 -0
  85. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/encoder/base.py +0 -0
  86. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/extraction.py +0 -0
  87. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/models.py +0 -0
  88. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/transport/__init__.py +0 -0
  89. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/transport/base.py +0 -0
  90. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/src/mycorrhizal/spores/transport/file.py +0 -0
  91. {mycorrhizal-0.2.0 → mycorrhizal-0.2.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycorrhizal
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Utilities and DSLs for modelling and implementing safe, performant, structured systems
5
5
  Author-email: Jeff Ciesielski <jeffciesielski@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -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) -> Optional[Tuple[str, ...]]:
288
+ def _resolve_place_ref(self, place_ref) -> Tuple[str, ...]:
289
289
  """Resolve a PlaceRef to runtime place parts.
290
290
 
291
- Attempts multiple resolution strategies in order:
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, None otherwise
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", place_ref, parts)
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
- # Map relative to this transition's parent parts
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) + [place_ref.local_name])
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", parts, candidate)
333
+ logger.debug("[resolve] mapped %s -> %s using parent_prefix",
334
+ local_name, candidate)
320
335
  return candidate
321
336
 
322
- # Fallback: find any place whose last segment matches local_name
323
- for p in self.net.places.keys():
324
- if isinstance(p, tuple):
325
- segs = list(p)
326
- else:
327
- segs = p.split('.')
328
- if segs and segs[-1] == place_ref.local_name:
329
- logger.debug("[resolve] suffix match %s -> %s", place_ref.local_name, p)
330
- return tuple(segs)
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(self.spec, self.blackboard, self.timebase)
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):
@@ -410,7 +410,7 @@ class Action(Node[BB]):
410
410
  # Log trace if enabled
411
411
  trace_logger = _trace_logger_ctx.get()
412
412
  if trace_logger is not None:
413
- trace_logger.info(f"action: {self._fq_name} | {final_status.name}")
413
+ trace_logger.debug(f"action: {self._fq_name} | {final_status.name}")
414
414
 
415
415
  return await self._finish(bb, final_status, tb)
416
416
 
@@ -441,7 +441,7 @@ class Condition(Action[BB]):
441
441
  # Log trace if enabled
442
442
  trace_logger = _trace_logger_ctx.get()
443
443
  if trace_logger is not None:
444
- trace_logger.info(f"condition: {self._fq_name} | {final_status.name}")
444
+ trace_logger.debug(f"condition: {self._fq_name} | {final_status.name}")
445
445
 
446
446
  return await self._finish(bb, final_status, tb)
447
447
 
@@ -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
- lines.append(f" {this_id} --> {child_id}")
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)