process-bigraph 1.0.5__tar.gz → 1.0.7__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 (43) hide show
  1. process_bigraph-1.0.7/PKG-INFO +146 -0
  2. process_bigraph-1.0.7/README.md +126 -0
  3. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/composite.py +184 -60
  4. process_bigraph-1.0.7/process_bigraph/processes/math_expression.py +525 -0
  5. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/types/process.py +55 -7
  6. process_bigraph-1.0.7/process_bigraph.egg-info/PKG-INFO +146 -0
  7. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph.egg-info/SOURCES.txt +1 -11
  8. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph.egg-info/requires.txt +6 -0
  9. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/pyproject.toml +7 -1
  10. process_bigraph-1.0.5/.github/workflows/notebook_to_html.yml +0 -43
  11. process_bigraph-1.0.5/.github/workflows/pytest.yml +0 -29
  12. process_bigraph-1.0.5/.gitignore +0 -17
  13. process_bigraph-1.0.5/CLA.md +0 -113
  14. process_bigraph-1.0.5/CODE_OF_CONDUCT.md +0 -137
  15. process_bigraph-1.0.5/CONTRIBUTING.md +0 -44
  16. process_bigraph-1.0.5/PKG-INFO +0 -63
  17. process_bigraph-1.0.5/README.md +0 -49
  18. process_bigraph-1.0.5/doc/_static/process-bigraph.png +0 -0
  19. process_bigraph-1.0.5/notebooks/process-bigraphs.ipynb +0 -739
  20. process_bigraph-1.0.5/notebooks/visualize_processes.ipynb +0 -237
  21. process_bigraph-1.0.5/process_bigraph.egg-info/PKG-INFO +0 -63
  22. process_bigraph-1.0.5/pytest.ini +0 -4
  23. process_bigraph-1.0.5/release.sh +0 -41
  24. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/AUTHORS.md +0 -0
  25. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/LICENSE +0 -0
  26. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/__init__.py +0 -0
  27. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/emitter.py +0 -0
  28. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/experiments/__init__.py +0 -0
  29. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  30. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/processes/__init__.py +0 -0
  31. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/processes/examples.py +0 -0
  32. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/processes/growth_division.py +0 -0
  33. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/processes/parameter_scan.py +0 -0
  34. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/protocols/__init__.py +0 -0
  35. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/protocols/parallel.py +0 -0
  36. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/protocols/rest.py +0 -0
  37. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/protocols/socket.py +0 -0
  38. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/run.py +0 -0
  39. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/types/__init__.py +0 -0
  40. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph/units.py +0 -0
  41. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph.egg-info/dependency_links.txt +0 -0
  42. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/process_bigraph.egg-info/top_level.txt +0 -0
  43. {process_bigraph-1.0.5 → process_bigraph-1.0.7}/setup.cfg +0 -0
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: process-bigraph
3
+ Version: 1.0.7
4
+ Summary: protocol and execution for compositional systems biology
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ License-File: AUTHORS.md
9
+ Requires-Dist: bigraph-schema
10
+ Requires-Dist: ipdb>=0.13.13
11
+ Requires-Dist: matplotlib
12
+ Requires-Dist: pint>=0.24.4
13
+ Requires-Dist: scipy>=1.8
14
+ Requires-Dist: pandas
15
+ Requires-Dist: sympy
16
+ Requires-Dist: ipykernel
17
+ Requires-Dist: jupyter
18
+ Requires-Dist: notebook
19
+ Dynamic: license-file
20
+
21
+ # Process-Bigraph
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/process-bigraph.svg)](https://pypi.org/project/process-bigraph/)
24
+ [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-Tutorials-brightgreen)](https://vivarium-collective.github.io/process-bigraph/notebooks/index.html)
25
+
26
+ **Process-Bigraph** is a compositional runtime and protocol for building and executing
27
+ **multiscale biological models from interoperable processes**.
28
+
29
+ It provides a shared architectural layer for:
30
+ - declaring **process interfaces**
31
+ - wiring processes through **typed shared state**
32
+ - orchestrating execution across **heterogeneous timescales**
33
+ - supporting **dynamic structure** (workflows, division, graph rewrites)
34
+
35
+ Process-Bigraph is the execution core of **Vivarium 2.0**, designed to integrate models
36
+ built with different formalisms—including ODEs, FBA, agent-based models, spatial solvers,
37
+ and machine-learning components—into a single coherent simulation.
38
+
39
+ <p align="center">
40
+ <img src="https://github.com/vivarium-collective/process-bigraph/blob/main/doc/_static/composition_framework.png?raw=true"
41
+ width="800"
42
+ alt="Process Bigraph composition framework">
43
+ </p>
44
+
45
+ ---
46
+
47
+ ## 🧩 What is a Process Bigraph?
48
+
49
+ A **process bigraph** combines:
50
+
51
+ - **Typed stores** — hierarchical, schema-validated state defined with
52
+ [**bigraph-schema**](https://github.com/vivarium-collective/bigraph-schema)
53
+ - **Processes** — executable components with explicit input/output ports
54
+ - **Composites** — encapsulated sub-simulations with their own internal structure
55
+ - **Orchestration patterns** — multi-timestepping, directed workflows, and event-driven rewrites
56
+
57
+ Processes do **not** mutate state directly.
58
+ Instead, they emit **typed deltas** that are merged by the runtime.
59
+
60
+ This allows:
61
+ - numerical updates
62
+ - structural rewrites
63
+ - scheduling and orchestration
64
+
65
+ to coexist under a single execution semantics.
66
+
67
+ In this sense, Process-Bigraph is a **composition protocol**, not a domain-specific simulator.
68
+
69
+ ---
70
+
71
+ ## 📄 Paper reference
72
+
73
+ The conceptual framework and formal semantics of process bigraphs are introduced in:
74
+
75
+ > **Agmon, E. & Spangler, R. K.**
76
+ > *Process Bigraphs and the Architecture of Compositional Systems Biology*
77
+ > https://arxiv.org/abs/2512.23754
78
+
79
+ ---
80
+
81
+ ## 🚀 Getting Started
82
+
83
+ ### Installation
84
+
85
+ ```console
86
+ pip install process-bigraph
87
+ ```
88
+
89
+ ## 📘 Tutorials
90
+
91
+ The Process-Bigraph tutorials are executable Jupyter notebooks,
92
+ rendered to HTML and published automatically on GitHub Pages.
93
+
94
+ - 📚 **Tutorial Index (all tutorials)**
95
+ https://vivarium-collective.github.io/process-bigraph/notebooks/index.html
96
+
97
+ ### Learning Path (Featured Tutorials)
98
+
99
+ - **Tutorial 1 — Process-Bigraph Basics**
100
+ *Processes, Steps, ports, Composites, workflows, and emitters*
101
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_1.html
102
+
103
+ - **Tutorial 2 — Wrapping an ODE Solver (`odeint`)**
104
+ *How to expose an existing scientific API as a Process*
105
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_2.html
106
+
107
+ - **Tutorial 3 — Declarative Math**
108
+ *Defining mathematical relationships, signal pipelines, and events using `MathExpressionStep`*
109
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
110
+
111
+ More tutorials are added continuously and appear automatically in the index.
112
+
113
+ ---
114
+
115
+ ## 🧪 Reference Implementation: spatio-flux
116
+
117
+ Process-Bigraph is exercised end-to-end in **spatio-flux**, a multiscale reference
118
+ model built entirely using the process-bigraph protocol.
119
+
120
+ spatio-flux composes spatial fields, particle dynamics, and metabolic processes
121
+ using typed shared state and declarative orchestration.
122
+
123
+ GitHub: https://github.com/vivarium-collective/spatio-flux
124
+ Live test report: https://vivarium-collective.github.io/spatio-flux/report/index.html
125
+
126
+ ---
127
+
128
+ ## 🔗 Related Resources
129
+
130
+ - **Bigraph Schema Basics**
131
+ https://vivarium-collective.github.io/bigraph-viz/notebooks/basics.html
132
+ *Introduction to the schema language underlying Process-Bigraph*
133
+
134
+ - **Visualization of Bigraph Document** — diagramming and rendering with
135
+ [**bigraph-viz**](https://github.com/vivarium-collective/bigraph-viz)
136
+ https://vivarium-collective.github.io/bigraph-viz/notebooks/format.html
137
+
138
+ - **E. coli Whole-Cell Wiring Diagram**
139
+ https://raw.githubusercontent.com/vivarium-collective/bigraph-viz/main/doc/_static/ecoli.png
140
+
141
+ ---
142
+
143
+ ## 📜 License
144
+
145
+ Process-Bigraph is open-source software released under the
146
+ [Apache 2 License](https://github.com/vivarium-collective/process-bigraph/blob/main/LICENSE).
@@ -0,0 +1,126 @@
1
+ # Process-Bigraph
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/process-bigraph.svg)](https://pypi.org/project/process-bigraph/)
4
+ [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-Tutorials-brightgreen)](https://vivarium-collective.github.io/process-bigraph/notebooks/index.html)
5
+
6
+ **Process-Bigraph** is a compositional runtime and protocol for building and executing
7
+ **multiscale biological models from interoperable processes**.
8
+
9
+ It provides a shared architectural layer for:
10
+ - declaring **process interfaces**
11
+ - wiring processes through **typed shared state**
12
+ - orchestrating execution across **heterogeneous timescales**
13
+ - supporting **dynamic structure** (workflows, division, graph rewrites)
14
+
15
+ Process-Bigraph is the execution core of **Vivarium 2.0**, designed to integrate models
16
+ built with different formalisms—including ODEs, FBA, agent-based models, spatial solvers,
17
+ and machine-learning components—into a single coherent simulation.
18
+
19
+ <p align="center">
20
+ <img src="https://github.com/vivarium-collective/process-bigraph/blob/main/doc/_static/composition_framework.png?raw=true"
21
+ width="800"
22
+ alt="Process Bigraph composition framework">
23
+ </p>
24
+
25
+ ---
26
+
27
+ ## 🧩 What is a Process Bigraph?
28
+
29
+ A **process bigraph** combines:
30
+
31
+ - **Typed stores** — hierarchical, schema-validated state defined with
32
+ [**bigraph-schema**](https://github.com/vivarium-collective/bigraph-schema)
33
+ - **Processes** — executable components with explicit input/output ports
34
+ - **Composites** — encapsulated sub-simulations with their own internal structure
35
+ - **Orchestration patterns** — multi-timestepping, directed workflows, and event-driven rewrites
36
+
37
+ Processes do **not** mutate state directly.
38
+ Instead, they emit **typed deltas** that are merged by the runtime.
39
+
40
+ This allows:
41
+ - numerical updates
42
+ - structural rewrites
43
+ - scheduling and orchestration
44
+
45
+ to coexist under a single execution semantics.
46
+
47
+ In this sense, Process-Bigraph is a **composition protocol**, not a domain-specific simulator.
48
+
49
+ ---
50
+
51
+ ## 📄 Paper reference
52
+
53
+ The conceptual framework and formal semantics of process bigraphs are introduced in:
54
+
55
+ > **Agmon, E. & Spangler, R. K.**
56
+ > *Process Bigraphs and the Architecture of Compositional Systems Biology*
57
+ > https://arxiv.org/abs/2512.23754
58
+
59
+ ---
60
+
61
+ ## 🚀 Getting Started
62
+
63
+ ### Installation
64
+
65
+ ```console
66
+ pip install process-bigraph
67
+ ```
68
+
69
+ ## 📘 Tutorials
70
+
71
+ The Process-Bigraph tutorials are executable Jupyter notebooks,
72
+ rendered to HTML and published automatically on GitHub Pages.
73
+
74
+ - 📚 **Tutorial Index (all tutorials)**
75
+ https://vivarium-collective.github.io/process-bigraph/notebooks/index.html
76
+
77
+ ### Learning Path (Featured Tutorials)
78
+
79
+ - **Tutorial 1 — Process-Bigraph Basics**
80
+ *Processes, Steps, ports, Composites, workflows, and emitters*
81
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_1.html
82
+
83
+ - **Tutorial 2 — Wrapping an ODE Solver (`odeint`)**
84
+ *How to expose an existing scientific API as a Process*
85
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_2.html
86
+
87
+ - **Tutorial 3 — Declarative Math**
88
+ *Defining mathematical relationships, signal pipelines, and events using `MathExpressionStep`*
89
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
90
+
91
+ More tutorials are added continuously and appear automatically in the index.
92
+
93
+ ---
94
+
95
+ ## 🧪 Reference Implementation: spatio-flux
96
+
97
+ Process-Bigraph is exercised end-to-end in **spatio-flux**, a multiscale reference
98
+ model built entirely using the process-bigraph protocol.
99
+
100
+ spatio-flux composes spatial fields, particle dynamics, and metabolic processes
101
+ using typed shared state and declarative orchestration.
102
+
103
+ GitHub: https://github.com/vivarium-collective/spatio-flux
104
+ Live test report: https://vivarium-collective.github.io/spatio-flux/report/index.html
105
+
106
+ ---
107
+
108
+ ## 🔗 Related Resources
109
+
110
+ - **Bigraph Schema Basics**
111
+ https://vivarium-collective.github.io/bigraph-viz/notebooks/basics.html
112
+ *Introduction to the schema language underlying Process-Bigraph*
113
+
114
+ - **Visualization of Bigraph Document** — diagramming and rendering with
115
+ [**bigraph-viz**](https://github.com/vivarium-collective/bigraph-viz)
116
+ https://vivarium-collective.github.io/bigraph-viz/notebooks/format.html
117
+
118
+ - **E. coli Whole-Cell Wiring Diagram**
119
+ https://raw.githubusercontent.com/vivarium-collective/bigraph-viz/main/doc/_static/ecoli.png
120
+
121
+ ---
122
+
123
+ ## 📜 License
124
+
125
+ Process-Bigraph is open-source software released under the
126
+ [Apache 2 License](https://github.com/vivarium-collective/process-bigraph/blob/main/LICENSE).
@@ -16,6 +16,8 @@ import os
16
16
  import copy
17
17
  import json
18
18
  import math
19
+ import numpy as np
20
+
19
21
  from typing import (
20
22
  Any, Dict, List, Optional, Set, Tuple, Union,
21
23
  Mapping, MutableMapping, Sequence,
@@ -72,8 +74,11 @@ def find_instances(
72
74
 
73
75
  for key, inner in state.items():
74
76
  if isinstance(inner, dict):
75
- if isinstance(inner.get('instance'), process_class):
77
+ instance = inner.get('instance')
78
+
79
+ if isinstance(instance, process_class):
76
80
  found[key] = inner
81
+
77
82
  elif not is_schema_key(key):
78
83
  sub_instances = find_instances(inner, instance_type)
79
84
  if sub_instances:
@@ -261,7 +266,7 @@ def build_step_network(steps):
261
266
 
262
267
  # Assign the priority
263
268
  if ancestors[step_key]['priority'] is None:
264
- ancestors[step_key]['priority'] = step['priority']
269
+ ancestors[step_key]['priority'] = step.get('priority', 0.0)
265
270
 
266
271
  input_paths = ancestors[step_key]['input_paths'] or []
267
272
  output_paths = ancestors[step_key]['output_paths'] or []
@@ -689,6 +694,9 @@ class Process(Open):
689
694
  update = self.update(state, interval)
690
695
  return SyncUpdate(update)
691
696
 
697
+ def calculate_timestep(self, interval, state):
698
+ return interval
699
+
692
700
  def update(self, state: Dict[str, Any], interval: float) -> Dict[str, Any]:
693
701
  """
694
702
  Override this method to implement the process logic.
@@ -703,64 +711,74 @@ class Process(Open):
703
711
  return {}
704
712
 
705
713
 
706
- def as_step(inputs, outputs, core=None):
714
+ def as_step(inputs, outputs, name=None, aliases=None):
707
715
  """
708
- Decorator to create a Step from a function named update_*.
709
- If core is provided, registers under the name *.
716
+ Decorator: convert an `update_*` pure function into a Step subclass.
717
+
718
+ - Does NOT register into any core.
719
+ - Adds metadata so discover_packages can register nice aliases (e.g. "add").
710
720
  """
711
721
  def decorator(func):
712
- assert func.__name__.startswith('update_'), "Function name must be of the form update_*"
713
- step_name = func.__name__[len('update_'):]
722
+ if not func.__name__.startswith("update_"):
723
+ raise AssertionError("Function name must be of the form update_*")
714
724
 
715
- class FunctionStep(Step):
716
- def inputs(self):
717
- return inputs
725
+ step_name = name or func.__name__[len("update_"):]
726
+ step_aliases = list(aliases or [])
727
+ # default alias: the function-derived name, e.g. update_add -> "add"
728
+ if step_name not in step_aliases:
729
+ step_aliases.insert(0, step_name)
718
730
 
719
- def outputs(self):
720
- return outputs
731
+ class FunctionStep(Step):
732
+ def inputs(self): return inputs
733
+ def outputs(self): return outputs
734
+ def update(self, state): return func(state)
721
735
 
722
- def update(self, state):
723
- return func(state)
736
+ FunctionStep.__name__ = f"{step_name}Step"
724
737
 
725
- FunctionStep.__name__ = step_name + 'Step'
738
+ # IMPORTANT: make this class look like it belongs to the user's module
739
+ FunctionStep.__module__ = func.__module__
726
740
 
727
- if core is not None:
728
- core.register_link(step_name, FunctionStep)
741
+ # Discovery metadata
742
+ FunctionStep.__pb_kind__ = "step"
743
+ FunctionStep.__pb_aliases__ = step_aliases
744
+ FunctionStep.__pb_wrapped__ = func
729
745
 
730
746
  return FunctionStep
731
-
732
747
  return decorator
733
748
 
734
749
 
735
- def as_process(inputs, outputs, core=None):
750
+ def as_process(inputs, outputs, name=None, aliases=None):
736
751
  """
737
- Decorator to create a Process from a function named update_*.
738
- If core is provided, registers under the name *.
752
+ Decorator: convert an `update_*` function into a Process subclass.
753
+
754
+ - Does NOT register into any core.
755
+ - Adds metadata so discover_packages can register nice aliases (e.g. "odeint").
739
756
  """
740
757
  def decorator(func):
741
- assert func.__name__.startswith('update_'), "Function name must be of the form update_*"
742
- process_name = func.__name__[len('update_'):]
758
+ if not func.__name__.startswith("update_"):
759
+ raise AssertionError("Function name must be of the form update_*")
743
760
 
744
- class FunctionProcess(Process):
745
- def __init__(self, config=None, core=None):
746
- super().__init__(config=config, core=core)
747
-
748
- def inputs(self):
749
- return inputs
761
+ process_name = name or func.__name__[len("update_"):]
762
+ process_aliases = list(aliases or [])
763
+ if process_name not in process_aliases:
764
+ process_aliases.insert(0, process_name)
750
765
 
751
- def outputs(self):
752
- return outputs
766
+ class FunctionProcess(Process):
767
+ def inputs(self): return inputs
768
+ def outputs(self): return outputs
769
+ def update(self, state, interval): return func(state, interval)
753
770
 
754
- def update(self, state, interval):
755
- return func(state, interval)
771
+ FunctionProcess.__name__ = f"{process_name}Process"
756
772
 
757
- FunctionProcess.__name__ = process_name + 'Process'
773
+ # IMPORTANT: make this class look like it belongs to the user's module
774
+ FunctionProcess.__module__ = func.__module__
758
775
 
759
- if core is not None:
760
- core.register_link(process_name, FunctionProcess)
776
+ # Discovery metadata
777
+ FunctionProcess.__pb_kind__ = "process"
778
+ FunctionProcess.__pb_aliases__ = process_aliases
779
+ FunctionProcess.__pb_wrapped__ = func
761
780
 
762
781
  return FunctionProcess
763
-
764
782
  return decorator
765
783
 
766
784
 
@@ -893,7 +911,8 @@ class Composite(Process):
893
911
  'inputs': 'wires',
894
912
  'outputs': 'wires'
895
913
  },
896
- 'global_time_precision': 'maybe[float]'
914
+ 'global_time_precision': 'maybe[float]',
915
+ 'skip_initial_steps': 'maybe[boolean]'
897
916
  }
898
917
 
899
918
 
@@ -998,11 +1017,16 @@ class Composite(Process):
998
1017
  # A buffer for updates to be emitted at the composite's output interface.
999
1018
  self.bridge_updates: List[Any] = []
1000
1019
 
1020
+ # Precompile view/project operations for fast runtime access.
1021
+ self._compiled_links = {}
1022
+ self._build_view_project_cache()
1023
+
1001
1024
  # Build the dependency network between steps and determine which steps should run first.
1002
1025
  self.build_step_network()
1003
1026
 
1004
1027
  # Run all steps that are ready on the first cycle.
1005
- self.run_steps(self.to_run)
1028
+ if not self.config.get('skip_initial_steps', False):
1029
+ self.run_steps(self.to_run)
1006
1030
 
1007
1031
  @classmethod
1008
1032
  def load(cls, path: str, core: Optional[Any] = None) -> "Composite":
@@ -1051,6 +1075,42 @@ class Composite(Process):
1051
1075
  # do we want to do anything with these?
1052
1076
  removed_front = self.front.pop(removed_key)
1053
1077
 
1078
+ def _build_view_project_cache(self) -> None:
1079
+ """Precompile view/project operations for each process path.
1080
+
1081
+ Delegates to core.precompile_link() which pre-resolves wire paths
1082
+ and precomputes projection schemas so that runtime view/project
1083
+ calls bypass schema traversal entirely.
1084
+ """
1085
+ self._compiled_links = {}
1086
+
1087
+ for path in list(self.process_paths) + list(self.step_paths):
1088
+ compiled = self.core.precompile_link(
1089
+ self.schema, self.state, path)
1090
+ if compiled is not None:
1091
+ self._compiled_links[path] = compiled
1092
+
1093
+ def _invalidate_caches(self) -> None:
1094
+ """Invalidate precompiled link caches, forcing rebuild on next use."""
1095
+ self._compiled_links = {}
1096
+
1097
+ def _cached_view(self, path: Tuple[str, ...]) -> Dict[str, Any]:
1098
+ """Fast view using precompiled link when available."""
1099
+ compiled = self._compiled_links.get(path)
1100
+ if compiled is not None and compiled.get('view') is not None:
1101
+ return self.core.view_fast(compiled['view'], self.state)
1102
+ return self.core.view(self.schema, self.state, path)
1103
+
1104
+ def _cached_project(self, path: Tuple[str, ...], view: Any,
1105
+ ports_key: str = 'outputs') -> Any:
1106
+ """Fast project using precompiled link when available."""
1107
+ if ports_key == 'outputs':
1108
+ compiled = self._compiled_links.get(path)
1109
+ if compiled is not None and compiled.get('project') is not None:
1110
+ return self.core.project_ports_fast(compiled['project'], view)
1111
+ return self.core.project(
1112
+ self.schema, self.state, path, view, ports_key)
1113
+
1054
1114
  def merge(self, schema: Dict[str, Any], state: Dict[str, Any], path: Optional[List[str]] = None) -> None:
1055
1115
  """
1056
1116
  Merge a new schema/state subtree into the Composite.
@@ -1069,6 +1129,7 @@ class Composite(Process):
1069
1129
  state)
1070
1130
 
1071
1131
  self.find_instance_paths(self.state)
1132
+ self._build_view_project_cache()
1072
1133
 
1073
1134
  def merge_schema(
1074
1135
  self,
@@ -1160,8 +1221,17 @@ class Composite(Process):
1160
1221
 
1161
1222
  os.makedirs(outdir, exist_ok=True)
1162
1223
  filepath = os.path.join(outdir, filename)
1163
- with open(filepath, 'w') as f:
1164
- json.dump(document, f, indent=4)
1224
+ # outjson = json.dumps(
1225
+ # document,
1226
+ # default=encode_key)
1227
+
1228
+ with open(filepath, 'w') as outfile:
1229
+ json.dump(
1230
+ document,
1231
+ outfile,
1232
+ indent=2,
1233
+ default=encode_key)
1234
+
1165
1235
  print(f"Saved composite to {filepath}")
1166
1236
 
1167
1237
 
@@ -1401,6 +1471,7 @@ class Composite(Process):
1401
1471
  paths.append(path)
1402
1472
 
1403
1473
  update_paths = self.apply_updates(updates)
1474
+ update_paths.append(('global_time',)) # updated global time can trigger steps
1404
1475
  self.expire_process_paths(update_paths)
1405
1476
  self.trigger_steps(update_paths)
1406
1477
 
@@ -1449,11 +1520,10 @@ class Composite(Process):
1449
1520
  state = future_front['state']
1450
1521
  else:
1451
1522
  # Otherwise, slice the current state for the process
1452
- state = self.core.view(
1453
- self.schema,
1454
- self.state,
1455
- path)
1456
- process_interval = process['interval']
1523
+ state = self._cached_view(path)
1524
+ state_interval = process['interval']
1525
+ process_interval = process['instance'].calculate_timestep(state_interval, state)
1526
+ process['interval'] = process_interval
1457
1527
 
1458
1528
  # Determine the target time for the next update
1459
1529
  future = (
@@ -1523,9 +1593,7 @@ class Composite(Process):
1523
1593
  if not isinstance(update_results, list):
1524
1594
  update_results = [update_results]
1525
1595
 
1526
- return [self.core.project(
1527
- schema,
1528
- state,
1596
+ return [self._cached_project(
1529
1597
  process_path,
1530
1598
  update_result,
1531
1599
  ports_key) for update_result in update_results]
@@ -1533,6 +1601,24 @@ class Composite(Process):
1533
1601
  # Return a deferred object that will project the update when requested
1534
1602
  return Defer(update, defer_project, (self.schema, self.state, path))
1535
1603
 
1604
+ @staticmethod
1605
+ def _has_structural_keys(state: Any) -> bool:
1606
+ """Check if a state dict contains keys that signal structural changes.
1607
+
1608
+ Structural changes (_add, _remove, _type) require re-running
1609
+ realize() and find_instance_paths(). Plain value updates do not.
1610
+ """
1611
+ if not isinstance(state, dict):
1612
+ return False
1613
+ for key, value in state.items():
1614
+ if key in ('_add', '_remove'):
1615
+ return True
1616
+ if key == '_type':
1617
+ return True
1618
+ if isinstance(value, dict) and Composite._has_structural_keys(value):
1619
+ return True
1620
+ return False
1621
+
1536
1622
  def apply_updates(self, updates: List["Defer"]) -> List[Union[str, Tuple[str, ...]]]:
1537
1623
  """
1538
1624
  Apply a series of deferred updates and record the resulting bridge outputs.
@@ -1548,6 +1634,7 @@ class Composite(Process):
1548
1634
  A list of update paths (used to determine which processes to refresh).
1549
1635
  """
1550
1636
  update_paths = []
1637
+ had_structural_changes = False
1551
1638
 
1552
1639
  for defer in updates:
1553
1640
  # Resolve deferred computation to get update(s)
@@ -1558,13 +1645,14 @@ class Composite(Process):
1558
1645
  series = [series]
1559
1646
 
1560
1647
  for update_schema, update_state in series:
1561
- # if update and isinstance(update, dict) and 'environment' in update and update['environment'] and isinstance(update['environment'], dict) and '_react' in update['environment']:
1562
- # import ipdb; ipdb.set_trace()
1563
-
1564
1648
  # Extract all hierarchical paths touched by this update
1565
1649
  paths = hierarchy_depth(update_state)
1566
1650
  update_paths.extend(paths.keys())
1567
1651
 
1652
+ # Detect structural changes before applying
1653
+ if not had_structural_changes:
1654
+ had_structural_changes = self._has_structural_keys(update_state)
1655
+
1568
1656
  # Apply update directly to the internal state,
1569
1657
  # using the schema from the link itself
1570
1658
  self.state, merges = self.core.apply(
@@ -1572,20 +1660,22 @@ class Composite(Process):
1572
1660
  self.state,
1573
1661
  update_state)
1574
1662
 
1575
- self.schema = self.core.resolve_merges(
1576
- self.schema,
1577
- merges)
1663
+ if merges:
1664
+ had_structural_changes = True
1665
+ self.schema = self.core.resolve_merges(
1666
+ self.schema,
1667
+ merges)
1578
1668
 
1579
1669
  # Read updated bridge outputs, if available
1580
1670
  bridge_update = self.read_bridge(update_state)
1581
1671
  if bridge_update:
1582
1672
  self.bridge_updates.append(bridge_update)
1583
1673
 
1584
- self.schema, self.state = self.core.realize(self.schema, self.state)
1585
-
1586
- # TODO: are we doing this twice?
1587
- # Refresh process and step instance paths
1588
- self.find_instance_paths(self.state)
1674
+ # Only run expensive realize and instance discovery when structural changes occurred
1675
+ if had_structural_changes:
1676
+ self.schema, self.state = self.core.realize(self.schema, self.state)
1677
+ self.find_instance_paths(self.state)
1678
+ self._build_view_project_cache()
1589
1679
 
1590
1680
  return update_paths
1591
1681
 
@@ -1599,12 +1689,33 @@ class Composite(Process):
1599
1689
  Args:
1600
1690
  update_paths: A list of hierarchical paths that were modified.
1601
1691
  """
1692
+ # Quick check: if no update path shares a first element with any process path,
1693
+ # then no overlap is possible and we can skip the expensive scan.
1694
+ if not hasattr(self, '_process_path_roots'):
1695
+ self._process_path_roots = set()
1696
+ process_roots = self._process_path_roots
1697
+ if not process_roots:
1698
+ process_roots = {p[0] for p in self.process_paths if p}
1699
+ self._process_path_roots = process_roots
1700
+
1701
+ # Fast rejection: check if any update touches a process-adjacent path
1702
+ needs_check = False
1703
+ for update_path in update_paths:
1704
+ if update_path and update_path[0] in process_roots:
1705
+ needs_check = True
1706
+ break
1707
+
1708
+ if not needs_check:
1709
+ return
1710
+
1602
1711
  for update_path in update_paths:
1603
1712
  for process_path in self.process_paths.copy():
1604
1713
  # Match if update path completely overlaps the process path prefix
1605
1714
  updated = all(update == process for update, process in zip(update_path, process_path))
1606
1715
  if updated:
1607
1716
  self.find_instance_paths(self.state)
1717
+ self._build_view_project_cache()
1718
+ self._process_path_roots = set() # Reset for rebuild
1608
1719
  return # Exit early after one match, as paths are re-evaluated
1609
1720
 
1610
1721
 
@@ -1649,3 +1760,16 @@ class Composite(Process):
1649
1760
  self.run(interval)
1650
1761
 
1651
1762
  return self.bridge_updates
1763
+
1764
+
1765
+ def encode_key(o):
1766
+ if isinstance(o, np.ndarray):
1767
+ o.tolist()
1768
+
1769
+ elif isinstance(o, dict):
1770
+ return {
1771
+ str(k): encode_key(v)
1772
+ for k, v in o.items()}
1773
+
1774
+ else:
1775
+ return o