process-bigraph 0.0.43__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.
@@ -0,0 +1,1551 @@
1
+ """
2
+ composite.py
3
+
4
+ This module defines the core execution logic for compositional simulation workflows using the
5
+ Process Bigraph Protocol (PBP). It includes:
6
+
7
+ - `Composite`: A process orchestrator supporting nested processes, steps, and synchronization.
8
+ - `Step`: A process steps triggered by dependency updates.
9
+ - `Process`: A time-driven process unit.
10
+ - Utility functions for dependency tracking, merging, scheduling, and update application.
11
+
12
+ Used as part of the Vivarium 2.0 ecosystem for modular biological modeling.
13
+ """
14
+
15
+ import os
16
+ import copy
17
+ import json
18
+ import math
19
+ from typing import (
20
+ Any, Dict, List, Optional, Set, Tuple, Union,
21
+ Mapping, MutableMapping, Sequence,
22
+ Callable, Type
23
+ )
24
+ import collections
25
+
26
+ from bigraph_schema import (
27
+ Edge, Registry, TypeSystem, visit_method,
28
+ get_path, set_path, resolve_path, hierarchy_depth, deep_merge,
29
+ is_schema_key, strip_schema_keys)
30
+
31
+ from bigraph_schema.protocols import local_lookup_module
32
+
33
+
34
+ # =========================
35
+ # Process Utility Functions
36
+ # =========================
37
+
38
+ def assert_interface(interface: Dict[str, Any]) -> None:
39
+ """
40
+ Ensure that the interface dictionary contains both 'inputs' and 'outputs' keys.
41
+
42
+ Args:
43
+ interface: A dictionary describing a process interface.
44
+
45
+ Raises:
46
+ AssertionError: If required keys are missing or extra keys are present.
47
+ """
48
+ required_keys = {'inputs', 'outputs'}
49
+ existing_keys = set(interface.keys())
50
+ assert existing_keys == required_keys, (
51
+ f"Every interface requires exactly the keys 'inputs' and 'outputs', "
52
+ f"but found: {existing_keys}"
53
+ )
54
+
55
+
56
+ def find_instances(
57
+ state: Dict[str, Any],
58
+ instance_type: str = 'process_bigraph.composite.Process'
59
+ ) -> Dict[str, Any]:
60
+ """
61
+ Recursively find all dictionary entries that contain an 'instance' of the given type.
62
+
63
+ Args:
64
+ state: Nested state dictionary.
65
+ instance_type: Fully qualified path to the target class (e.g., 'module.Class').
66
+
67
+ Returns:
68
+ A dictionary of matching subtrees keyed by their path segment.
69
+ """
70
+ process_class = local_lookup_module(instance_type)
71
+ found: Dict[str, Any] = {}
72
+
73
+ for key, inner in state.items():
74
+ if isinstance(inner, dict):
75
+ if isinstance(inner.get('instance'), process_class):
76
+ found[key] = inner
77
+ elif not is_schema_key(key):
78
+ sub_instances = find_instances(inner, instance_type)
79
+ if sub_instances:
80
+ found[key] = sub_instances
81
+
82
+ return found
83
+
84
+
85
+ def find_instance_paths(
86
+ state: Dict[str, Any],
87
+ instance_type: str = 'process_bigraph.composite.Process'
88
+ ) -> Dict[Tuple[str, ...], Any]:
89
+ """
90
+ Find all paths to instances of a given type in the state.
91
+
92
+ Args:
93
+ state: The full nested state dictionary.
94
+ instance_type: Fully qualified class name to match.
95
+
96
+ Returns:
97
+ A dictionary mapping full paths (as tuples) to instance-containing subtrees.
98
+ """
99
+ instances = find_instances(state, instance_type)
100
+ return hierarchy_depth(instances)
101
+
102
+
103
+ def find_step_triggers(
104
+ path: Union[List[str], Tuple[str, ...]],
105
+ step: Dict[str, Any]
106
+ ) -> Dict[Tuple[str, ...], List[Union[List[str], Tuple[str, ...]]]]:
107
+ """
108
+ Identify which paths, when updated, should trigger the execution of a given step.
109
+
110
+ Args:
111
+ path: Path to the step in the composite model tree.
112
+ step: Step object containing an 'inputs' field with wire mappings.
113
+
114
+ Returns:
115
+ Mapping from trigger paths to lists of step paths that they trigger.
116
+ """
117
+ prefix = tuple(path[:-1])
118
+ triggers: Dict[Tuple[str, ...], List[Union[List[str], Tuple[str, ...]]]] = {}
119
+ wire_paths = find_leaves(step['inputs'])
120
+
121
+ for wire in wire_paths:
122
+ trigger_path = resolve_path(prefix + tuple(wire))
123
+ triggers.setdefault(trigger_path, []).append(path)
124
+
125
+ return triggers
126
+
127
+
128
+ def explode_path(path: Union[List[str], Tuple[str, ...]]) -> List[Tuple[str, ...]]:
129
+ """
130
+ Break a hierarchical path into all its prefix paths.
131
+
132
+ Example:
133
+ ('a', 'b', 'c') → [(), ('a',), ('a', 'b'), ('a', 'b', 'c')]
134
+
135
+ Args:
136
+ path: A tuple or list representing a path.
137
+
138
+ Returns:
139
+ A list of prefix paths.
140
+ """
141
+ explode: Tuple[str, ...] = ()
142
+ paths = [explode]
143
+ for node in path:
144
+ explode = explode + (node,)
145
+ paths.append(explode)
146
+ return paths
147
+
148
+
149
+ def merge_collections(
150
+ existing: Optional[MutableMapping[str, Any]],
151
+ new: Optional[MutableMapping[str, Any]]
152
+ ) -> MutableMapping[str, Any]:
153
+ """
154
+ Merge two nested structures (dicts or lists), combining compatible elements in-place.
155
+
156
+ Args:
157
+ existing: An existing collection to merge into.
158
+ new: A new collection to merge.
159
+
160
+ Returns:
161
+ The merged structure.
162
+
163
+ Raises:
164
+ Exception: If types are incompatible or mergeable fields conflict.
165
+ """
166
+ existing = existing or {}
167
+ new = new or {}
168
+
169
+ for key, value in new.items():
170
+ if key in existing:
171
+ if isinstance(existing[key], dict) and isinstance(value, Mapping):
172
+ merge_collections(existing[key], value)
173
+ elif isinstance(existing[key], list) and isinstance(value, Sequence):
174
+ existing[key].extend(value)
175
+ else:
176
+ raise Exception(
177
+ f"Cannot merge conflicting types or values for key '{key}':\n"
178
+ f"existing={existing[key]}\nnew={value}"
179
+ )
180
+ else:
181
+ existing[key] = value
182
+
183
+ return existing
184
+
185
+
186
+ def empty_front(time: float) -> Dict[str, Any]:
187
+ """
188
+ Generate a default front buffer for a process.
189
+
190
+ Args:
191
+ time: The current simulation time.
192
+
193
+ Returns:
194
+ A dictionary with time and an empty update field.
195
+ """
196
+ return {'time': time, 'update': {}}
197
+
198
+
199
+ def find_leaves(tree_structure, path=None):
200
+ """
201
+ Recursively find all leaf paths in a nested dictionary structure.
202
+
203
+ Args:
204
+ tree_structure (any): A nested structure of dicts/lists/tuples.
205
+ path (tuple or None): Current traversal path (for recursion).
206
+
207
+ Returns:
208
+ list: List of leaf paths as tuples.
209
+ """
210
+ leaves = []
211
+ path = ()
212
+
213
+ if tree_structure is None:
214
+ pass
215
+ elif isinstance(tree_structure, list):
216
+ leaves = tree_structure
217
+ elif isinstance(tree_structure, tuple):
218
+ leaves.append(tree_structure)
219
+ else:
220
+ for key, value in tree_structure.items():
221
+ if isinstance(value, dict):
222
+ subleaves = find_leaves(value, path + (key,))
223
+ leaves.extend(subleaves)
224
+ else:
225
+ leaves.append(path + tuple(value))
226
+
227
+ return leaves
228
+
229
+
230
+ def build_step_network(steps):
231
+ """
232
+ Build the data dependency graph among steps.
233
+
234
+ Args:
235
+ steps: A mapping of step identifiers to their instance/config.
236
+
237
+ Returns:
238
+ - ancestors: A mapping from step keys to their input/output paths.
239
+ - nodes: A mapping from paths to sets of steps that are dependent on them.
240
+ """
241
+ ancestors = {
242
+ step_key: {'input_paths': None, 'output_paths': None}
243
+ for step_key in steps
244
+ }
245
+ nodes = {}
246
+
247
+ for step_key, step in steps.items():
248
+ schema = step['instance'].interface()
249
+ assert_interface(schema)
250
+
251
+ # Compute input paths once per step
252
+ if ancestors[step_key]['input_paths'] is None:
253
+ ancestors[step_key]['input_paths'] = find_leaves(step['inputs'])
254
+
255
+ # Compute output paths once per step
256
+ if ancestors[step_key]['output_paths'] is None:
257
+ ancestors[step_key]['output_paths'] = find_leaves(step.get('outputs', {}))
258
+
259
+ input_paths = ancestors[step_key]['input_paths'] or []
260
+ output_paths = ancestors[step_key]['output_paths'] or []
261
+
262
+ # Track which steps consume/produce each path
263
+ for input_path in input_paths:
264
+ path = tuple(input_path)
265
+ nodes.setdefault(path, {'before': set(), 'after': set()})
266
+ nodes[path]['after'].add(step_key)
267
+
268
+ for output_path in output_paths:
269
+ if output_path not in input_paths:
270
+ path = tuple(output_path)
271
+ nodes.setdefault(path, {'before': set(), 'after': set()})
272
+ nodes[path]['before'].add(step_key)
273
+
274
+ return ancestors, nodes
275
+
276
+
277
+ def build_trigger_state(nodes):
278
+ """
279
+ Initialize the trigger state from dependency nodes.
280
+
281
+ Args:
282
+ nodes: Dependency graph nodes with 'before' and 'after' sets.
283
+
284
+ Returns:
285
+ A mapping of paths to the set of steps waiting on those paths.
286
+ """
287
+ return {key: value['before'].copy() for key, value in nodes.items()}
288
+
289
+
290
+ def find_downstream(steps, nodes, upstream):
291
+ """
292
+ Given a set of updated steps, identify all downstream steps that depend on them.
293
+
294
+ Args:
295
+ steps: Step metadata with input/output info.
296
+ nodes: Dependency graph.
297
+ upstream: Initial set of triggered step paths.
298
+
299
+ Returns:
300
+ Set of all steps affected directly or transitively.
301
+ """
302
+ downstream = set(upstream)
303
+ visited = set([])
304
+ previous_len = -1
305
+
306
+ while len(downstream) > len(visited) and len(visited) > previous_len:
307
+ previous_len = len(visited)
308
+ down = set([])
309
+ for step_path in downstream:
310
+ if step_path not in visited:
311
+ step_outputs = steps[step_path]['output_paths']
312
+ if step_outputs is None:
313
+ step_outputs = [] # Ensure step_outputs is always an iterable
314
+ for output in step_outputs:
315
+ for subpath in explode_path(output):
316
+ if subpath in nodes:
317
+ for dependent in nodes[subpath]['after']:
318
+ down.add(dependent)
319
+ visited.add(step_path)
320
+ downstream |= down
321
+
322
+ return downstream
323
+
324
+
325
+ def determine_steps(steps, remaining, fulfilled):
326
+ """
327
+ Determine which steps are eligible to run, based on current fulfilled triggers.
328
+
329
+ Args:
330
+ steps: Step metadata.
331
+ remaining: Set of step paths not yet run.
332
+ fulfilled: Map of data paths to steps waiting for fulfillment.
333
+
334
+ Returns:
335
+ - to_run: List of ready step paths.
336
+ - remaining: Updated remaining steps.
337
+ - fulfilled: Updated fulfilled structure.
338
+ """
339
+ to_run = []
340
+
341
+ for step_path in list(remaining):
342
+ step_inputs = steps[step_path].get('input_paths', []) or []
343
+ if all(len(fulfilled[input]) == 0 for input in step_inputs):
344
+ to_run.append(step_path)
345
+
346
+ for step_path in to_run:
347
+ remaining.remove(step_path)
348
+ step_outputs = steps[step_path].get('output_paths', []) or []
349
+ for output in step_outputs:
350
+ if step_path in fulfilled.get(output, set()):
351
+ fulfilled[output].remove(step_path)
352
+
353
+ return to_run, remaining, fulfilled
354
+
355
+
356
+ def interval_time_precision(timestep: float) -> int:
357
+ """
358
+ Compute the number of decimal places required to represent the given timestep.
359
+
360
+ Args:
361
+ timestep: Time interval as float.
362
+
363
+ Returns:
364
+ The number of digits after the decimal point.
365
+ """
366
+ return len(str(timestep).split('.')[1]) if '.' in str(timestep) else 0
367
+
368
+
369
+ # ===============
370
+ # Process Classes
371
+ # ===============
372
+
373
+ class SyncUpdate:
374
+ """
375
+ Wrapper for synchronous process updates.
376
+
377
+ This object encapsulates an update dictionary and provides a `.get()` method
378
+ for compatibility with deferred or lazy update execution pipelines.
379
+ """
380
+
381
+ def __init__(self, update: Dict[str, Any]) -> None:
382
+ """
383
+ Args:
384
+ update: The process update to wrap.
385
+ """
386
+ self.update = update
387
+
388
+ def get(self) -> Dict[str, Any]:
389
+ """
390
+ Returns:
391
+ The stored process update.
392
+ """
393
+ return self.update
394
+
395
+
396
+ class Open(Edge):
397
+ METHOD_COMMANDS = (
398
+ 'initial_state', 'inputs', 'outputs', 'update')
399
+
400
+ ATTRIBUTE_READ_COMMANDS = (
401
+ 'config', 'composition', 'state')
402
+
403
+
404
+ def __init__(self, config=None, core=None):
405
+ self._command_result: Any = None
406
+ self._pending_command: Optional[
407
+ Tuple[str, Optional[tuple], Optional[dict]]] = None
408
+
409
+ super().__init__(config, core=core)
410
+
411
+ def pre_send_command(
412
+ self, command: str, args: Optional[tuple], kwargs:
413
+ Optional[dict]) -> None:
414
+ '''Run pre-checks before starting a command.
415
+
416
+ This method should be called at the start of every
417
+ implementation of :py:meth:`send_command`.
418
+
419
+ Args:
420
+ command: The name of the command to run.
421
+ args: A tuple of positional arguments for the command.
422
+ kwargs: A dictionary of keyword arguments for the command.
423
+
424
+ Raises:
425
+ RuntimeError: Raised when a user tries to send a command
426
+ while a previous command is still pending (i.e. the user
427
+ hasn't called :py:meth:`get_command_result` yet for the
428
+ previous command).
429
+ '''
430
+ if self._pending_command:
431
+ raise RuntimeError(
432
+ f'Trying to send command {(command, args, kwargs)} but '
433
+ f'command {self._pending_command} is still pending.')
434
+ self._pending_command = command, args, kwargs
435
+
436
+
437
+ def send_command(
438
+ self, command: str, args: Optional[tuple] = None,
439
+ kwargs: Optional[dict] = None,
440
+ run_pre_check: bool = True) -> None:
441
+ '''Handle :term:`process commands`.
442
+
443
+ This method handles the commands listed in
444
+ :py:attr:`METHOD_COMMANDS` by passing ``args``
445
+ and ``kwargs`` to the method of ``self`` with the name
446
+ of the command and saving the return value as the result.
447
+
448
+ This method handles the commands listed in
449
+ :py:attr:`ATTRIBUTE_READ_COMMANDS` by returning the attribute of
450
+ ``self`` with the name matching the command, and it handles the
451
+ commands listed in :py:attr:`ATTRIBUTE_WRITE_COMMANDS` by
452
+ setting the attribute in the command to the first argument in
453
+ ``args``. The command must be named ``set_attr`` for attribute
454
+ ``attr``.
455
+
456
+ To add support for a custom command, override this function in
457
+ your subclass. Each command is defined by a name (a string)
458
+ and accepts both positional and keyword arguments. Any custom
459
+ commands you add should have associated methods such that:
460
+
461
+ * The command name matches the method name.
462
+ * The command and method accept the same positional and keyword
463
+ arguments.
464
+ * The command and method return the same values.
465
+
466
+ If all of the above are satisfied, you can use
467
+ :py:meth:`Process.run_command_method` to handle the command.
468
+
469
+ Your implementation of this function needs to handle all the
470
+ commands you want to support. When presented with an unknown
471
+ command, you should call the superclass method, which will
472
+ either handle the command or call its superclass method. At the
473
+ top of this recursive chain, this ``Process.send_command()``
474
+ method handles some built-in commands and will raise an error
475
+ for unknown commands.
476
+
477
+ Any overrides of this method must also call
478
+ :py:meth:`pre_send_command` at the start of the method. This
479
+ call will check that no command is currently pending to avoid
480
+ confusing behavior when multiple commands are started without
481
+ intervening retrievals of command results. Since your overriding
482
+ method will have already performed the pre-check, it should pass
483
+ ``run_pre_check=False`` when calling the superclass method.
484
+
485
+ Args:
486
+ command: The name of the command to run.
487
+ args: A tuple of positional arguments for the command.
488
+ kwargs: A dictionary of keyword arguments for the command.
489
+ run_pre_check: Whether to run the pre-checks implemented in
490
+ :py:meth:`pre_send_command`. This should be left at its
491
+ default value unless the pre-checks have already been
492
+ performed (e.g. if this method is being called by a
493
+ subclass's overriding method.)
494
+
495
+ Returns:
496
+ None. This method just starts the command running.
497
+
498
+ Raises:
499
+ ValueError: For unknown commands.
500
+ '''
501
+ if run_pre_check:
502
+ self.pre_send_command(command, args, kwargs)
503
+ args = args or tuple()
504
+ kwargs = kwargs or {}
505
+ if command in self.METHOD_COMMANDS:
506
+ self._command_result = self.run_command_method(
507
+ command, args, kwargs)
508
+ elif command in self.ATTRIBUTE_READ_COMMANDS:
509
+ self._command_result = getattr(self, command)
510
+ # elif command in self.ATTRIBUTE_WRITE_COMMANDS:
511
+ # assert command.startswith('set_')
512
+ # assert args
513
+ # setattr(self, command[len('set_'):], args[0])
514
+ else:
515
+ raise ValueError(
516
+ f'Process {self} does not understand the process '
517
+ f'command {command}')
518
+
519
+ def run_command_method(
520
+ self, command: str, args: tuple, kwargs: dict) -> Any:
521
+ '''Run a command whose name and interface match a method.
522
+
523
+ Args:
524
+ command: The command name, which must equal to a method of
525
+ ``self``.
526
+ args: The positional arguments to pass to the method.
527
+ kwargs: The keywords arguments for the method.
528
+
529
+ Returns:
530
+ The result of calling ``self.command(*args, **kwargs)`` is
531
+ returned for command ``command``.
532
+ '''
533
+ return getattr(self, command)(*args, **kwargs)
534
+
535
+ def get_command_result(self) -> Any:
536
+ '''Retrieve the result from the last-run command.
537
+
538
+ Returns:
539
+ The result of the last command run. Note that this method
540
+ should only be called once immediately after each call to
541
+ :py:meth:`send_command`.
542
+
543
+ Raises:
544
+ RuntimeError: When there is no command pending. This can
545
+ happen when this method is called twice without an
546
+ intervening call to :py:meth:`send_command`.
547
+ '''
548
+ if not self._pending_command:
549
+ raise RuntimeError(
550
+ 'Trying to retrieve command result, but no command is '
551
+ 'pending.')
552
+ self._pending_command = None
553
+ result = self._command_result
554
+ self._command_result = None
555
+ return result
556
+
557
+ def run_command(
558
+ self, command: str, args: Optional[tuple] = None,
559
+ kwargs: Optional[dict] = None) -> Any:
560
+ '''Helper function that sends a command and returns result.'''
561
+ self.send_command(command, args, kwargs)
562
+ return self.get_command_result()
563
+
564
+
565
+ class Step(Open):
566
+ """
567
+ Step base class.
568
+
569
+ A `Step` is a stateless, non-temporal computational unit within a composite process.
570
+ It is triggered when its data dependencies are satisfied, functioning like a reaction
571
+ or transformation rule.
572
+
573
+ Override the `.update()` method to define custom behavior.
574
+ """
575
+ # TODO: support trigger every time as well as dependency trigger
576
+
577
+ def invoke(self, state: Dict[str, Any], _: Optional[float] = None) -> SyncUpdate:
578
+ """
579
+ Run the step using the given state and return its update.
580
+
581
+ Args:
582
+ state: The input state to compute the update from.
583
+ _: Ignored time interval placeholder (not used by steps).
584
+
585
+ Returns:
586
+ A SyncUpdate object containing the update dictionary.
587
+ """
588
+ update = self.update(state)
589
+ return SyncUpdate(update)
590
+
591
+ def register_shared(self, instance):
592
+ """
593
+ Register a reference to a shared instance, e.g., for access to core or context.
594
+
595
+ Args:
596
+ instance: A reference to the external object being shared with the step.
597
+ """
598
+ self.instance = instance
599
+
600
+ def update(self, state: Dict[str, Any], interval=None) -> Dict[str, Any]:
601
+ """
602
+ Compute and return the update for the step.
603
+
604
+ Override this method in subclasses to define the step's logic.
605
+
606
+ Args:
607
+ state: The current simulation state at the step's inputs.
608
+
609
+ Returns:
610
+ A dictionary representing the update to apply.
611
+ """
612
+ return {}
613
+
614
+
615
+ class Process(Open):
616
+ """
617
+ Process base class.
618
+
619
+ A `Process` is a temporal unit of computation that operates on state and advances in time.
620
+ Each subclass must implement the `update()` method and optionally the `invoke()` method.
621
+
622
+ Processes are stateful and typically used for simulations of continuous or discrete dynamics.
623
+ """
624
+
625
+ def invoke(self, state: Dict[str, Any], interval: float):
626
+ """
627
+ Execute the process update for a given state and time interval.
628
+
629
+ Args:
630
+ state: The current simulation state for this process.
631
+ interval: The time step over which to apply the update.
632
+
633
+ Returns:
634
+ A SyncUpdate containing the update result.
635
+ """
636
+ update = self.update(state, interval)
637
+ return SyncUpdate(update)
638
+
639
+ def update(self, state: Dict[str, Any], interval: float) -> Dict[str, Any]:
640
+ """
641
+ Override this method to implement the process logic.
642
+
643
+ Args:
644
+ state: The current simulation state at the process ports.
645
+ interval: The time step over which to simulate.
646
+
647
+ Returns:
648
+ A dictionary representing the update to apply to the state.
649
+ """
650
+ return {}
651
+
652
+
653
+ def as_step(inputs, outputs, core=None):
654
+ """
655
+ Decorator to create a Step from a function named update_*.
656
+ If core is provided, registers under the name *.
657
+ """
658
+ def decorator(func):
659
+ assert func.__name__.startswith('update_'), "Function name must be of the form update_*"
660
+ step_name = func.__name__[len('update_'):]
661
+
662
+ class FunctionStep(Step):
663
+ def inputs(self):
664
+ return inputs
665
+
666
+ def outputs(self):
667
+ return outputs
668
+
669
+ def update(self, state):
670
+ return func(state)
671
+
672
+ FunctionStep.__name__ = step_name + 'Step'
673
+
674
+ if core is not None:
675
+ core.register_process(step_name, FunctionStep)
676
+
677
+ return FunctionStep
678
+
679
+ return decorator
680
+
681
+
682
+ def as_process(inputs, outputs, core=None):
683
+ """
684
+ Decorator to create a Process from a function named update_*.
685
+ If core is provided, registers under the name *.
686
+ """
687
+ def decorator(func):
688
+ assert func.__name__.startswith('update_'), "Function name must be of the form update_*"
689
+ process_name = func.__name__[len('update_'):]
690
+
691
+ class FunctionProcess(Process):
692
+ def __init__(self, config=None, core=None):
693
+ super().__init__(config=config, core=core)
694
+
695
+ def inputs(self):
696
+ return inputs
697
+
698
+ def outputs(self):
699
+ return outputs
700
+
701
+ def update(self, state, interval):
702
+ return func(state, interval)
703
+
704
+ FunctionProcess.__name__ = process_name + 'Process'
705
+
706
+ if core is not None:
707
+ core.register_process(process_name, FunctionProcess)
708
+
709
+ return FunctionProcess
710
+
711
+ return decorator
712
+
713
+
714
+ class ProcessEnsemble(Process):
715
+ """
716
+ ProcessEnsemble base class.
717
+
718
+ A container for multiple sub-processes that exposes a combined interface by unifying
719
+ their inputs and outputs. Useful when combining multiple related processes into a single one.
720
+ """
721
+
722
+ def __init__(self, config: Optional[Dict[str, Any]] = None, core: Optional[Any] = None) -> None:
723
+ """
724
+ Args:
725
+ config: Configuration dictionary for the ensemble process.
726
+ core: Optional shared core/context for schema operations and initialization.
727
+ """
728
+ super().__init__(config=config, core=core)
729
+
730
+ def union_interface(self) -> Dict[str, Dict[str, Any]]:
731
+ """
732
+ Generate a unified interface by combining all inputs_*/outputs_* methods defined in the subclass.
733
+
734
+ Returns:
735
+ A dictionary with 'inputs' and 'outputs' schemas merged from all sub-process interfaces.
736
+ """
737
+ union_inputs: Dict[str, Any] = {}
738
+ union_outputs: Dict[str, Any] = {}
739
+
740
+ for attr_name in dir(self):
741
+ if attr_name.startswith('inputs_'):
742
+ inputs_func = getattr(self, attr_name)
743
+ if callable(inputs_func):
744
+ inputs = inputs_func()
745
+ union_inputs = self.core.resolve_schemas(union_inputs, inputs)
746
+
747
+ if attr_name.startswith('outputs_'):
748
+ outputs_func = getattr(self, attr_name)
749
+ if callable(outputs_func):
750
+ outputs = outputs_func()
751
+ union_outputs = self.core.resolve_schemas(union_outputs, outputs)
752
+
753
+ return {
754
+ 'inputs': union_inputs,
755
+ 'outputs': union_outputs
756
+ }
757
+
758
+
759
+ class Defer:
760
+ """
761
+ Defer a computation by holding a reference to a function and its arguments
762
+ until a later time when `.get()` is called.
763
+
764
+ This is used to delay the application of a function to a value until all
765
+ required data is available, typically used for processing deferred updates
766
+ in simulation pipelines.
767
+
768
+ Attributes:
769
+ defer (SupportsGet): An object that supports `.get()` and returns the input to the function.
770
+ f (Callable[[Any, Any], Any]): A binary function to apply to the result of `defer.get()` and `args`.
771
+ args (Any): Arguments passed to the function alongside the deferred value.
772
+ """
773
+
774
+ def __init__(
775
+ self,
776
+ defer,
777
+ f,
778
+ args
779
+ ) -> None:
780
+ """
781
+ Args:
782
+ defer: Any object that implements `.get()` and returns a value.
783
+ f: A function that takes two arguments: the result of `defer.get()` and `args`.
784
+ args: A secondary argument passed to `f`.
785
+ """
786
+ self.defer = defer
787
+ self.f = f
788
+ self.args = args
789
+
790
+ def get(self) -> Any:
791
+ """
792
+ Perform the deferred computation by calling the stored function with the
793
+ deferred result and provided arguments.
794
+
795
+ Returns:
796
+ The result of `f(defer.get(), args)`.
797
+ """
798
+ return self.f(self.defer.get(), self.args)
799
+
800
+
801
+ def match_star_path(
802
+ path: Tuple[str, ...],
803
+ star_path: Tuple[str, ...]
804
+ ) -> bool:
805
+ """
806
+ Compare two paths where elements in `star_path` may contain wildcards (*).
807
+
808
+ Args:
809
+ path: A tuple representing the actual path (e.g., ('cells', 'A', 'growth')).
810
+ star_path: A tuple that may contain '*' wildcards to match any segment.
811
+
812
+ Returns:
813
+ True if the paths match, treating '*' as a wildcard; False otherwise.
814
+
815
+ Example:
816
+ match_star_path(('cells', 'A', 'growth'), ('cells', '*', 'growth')) # True
817
+ match_star_path(('cells', 'A'), ('cells', '*', 'growth')) # False
818
+ """
819
+ for element, star_element in zip(path, star_path):
820
+ if star_element != "*" and element != star_element:
821
+ return False
822
+ return True
823
+
824
+
825
+ class Composite(Process):
826
+ """
827
+ A Composite process contains a dynamic network of child Processes and Steps
828
+ connected via a schema and bridge. It manages time, state, dependencies, and
829
+ update propagation during simulation.
830
+ """
831
+
832
+ config_schema = {
833
+ 'composition': 'schema',
834
+ 'state': 'tree[any]',
835
+ 'interface': {
836
+ 'inputs': 'schema',
837
+ 'outputs': 'schema'
838
+ },
839
+ 'bridge': {
840
+ 'inputs': 'wires',
841
+ 'outputs': 'wires'
842
+ },
843
+ 'global_time_precision': 'maybe[float]'
844
+ }
845
+
846
+
847
+ # ==============================
848
+ # Initialization & Configuration
849
+ # ==============================
850
+
851
+ def initialize(self, config: Optional[Dict[str, Any]] = None) -> None:
852
+ """
853
+ Initialize the composite model from its config.
854
+
855
+ This method:
856
+ - Adds `global_time` to schema/state if missing
857
+ - Generates the full composition/state tree
858
+ - Finds all step/process instances
859
+ - Resolves the schema bridge
860
+ - Prepares the step execution network
861
+ - Computes initial front (per-process timeline)
862
+
863
+ Args:
864
+ config: Optional override configuration (usually not needed).
865
+ """
866
+
867
+ # Get the initial composition schema from config.
868
+ initial_composition = self.config.get('composition', {})
869
+
870
+ # Ensure 'global_time' is explicitly declared in the schema.
871
+ if 'global_time' not in initial_composition:
872
+ initial_composition['global_time'] = 'float'
873
+
874
+ # Get the initial state from config.
875
+ initial_state = self.config.get('state', {})
876
+
877
+ # Ensure the initial simulation state has a global_time initialized.
878
+ if 'global_time' not in initial_state:
879
+ initial_state['global_time'] = 0.0
880
+
881
+ # Generate internal schema and state structures using the core engine.
882
+ self.composition, self.state = self.core.generate(
883
+ initial_composition,
884
+ initial_state)
885
+
886
+ # Load the bridge configuration, which defines how inputs/outputs connect to the world.
887
+ self.bridge = self.config.get('bridge', {})
888
+
889
+ # initialize an empty front for finding the instance paths
890
+ self.front = {}
891
+
892
+ # Identify all Process and Step instances in the state tree.
893
+ self.find_instance_paths(self.state)
894
+
895
+ # Merge both process and step paths into a single edge dictionary.
896
+ self.edge_paths = {**self.process_paths, **self.step_paths}
897
+
898
+ # Initialize each process/step's state and accumulate it into a unified state tree.
899
+ edge_state: Dict[str, Any] = {}
900
+ for path, edge in self.edge_paths.items():
901
+ # Generate the initial state for this specific edge (process or step).
902
+ initial = self.core.initialize_edge_state(
903
+ self.composition,
904
+ path,
905
+ edge)
906
+
907
+ # Merge the new edge state with the global state tree, checking for conflicts.
908
+ try:
909
+ edge_state = deep_merge(edge_state, initial)
910
+ except Exception:
911
+ raise Exception(
912
+ f'initial state from edge does not match initial state from other edges:\n'
913
+ f'{path}\n{edge}\n{edge_state}'
914
+ )
915
+
916
+ # Apply the merged edge_state into the global state and update instance paths.
917
+ self.merge(self.composition, edge_state)
918
+
919
+ # Wire the input/output schema for the Composite from the bridge config.
920
+ self.process_schema = {
921
+ port: self.core.wire_schema(self.composition, self.bridge[port])
922
+ for port in ['inputs', 'outputs']
923
+ }
924
+
925
+ # Set the global time precision used to round step time advances.
926
+ self.global_time_precision = self.config.get('global_time_precision')
927
+
928
+ # Initialize a "front" dictionary tracking the next update time and update data per process.
929
+ self.front = {
930
+ path: empty_front(self.state['global_time'])
931
+ for path in self.process_paths
932
+ }
933
+
934
+ # A buffer for updates to be emitted at the composite's output interface.
935
+ self.bridge_updates: List[Any] = []
936
+
937
+ # Build the dependency network between steps and determine which steps should run first.
938
+ self.build_step_network()
939
+
940
+ # Run all steps that are ready on the first cycle.
941
+ self.run_steps(self.to_run)
942
+
943
+ @classmethod
944
+ def load(cls, path: str, core: Optional[Any] = None) -> "Composite":
945
+ """
946
+ Load a Composite from a saved JSON file.
947
+
948
+ Args:
949
+ path: Path to the saved composition file.
950
+ core: Optional core context providing deserialization.
951
+
952
+ Returns:
953
+ A new Composite instance.
954
+ """
955
+ with open(path) as data:
956
+ document = json.load(data)
957
+ composition = document['composition']
958
+ document['composition'] = core.deserialize('schema', composition)
959
+ return cls(document, core=core)
960
+
961
+ def clean_front(self, state):
962
+ self.find_instance_paths(state)
963
+
964
+ def find_instance_paths(self, state: Dict[str, Any]) -> None:
965
+ """
966
+ Identify all Step and Process instances in the current state.
967
+
968
+ Populates:
969
+ - self.process_paths
970
+ - self.step_paths
971
+ """
972
+ self.process_paths = find_instance_paths(state, 'process_bigraph.composite.Process')
973
+ self.step_paths = find_instance_paths(state, 'process_bigraph.composite.Step')
974
+
975
+ all_paths = set(
976
+ list(self.process_paths.keys()) +
977
+ list(self.step_paths.keys()))
978
+
979
+ front_paths = set(
980
+ self.front.keys())
981
+
982
+ for removed_key in front_paths.difference(all_paths):
983
+ # do we want to do anything with these?
984
+ removed_front = self.front.pop(removed_key)
985
+
986
+ def merge(self, schema: Dict[str, Any], state: Dict[str, Any], path: Optional[List[str]] = None) -> None:
987
+ """
988
+ Merge a new schema/state subtree into the Composite.
989
+
990
+ Args:
991
+ schema: Schema dictionary to merge.
992
+ state: State dictionary to merge.
993
+ path: Path where merge should occur (default: root).
994
+ """
995
+ path = path or []
996
+ self.composition, self.state = self.core.merge(
997
+ self.composition,
998
+ self.state,
999
+ path,
1000
+ schema,
1001
+ state)
1002
+ self.find_instance_paths(self.state)
1003
+
1004
+ def merge_schema(
1005
+ self,
1006
+ schema: Dict[str, Any],
1007
+ path: Optional[List[str]] = None
1008
+ ) -> None:
1009
+ """
1010
+ Merge a new schema subtree into the current composite schema and regenerate state.
1011
+
1012
+ Args:
1013
+ schema: The schema subtree to merge.
1014
+ path: Optional path at which to merge the schema (defaults to root).
1015
+ """
1016
+ path = path or []
1017
+
1018
+ # Set the new schema subtree at the given path
1019
+ scoped_schema = set_path({}, path, schema)
1020
+
1021
+ # Merge it into the existing schema
1022
+ self.composition = self.core.merge_schemas(self.composition, scoped_schema)
1023
+
1024
+ # Re-generate state based on the new schema structure
1025
+ self.composition, self.state = self.core.generate(self.composition, self.state)
1026
+
1027
+ # Re-scan the state tree for processes and steps
1028
+ self.find_instance_paths(self.state)
1029
+
1030
+ def apply(self, update: Dict[str, Any], path: Optional[List[str]] = None) -> None:
1031
+ """
1032
+ Apply an update to the current state.
1033
+
1034
+ Args:
1035
+ update: A state update dictionary.
1036
+ path: Optional path to scope the update under.
1037
+ """
1038
+ path = path or []
1039
+ scoped_update = set_path({}, path, update)
1040
+ self.state = self.core.apply(self.composition, self.state, scoped_update)
1041
+ self.find_instance_paths(self.state)
1042
+
1043
+
1044
+ # ===================
1045
+ # Serialization & I/O
1046
+ # ===================
1047
+
1048
+ def serialize_state(self) -> Dict[str, Any]:
1049
+ """
1050
+ Serialize the internal state using the core serializer.
1051
+
1052
+ Returns:
1053
+ A serialized representation of the current state.
1054
+ """
1055
+ return self.core.serialize(self.composition, self.state)
1056
+
1057
+ def serialize_schema(self) -> Dict[str, Any]:
1058
+ """
1059
+ Serialize the composition (schema) using the core serializer.
1060
+
1061
+ Returns:
1062
+ A serialized schema representation.
1063
+ """
1064
+ return self.core.serialize('schema', self.composition)
1065
+
1066
+ def save(
1067
+ self,
1068
+ filename: str = 'composite.json',
1069
+ outdir: str = 'out',
1070
+ schema: bool = False,
1071
+ state: bool = False
1072
+ ) -> None:
1073
+ """
1074
+ Save the composite to a JSON file.
1075
+
1076
+ Args:
1077
+ filename: Output filename.
1078
+ outdir: Output directory.
1079
+ schema: Whether to include the serialized schema.
1080
+ state: Whether to include the serialized state.
1081
+ """
1082
+ if not schema and not state:
1083
+ schema = state = True
1084
+
1085
+ document = {}
1086
+ if state:
1087
+ document['state'] = self.serialize_state()
1088
+ if schema:
1089
+ document['composition'] = self.serialize_schema()
1090
+
1091
+ os.makedirs(outdir, exist_ok=True)
1092
+ filepath = os.path.join(outdir, filename)
1093
+ with open(filepath, 'w') as f:
1094
+ json.dump(document, f, indent=4)
1095
+ print(f"Saved composite to {filepath}")
1096
+
1097
+
1098
+ # ==================
1099
+ # Interface & Wiring
1100
+ # ==================
1101
+
1102
+ def inputs(self) -> Dict[str, Any]:
1103
+ """Return the composite's input schema (wired to the bridge)."""
1104
+ return self.process_schema.get('inputs', {})
1105
+
1106
+ def outputs(self) -> Dict[str, Any]:
1107
+ """Return the composite's output schema (wired to the bridge)."""
1108
+ return self.process_schema.get('outputs', {})
1109
+
1110
+ def read_bridge(self, state: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
1111
+ """
1112
+ View the external bridge output ports using the current or provided state.
1113
+
1114
+ This method uses the composite's interface and bridge configuration to extract
1115
+ the substate that corresponds to external output ports.
1116
+
1117
+ Args:
1118
+ state: Optional state dictionary. If not provided, uses `self.state`.
1119
+
1120
+ Returns:
1121
+ A dictionary of output values from the bridge view, or None if not found.
1122
+ """
1123
+ state = state or self.state
1124
+
1125
+ bridge_view = self.core.view(
1126
+ self.interface()['outputs'],
1127
+ self.bridge['outputs'],
1128
+ (),
1129
+ top_schema=self.composition,
1130
+ top_state=state
1131
+ )
1132
+
1133
+ return bridge_view
1134
+
1135
+
1136
+ # =======================
1137
+ # Step Network Management
1138
+ # =======================
1139
+
1140
+ def build_step_network(self) -> None:
1141
+ """
1142
+ Construct the internal dependency network for all registered steps.
1143
+
1144
+ This includes:
1145
+ - Finding trigger paths for each step based on their input wires
1146
+ - Registering wildcard triggers (e.g. `*`-based patterns)
1147
+ - Building a graph of data dependencies between steps
1148
+ - Initializing tracking structures for trigger state and pending steps
1149
+ - Populating the `self.to_run` queue with steps ready to execute
1150
+ """
1151
+ self.step_triggers: Dict[Tuple[str, ...], List[Union[str, Tuple[str, ...]]]] = {}
1152
+ self.star_triggers: Dict[Tuple[str, ...], List[Union[str, Tuple[str, ...]]]] = {}
1153
+
1154
+ # Collect triggers for each step's input schema
1155
+ for step_path, step in self.step_paths.items():
1156
+ step_triggers = find_step_triggers(step_path, step)
1157
+ self.step_triggers = merge_collections(self.step_triggers, step_triggers)
1158
+
1159
+ # Identify wildcard-based triggers (those containing '*')
1160
+ for trigger_path in self.step_triggers:
1161
+ if "*" in trigger_path:
1162
+ self.star_triggers[trigger_path] = self.step_triggers[trigger_path]
1163
+
1164
+ # Track which steps have already executed in the current cycle
1165
+ self.steps_run: Set[Union[str, Tuple[str, ...]]] = set()
1166
+
1167
+ # Build the step execution dependency graph
1168
+ self.step_dependencies, self.node_dependencies = build_step_network(self.step_paths)
1169
+
1170
+ # Initialize trigger fulfillment state and steps remaining
1171
+ self.reset_step_state(self.step_paths)
1172
+
1173
+ # Compute the initial set of runnable steps
1174
+ self.to_run = self.cycle_step_state()
1175
+
1176
+ self.clean_front(self.state)
1177
+
1178
+ def reset_step_state(
1179
+ self,
1180
+ step_paths: Dict[Union[str, Tuple[str, ...]], Any]
1181
+ ) -> None:
1182
+ """
1183
+ Reset the trigger tracking state for a given set of steps.
1184
+
1185
+ Args:
1186
+ step_paths: A dictionary of step paths (as keys).
1187
+ """
1188
+ # Start with a fresh trigger state from the dependency graph
1189
+ self.trigger_state = build_trigger_state(self.node_dependencies)
1190
+
1191
+ # Track steps still waiting to be executed in this cycle
1192
+ self.steps_remaining: Set[Union[str, Tuple[str, ...]]] = set(step_paths)
1193
+
1194
+ def cycle_step_state(self) -> List[Union[str, Tuple[str, ...]]]:
1195
+ """
1196
+ Evaluate the current trigger state and determine which steps can run.
1197
+
1198
+ Returns:
1199
+ A list of step paths that are ready to be invoked in this cycle.
1200
+ """
1201
+ to_run, self.steps_remaining, self.trigger_state = determine_steps(
1202
+ self.step_dependencies,
1203
+ self.steps_remaining,
1204
+ self.trigger_state
1205
+ )
1206
+ return to_run
1207
+
1208
+ def trigger_steps(self, update_paths: List[Tuple[str, ...]]) -> None:
1209
+ """
1210
+ Determine and run step processes triggered by recent state updates.
1211
+
1212
+ Args:
1213
+ update_paths: Paths in the state that were updated.
1214
+ """
1215
+ steps_to_run: List[Tuple[str, ...]] = []
1216
+
1217
+ for update_path in update_paths:
1218
+ for path in explode_path(update_path):
1219
+ # Check direct trigger matches
1220
+ step_paths = self.step_triggers.get(path, [])
1221
+
1222
+ # Also handle wildcard (*) path matches
1223
+ if self.star_triggers:
1224
+ for star_trigger, star_steps in self.star_triggers.items():
1225
+ if match_star_path(path, star_trigger):
1226
+ step_paths.extend(star_steps)
1227
+
1228
+ # Add unrun steps to the execution queue
1229
+ for step_path in step_paths:
1230
+ if step_path is not None and step_path not in self.steps_run:
1231
+ steps_to_run.append(step_path)
1232
+ self.steps_run.add(step_path)
1233
+
1234
+ # Identify downstream steps dependent on triggered ones
1235
+ steps_to_run = find_downstream(
1236
+ self.step_dependencies,
1237
+ self.node_dependencies,
1238
+ steps_to_run
1239
+ )
1240
+
1241
+ self.reset_step_state(steps_to_run)
1242
+ to_run = self.cycle_step_state()
1243
+ self.run_steps(to_run)
1244
+
1245
+ def run_steps(self, step_paths: List[Tuple[str, ...]]) -> None:
1246
+ """
1247
+ Execute a list of step processes, apply their updates, and handle cascading triggers.
1248
+
1249
+ Args:
1250
+ step_paths: A list of step path tuples to run.
1251
+ """
1252
+ if step_paths:
1253
+ updates = []
1254
+
1255
+ for step_path in step_paths:
1256
+ step = get_path(self.state, step_path)
1257
+ state = self.core.view_edge(
1258
+ self.composition, self.state, step_path, 'inputs'
1259
+ )
1260
+
1261
+ # Steps are always invoked with interval = -1.0
1262
+ step_update = self.process_update(
1263
+ step_path, step, state, -1.0, 'outputs'
1264
+ )
1265
+ updates.append(step_update)
1266
+
1267
+ update_paths = self.apply_updates(updates)
1268
+ self.expire_process_paths(update_paths)
1269
+
1270
+ to_run = self.cycle_step_state()
1271
+
1272
+ if to_run:
1273
+ self.run_steps(to_run)
1274
+ else:
1275
+ self.steps_run = set()
1276
+ else:
1277
+ self.steps_run = set()
1278
+
1279
+
1280
+ # ====================
1281
+ # Simulation Execution
1282
+ # ====================
1283
+
1284
+ def run(self, interval: float, force_complete: bool = False) -> None:
1285
+ """
1286
+ Advance simulation by running processes until a target time is reached.
1287
+
1288
+ The method loops through all registered processes and executes their updates
1289
+ incrementally based on their configured interval. Updates are applied and
1290
+ steps are triggered accordingly.
1291
+
1292
+ Args:
1293
+ interval: Time interval to simulate.
1294
+ force_complete: If True, forces all processes to reach the end time.
1295
+ """
1296
+ end_time = self.state['global_time'] + interval
1297
+
1298
+ while self.state['global_time'] < end_time or force_complete:
1299
+ full_step = math.inf
1300
+
1301
+ # Run each process and compute the minimum time step that advances simulation
1302
+ for path in self.process_paths:
1303
+ process = get_path(self.state, path)
1304
+ full_step = self.run_process(
1305
+ path, process, end_time, full_step, force_complete)
1306
+
1307
+ if full_step == math.inf:
1308
+ # No process ran — jump to the next scheduled process time
1309
+ next_event = end_time
1310
+ for path in self.front.keys():
1311
+ if self.front[path]['time'] < next_event:
1312
+ next_event = self.front[path]['time']
1313
+ self.state['global_time'] = next_event
1314
+
1315
+ elif self.state['global_time'] + full_step <= end_time:
1316
+ # At least one process ran — advance time and apply its update
1317
+ self.state['global_time'] += full_step
1318
+ updates = []
1319
+ paths = []
1320
+
1321
+ for path, advance in self.front.items():
1322
+ if advance['time'] <= self.state['global_time'] and advance['update']:
1323
+ updates.append(advance['update'])
1324
+ advance['update'] = {}
1325
+ paths.append(path)
1326
+
1327
+ update_paths = self.apply_updates(updates)
1328
+ self.expire_process_paths(update_paths)
1329
+ self.trigger_steps(update_paths)
1330
+
1331
+ else:
1332
+ # All remaining process events are beyond end_time
1333
+ self.state['global_time'] = end_time
1334
+
1335
+ if force_complete and self.state['global_time'] == end_time:
1336
+ force_complete = False
1337
+
1338
+ def run_process(
1339
+ self,
1340
+ path: Union[str, Tuple[str, ...]],
1341
+ process: Dict[str, Any],
1342
+ end_time: float,
1343
+ full_step: float,
1344
+ force_complete: bool
1345
+ ) -> float:
1346
+ """
1347
+ Run a process at a given path and determine its next scheduled time.
1348
+
1349
+ This updates the `self.front` to store when the process is due next,
1350
+ and captures its update as a deferred computation.
1351
+
1352
+ Args:
1353
+ path: The path to the process in the state/composition tree.
1354
+ process: The dictionary representing the process (must contain 'interval').
1355
+ end_time: The simulation time to run up to.
1356
+ full_step: The current smallest time step among all processes.
1357
+ force_complete: If True, forces the process to reach `end_time` exactly.
1358
+
1359
+ Returns:
1360
+ The updated `full_step`, i.e., the shortest remaining time across all processes.
1361
+ """
1362
+ # Initialize the front buffer for this process if missing
1363
+ if path not in self.front:
1364
+ self.front[path] = empty_front(self.state['global_time'])
1365
+
1366
+ process_time = self.front[path]['time']
1367
+
1368
+ if process_time <= self.state['global_time']:
1369
+ # Use future state if already scheduled and saved
1370
+ if 'future' in self.front[path]:
1371
+ future_front = self.front[path].pop('future')
1372
+ process_interval = future_front['interval']
1373
+ state = future_front['state']
1374
+ else:
1375
+ # Otherwise, slice the current state for the process
1376
+ state = self.core.view_edge(self.composition, self.state, path)
1377
+ process_interval = process['interval']
1378
+
1379
+ # Determine the target time for the next update
1380
+ future = (
1381
+ min(process_time + process_interval, end_time)
1382
+ if force_complete
1383
+ else process_time + process_interval
1384
+ )
1385
+
1386
+ # Apply rounding if global time precision is set
1387
+ if self.global_time_precision:
1388
+ future = round(future, self.global_time_precision)
1389
+
1390
+ # Compute how long this process would advance
1391
+ interval = future - self.state['global_time']
1392
+ if interval < full_step:
1393
+ full_step = interval
1394
+
1395
+ # Only proceed if the next step occurs within the target range
1396
+ if future <= end_time:
1397
+ update = self.process_update(path, process, state, process_interval)
1398
+
1399
+ # Store the update to apply when simulation reaches `future` time
1400
+ self.front[path]['time'] = future
1401
+ self.front[path]['update'] = update
1402
+
1403
+ else:
1404
+ # This process is scheduled in the future — ensure we don't skip ahead
1405
+ process_delay = process_time - self.state['global_time']
1406
+ if process_delay < full_step:
1407
+ full_step = process_delay
1408
+
1409
+ return full_step
1410
+
1411
+ def process_update(
1412
+ self,
1413
+ path: Union[str, Tuple[str, ...]],
1414
+ process: Dict[str, Any],
1415
+ states: Dict[str, Any],
1416
+ interval: float,
1417
+ ports_key: str = 'outputs'
1418
+ ) -> Defer:
1419
+ """
1420
+ Start generating a process's update and wrap it in a deferred transformation.
1421
+
1422
+ This is similar to invoking a process directly, but it delays transformation
1423
+ into absolute state terms until `.get()` is called on the returned `Defer` object.
1424
+
1425
+ Args:
1426
+ path: The path to the process in the state/composition tree.
1427
+ process: The dictionary representing the process instance (must include 'instance').
1428
+ states: The current state values at the process’s ports.
1429
+ interval: The time interval to simulate.
1430
+ ports_key: Which port ('inputs' or 'outputs') to use when projecting the update.
1431
+
1432
+ Returns:
1433
+ A `Defer` object that, when resolved, transforms the update to absolute paths.
1434
+ """
1435
+ # Strip schema-specific metadata from the state
1436
+ clean_state = strip_schema_keys(states)
1437
+
1438
+ # Invoke the process and retrieve a wrapped SyncUpdate object
1439
+ update = process['instance'].invoke(clean_state, interval)
1440
+
1441
+ # This nested function projects the update into the global state at the given path
1442
+ def defer_project(update_result: Any, args: Tuple[Any, Any, Union[str, Tuple[str, ...]]]) -> Any:
1443
+ schema, state, process_path = args
1444
+ return self.core.project_edge(schema, state, process_path, update_result, ports_key)
1445
+
1446
+ # Return a deferred object that will project the update when requested
1447
+ return Defer(update, defer_project, (self.composition, self.state, path))
1448
+
1449
+ def apply_updates(self, updates: List["Defer"]) -> List[Union[str, Tuple[str, ...]]]:
1450
+ """
1451
+ Apply a series of deferred updates and record the resulting bridge outputs.
1452
+
1453
+ For each update in the list, the deferred `.get()` method is called, which
1454
+ may return a single update or a list of updates. Each is then applied to the
1455
+ composite's state, and corresponding bridge outputs are captured.
1456
+
1457
+ Args:
1458
+ updates: A list of `Defer` objects representing delayed update functions.
1459
+
1460
+ Returns:
1461
+ A list of update paths (used to determine which processes to refresh).
1462
+ """
1463
+ update_paths = []
1464
+
1465
+ for defer in updates:
1466
+ # Resolve deferred computation to get update(s)
1467
+ series = defer.get()
1468
+ if series is None:
1469
+ continue
1470
+ if not isinstance(series, list):
1471
+ series = [series]
1472
+
1473
+ for update in series:
1474
+ # if update and isinstance(update, dict) and 'environment' in update and update['environment'] and isinstance(update['environment'], dict) and '_react' in update['environment']:
1475
+ # import ipdb; ipdb.set_trace()
1476
+
1477
+ # Extract all hierarchical paths touched by this update
1478
+ paths = hierarchy_depth(update)
1479
+ update_paths.extend(paths.keys())
1480
+
1481
+ # Apply update directly to the internal state
1482
+ self.state = self.core.apply_update(self.composition, self.state, update)
1483
+
1484
+ # Read updated bridge outputs, if available
1485
+ bridge_update = self.read_bridge(update)
1486
+ if bridge_update:
1487
+ self.bridge_updates.append(bridge_update)
1488
+
1489
+ # Refresh process and step instance paths
1490
+ self.find_instance_paths(self.state)
1491
+
1492
+ return update_paths
1493
+
1494
+ def expire_process_paths(self, update_paths: List[Union[str, Tuple[str, ...]]]) -> None:
1495
+ """
1496
+ Invalidate and refresh process paths if affected by recent updates.
1497
+
1498
+ This is used to ensure that processes are rediscovered if a state update
1499
+ altered a region where a process instance may be added, removed, or replaced.
1500
+
1501
+ Args:
1502
+ update_paths: A list of hierarchical paths that were modified.
1503
+ """
1504
+ for update_path in update_paths:
1505
+ for process_path in self.process_paths.copy():
1506
+ # Match if update path completely overlaps the process path prefix
1507
+ updated = all(update == process for update, process in zip(update_path, process_path))
1508
+ if updated:
1509
+ self.find_instance_paths(self.state)
1510
+ return # Exit early after one match, as paths are re-evaluated
1511
+
1512
+
1513
+ # ====================
1514
+ # Update Integration
1515
+ # ====================
1516
+ # This section handles how updates from processes and steps are applied to
1517
+ # the global state, how downstream effects are triggered, and how bridge
1518
+ # outputs are collected.
1519
+
1520
+ def update(self, state: Dict[str, Any], interval: float) -> List[Dict[str, Any]]:
1521
+ """
1522
+ Project input state, run the simulation interval, and return bridge updates.
1523
+
1524
+ This is the main entry point for executing a time step of the composite.
1525
+ It performs:
1526
+ - Input projection using the bridge schema
1527
+ - Merging projected input into state
1528
+ - Executing processes for the given time interval
1529
+ - Returning updates for the bridge output
1530
+
1531
+ Args:
1532
+ state: Input state to project into the composite.
1533
+ interval: Time interval to simulate.
1534
+
1535
+ Returns:
1536
+ A list of updates generated for the bridge outputs.
1537
+ """
1538
+ projection = self.core.project(
1539
+ self.interface()['inputs'],
1540
+ self.bridge['inputs'],
1541
+ [],
1542
+ state
1543
+ )
1544
+
1545
+ self.merge({}, projection)
1546
+ self.run(interval)
1547
+
1548
+ updates = self.bridge_updates
1549
+ self.bridge_updates = []
1550
+
1551
+ return updates