process-bigraph 1.3.1__tar.gz → 1.4.0__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 (41) hide show
  1. {process_bigraph-1.3.1/process_bigraph.egg-info → process_bigraph-1.4.0}/PKG-INFO +9 -1
  2. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/composite.py +137 -31
  3. process_bigraph-1.4.0/process_bigraph/protocols/ray.py +291 -0
  4. process_bigraph-1.4.0/process_bigraph/server/__init__.py +0 -0
  5. process_bigraph-1.4.0/process_bigraph/server/rest.py +91 -0
  6. process_bigraph-1.4.0/process_bigraph/server/start.py +46 -0
  7. {process_bigraph-1.3.1 → process_bigraph-1.4.0/process_bigraph.egg-info}/PKG-INFO +9 -1
  8. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph.egg-info/SOURCES.txt +4 -0
  9. process_bigraph-1.4.0/process_bigraph.egg-info/requires.txt +16 -0
  10. process_bigraph-1.4.0/pyproject.toml +64 -0
  11. process_bigraph-1.3.1/process_bigraph.egg-info/requires.txt +0 -6
  12. process_bigraph-1.3.1/pyproject.toml +0 -37
  13. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/AUTHORS.md +0 -0
  14. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/LICENSE +0 -0
  15. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/README.md +0 -0
  16. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/__init__.py +0 -0
  17. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/bundle.py +0 -0
  18. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/emitter.py +0 -0
  19. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/experiments/__init__.py +0 -0
  20. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  21. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/nextflow.py +0 -0
  22. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/plumbing.py +0 -0
  23. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/processes/__init__.py +0 -0
  24. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/processes/dynamic_structure.py +0 -0
  25. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/processes/examples.py +0 -0
  26. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/processes/growth_division.py +0 -0
  27. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/processes/math_expression.py +0 -0
  28. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/processes/parameter_scan.py +0 -0
  29. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/processes/reaction.py +0 -0
  30. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/protocols/__init__.py +0 -0
  31. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/protocols/parallel.py +0 -0
  32. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/protocols/rest.py +0 -0
  33. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/protocols/socket.py +0 -0
  34. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/run.py +0 -0
  35. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/run_step.py +0 -0
  36. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/types/__init__.py +0 -0
  37. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/types/process.py +0 -0
  38. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph/units.py +0 -0
  39. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph.egg-info/dependency_links.txt +0 -0
  40. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/process_bigraph.egg-info/top_level.txt +0 -0
  41. {process_bigraph-1.3.1 → process_bigraph-1.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.3.1
3
+ Version: 1.4.0
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,14 @@ Requires-Dist: pint>=0.24.4
12
12
  Requires-Dist: scipy>=1.8
13
13
  Requires-Dist: pandas
14
14
  Requires-Dist: sympy
15
+ Provides-Extra: ray
16
+ Requires-Dist: ray>=2.10; extra == "ray"
17
+ Provides-Extra: server-rest
18
+ Requires-Dist: fastapi>=0.116.1; extra == "server-rest"
19
+ Requires-Dist: fastapi-utils>=0.8.0; extra == "server-rest"
20
+ Requires-Dist: uvicorn>=0.35.0; extra == "server-rest"
21
+ Requires-Dist: fire; extra == "server-rest"
22
+ Requires-Dist: typing-inspect>=0.9.0; extra == "server-rest"
15
23
  Dynamic: license-file
16
24
 
17
25
  # Process-Bigraph
@@ -530,6 +530,21 @@ def build_step_network(steps):
530
530
  if producer not in node['after']:
531
531
  node['before'].add(producer)
532
532
 
533
+ # Precompute each step's direct dependents (= the set of steps
534
+ # that consume something this step writes). ``find_downstream``
535
+ # was reconstructing this every tick by walking
536
+ # output_paths × explode_path × nodes lookup; doing it once at
537
+ # graph-build time makes ``find_downstream`` a plain BFS over a
538
+ # cached adjacency list.
539
+ for step_key, ancestor in ancestors.items():
540
+ direct = set()
541
+ for output_path in ancestor['output_paths'] or []:
542
+ for subpath in explode_path(output_path):
543
+ node = nodes.get(subpath)
544
+ if node is not None:
545
+ direct.update(node['after'])
546
+ ancestor['_direct_dependents'] = frozenset(direct)
547
+
533
548
  return ancestors, nodes
534
549
 
535
550
 
@@ -552,36 +567,25 @@ def build_trigger_state(nodes, paths):
552
567
 
553
568
  def find_downstream(steps, nodes, upstream):
554
569
  """
555
- Given a set of updated steps, identify all downstream steps that depend on them.
556
-
557
- Args:
558
- steps: Step metadata with input/output info.
559
- nodes: Dependency graph.
560
- upstream: Initial set of triggered step paths.
570
+ Given a set of updated steps, identify all downstream steps that
571
+ depend on them — directly or transitively.
561
572
 
562
- Returns:
563
- Set of all steps affected directly or transitively.
573
+ BFS over the cached ``_direct_dependents`` adjacency that
574
+ ``build_step_network`` precomputes per step. ``nodes`` is no
575
+ longer consulted on the hot path (kept in the signature for
576
+ backward compatibility with callers that still pass it).
564
577
  """
565
578
  downstream = set(upstream)
566
- visited = set([])
567
- previous_len = -1
568
-
569
- while len(downstream) > len(visited) and len(visited) > previous_len:
570
- previous_len = len(visited)
571
- down = set([])
572
- for step_path in downstream:
573
- if step_path not in visited:
574
- step_outputs = steps[step_path]['output_paths']
575
- if step_outputs is None:
576
- step_outputs = [] # Ensure step_outputs is always an iterable
577
- for output in step_outputs:
578
- for subpath in explode_path(output):
579
- if subpath in nodes:
580
- for dependent in nodes[subpath]['after']:
581
- down.add(dependent)
582
- visited.add(step_path)
583
- downstream |= down
584
-
579
+ queue = list(upstream)
580
+ while queue:
581
+ step_path = queue.pop()
582
+ meta = steps.get(step_path)
583
+ if meta is None:
584
+ continue
585
+ for dep in meta.get('_direct_dependents', ()):
586
+ if dep not in downstream:
587
+ downstream.add(dep)
588
+ queue.append(dep)
585
589
  return downstream
586
590
 
587
591
 
@@ -1281,6 +1285,14 @@ class Composite(Process):
1281
1285
  # step hot path releases the GIL (numpy / scipy / numba / C
1282
1286
  # extensions: yes; pure-Python loops: no).
1283
1287
  'parallel_steps': 'boolean{false}',
1288
+ # Time-scheduled processes: when True, the per-tick `run` loop
1289
+ # fans out invocations across a thread pool. Right tool when
1290
+ # processes' update() releases the GIL (network I/O, numpy /
1291
+ # scipy / C extensions, Ray actor calls). Wrong tool when
1292
+ # update() is mostly pure Python — threads will just trade the
1293
+ # GIL and you'll see no speedup. The thread pool is shared with
1294
+ # parallel_steps and bounded by parallel_workers.
1295
+ 'parallel_processes': 'boolean{false}',
1284
1296
  'parallel_workers': 'maybe[integer]',
1285
1297
  # When True, ``initialize`` trusts ``state`` as-is and skips the
1286
1298
  # per-process ``link_state`` + ``combine`` pass that normally
@@ -1401,6 +1413,7 @@ class Composite(Process):
1401
1413
  # field in interface_schema for the rationale (threading vs.
1402
1414
  # multiprocessing for inner parallelism).
1403
1415
  self._parallel_steps = bool(self.config.get('parallel_steps', False))
1416
+ self._parallel_processes = bool(self.config.get('parallel_processes', False))
1404
1417
  self._parallel_workers = self.config.get('parallel_workers')
1405
1418
  self._step_executor = None # lazy: created on first run_steps need
1406
1419
 
@@ -2359,10 +2372,14 @@ class Composite(Process):
2359
2372
  full_step = math.inf
2360
2373
 
2361
2374
  # Run each process and compute the minimum time step that advances simulation
2362
- for path in self.process_paths:
2363
- process = get_path(self.state, path)
2364
- full_step = self.run_process(
2365
- path, process, end_time, full_step, force_complete)
2375
+ if self._parallel_processes and len(self.process_paths) > 1:
2376
+ full_step = self._run_processes_layer_parallel(
2377
+ end_time, full_step, force_complete)
2378
+ else:
2379
+ for path in self.process_paths:
2380
+ process = get_path(self.state, path)
2381
+ full_step = self.run_process(
2382
+ path, process, end_time, full_step, force_complete)
2366
2383
 
2367
2384
  if full_step == math.inf:
2368
2385
  # No process ran — jump to the next scheduled process time
@@ -2492,6 +2509,95 @@ class Composite(Process):
2492
2509
 
2493
2510
  return full_step
2494
2511
 
2512
+ def _run_processes_layer_parallel(
2513
+ self,
2514
+ end_time: float,
2515
+ full_step: float,
2516
+ force_complete: bool,
2517
+ ) -> float:
2518
+ """Parallel analog of the per-tick `for path in self.process_paths`
2519
+ loop. Splits run_process into a schedule pass (sequential, cheap)
2520
+ and an invoke pass (parallelized over the same ThreadPoolExecutor
2521
+ used by parallel_steps).
2522
+
2523
+ Semantics are identical to the serial loop: same schedule decisions,
2524
+ same Defers stored at the same self.front[path] slots. Only the
2525
+ order in which `instance.invoke()` calls happen changes.
2526
+
2527
+ Safe when:
2528
+ - `instance.update()` releases the GIL on its hot path (network
2529
+ I/O, Ray actor calls, numpy/scipy/C extensions).
2530
+ - Processes don't share mutable state across each other.
2531
+
2532
+ Not safe when processes share mutable state or hold global locks.
2533
+ Like `parallel_steps`, this is opt-in for that reason.
2534
+ """
2535
+ # ---- schedule pass: decide which processes are due now ---------- #
2536
+ due = [] # (path, process_dict, state, future_time, process_interval)
2537
+ for path in self.process_paths:
2538
+ process = get_path(self.state, path)
2539
+ if path not in self.front:
2540
+ self.front[path] = empty_front(self.state['global_time'])
2541
+ process_time = self.front[path]['time']
2542
+
2543
+ if process_time <= self.state['global_time']:
2544
+ if 'future' in self.front[path]:
2545
+ future_front = self.front[path].pop('future')
2546
+ process_interval = future_front['interval']
2547
+ state = future_front['state']
2548
+ else:
2549
+ state = self._cached_view(path)
2550
+ state_interval = process['interval']
2551
+ process_interval = process['instance'].calculate_timestep(
2552
+ state_interval, state)
2553
+ process['interval'] = process_interval
2554
+
2555
+ future = (
2556
+ min(process_time + process_interval, end_time)
2557
+ if force_complete
2558
+ else process_time + process_interval
2559
+ )
2560
+ if self.global_time_precision:
2561
+ future = round(future, self.global_time_precision)
2562
+
2563
+ interval = future - self.state['global_time']
2564
+ if interval < full_step:
2565
+ full_step = interval
2566
+
2567
+ if future <= end_time:
2568
+ due.append((path, process, state, future, process_interval))
2569
+ else:
2570
+ process_delay = process_time - self.state['global_time']
2571
+ if process_delay < full_step:
2572
+ full_step = process_delay
2573
+
2574
+ if not due:
2575
+ return full_step
2576
+
2577
+ # ---- invoke pass: parallel via the shared step executor --------- #
2578
+ # process_update calls instance.invoke(state, interval); that's the
2579
+ # only blocking part. Note: process_update accumulates into
2580
+ # self.process_update_time — there's a benign race on that counter
2581
+ # under contention, but it's only a metric (not correctness).
2582
+ def _invoke(item):
2583
+ path, process, state, _future, process_interval = item
2584
+ return path, self.process_update(
2585
+ path, process, state, process_interval, already_clean=True)
2586
+
2587
+ if len(due) > 1:
2588
+ pool = self._get_step_executor(len(due))
2589
+ results = list(pool.map(_invoke, due))
2590
+ else:
2591
+ results = [_invoke(due[0])]
2592
+
2593
+ # ---- bookkeep pass: store Defers + update self.front time slots - #
2594
+ future_by_path = {p: fut for (p, _, _, fut, _) in due}
2595
+ for path, defer in results:
2596
+ self.front[path]['time'] = future_by_path[path]
2597
+ self.front[path]['update'] = defer
2598
+
2599
+ return full_step
2600
+
2495
2601
  def process_update(
2496
2602
  self,
2497
2603
  path: Union[str, Tuple[str, ...]],
@@ -0,0 +1,291 @@
1
+ """
2
+ RayProcess — distributed transport backed by Ray actors.
3
+
4
+ Pair with the ``parallel_processes=True`` flag on Composite so the orchestrator
5
+ can dispatch per-step ``update()`` calls concurrently — that's what turns N
6
+ clients talking to a Ray actor pool into N parallel solves.
7
+
8
+ Install with the optional ray extra::
9
+
10
+ pip install process-bigraph[ray]
11
+
12
+ Architecture: pooled actors
13
+ ---------------------------
14
+ Each (process_class, process_config) pair backs a fixed pool of N Ray actors
15
+ (default N = ncpu). Every RayProcess client is round-robin assigned to one
16
+ pool actor; many "logical" processes share the same physical worker. This
17
+ bounds memory at O(ncpu) underlying-process instances instead of O(clients),
18
+ and bounds spawn cost at ncpu actors regardless of how many clients the
19
+ orchestrator wires up.
20
+
21
+ Why pooled, not actor-per-client:
22
+ - One actor per cell at moderate grids (e.g. 256 cells with a 150 MB cobra
23
+ Model each) trivially OOMs a typical workstation.
24
+ - Per-actor spawn cost (process fork + module import + heavy state init)
25
+ is 50-500 ms; paying that 256× per run is minutes of overhead.
26
+ - Ray actor methods are serialized by default — concurrent calls to one
27
+ actor are queued, so non-thread-safe state inside the underlying Process
28
+ isn't a concern.
29
+
30
+ Pool lifecycle:
31
+ - Pools live for the lifetime of the Python interpreter by default.
32
+ Subsequent ``Composite`` runs re-use the same actors — no re-spawn,
33
+ no model reload.
34
+ - Call ``shutdown_pools()`` to tear them down explicitly (useful in tests).
35
+ - ``RayProcess.end()`` is a no-op — clients come and go but actors persist.
36
+
37
+ Usage
38
+ -----
39
+ 1. Register the underlying Process classes once at startup so each Ray
40
+ worker can resolve them by name::
41
+
42
+ from process_bigraph.protocols.ray import register_process_class
43
+ from my_pkg.processes import MyProcess
44
+ register_process_class("MyProcess", MyProcess)
45
+
46
+ 2. Reference RayProcess in your composite spec::
47
+
48
+ "worker_0": {
49
+ "_type": "process",
50
+ "address": "local:RayProcess",
51
+ "config": {
52
+ "process_class": "MyProcess",
53
+ "process_config": { ... MyProcess's config ... },
54
+ # optional: cap pool size (default = os.cpu_count())
55
+ "pool_size": 8,
56
+ },
57
+ "inputs": { ... },
58
+ "outputs": { ... },
59
+ "interval": 0.1,
60
+ }
61
+
62
+ 3. Pass ``parallel_processes=True`` to Composite so the orchestrator dispatches
63
+ the per-step ``update()`` calls concurrently.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import os
69
+ import json
70
+ import hashlib
71
+ from typing import Any, Dict, List, Type, Optional
72
+
73
+ try:
74
+ import ray
75
+ except ImportError as e: # pragma: no cover
76
+ raise ImportError(
77
+ "process_bigraph.protocols.ray requires the optional `ray` "
78
+ "dependency. Install with: pip install process-bigraph[ray]"
79
+ ) from e
80
+
81
+ from process_bigraph import Process
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Process class registry.
86
+ # Ray pickles this into each new actor at spawn so workers don't need to
87
+ # import the same modules in their startup script.
88
+ # ---------------------------------------------------------------------------
89
+ _PROCESS_REGISTRY: Dict[str, Type[Process]] = {}
90
+
91
+
92
+ def register_process_class(name: str, cls: Type[Process]) -> None:
93
+ """Register a Process class so RayProcess can resolve it by name."""
94
+ _PROCESS_REGISTRY[name] = cls
95
+
96
+
97
+ def get_registry() -> Dict[str, Type[Process]]:
98
+ return dict(_PROCESS_REGISTRY)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Ray actor — one per pool slot. Holds a single Process instance.
103
+ # ---------------------------------------------------------------------------
104
+ @ray.remote
105
+ class _ProcessActor:
106
+ def __init__(self, registry: Dict[str, Type[Process]],
107
+ class_name: str, config: dict):
108
+ for k, v in registry.items():
109
+ _PROCESS_REGISTRY[k] = v
110
+ cls = _PROCESS_REGISTRY[class_name]
111
+ from process_bigraph import allocate_core
112
+ self.instance = cls(config, core=allocate_core())
113
+
114
+ def inputs(self):
115
+ return self.instance.inputs()
116
+
117
+ def outputs(self):
118
+ return self.instance.outputs()
119
+
120
+ def update(self, state: dict, interval: float):
121
+ return self.instance.update(state, interval)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Actor pool. One pool per (process_class, process_config). Persistent across
126
+ # RayProcess instances and across simulation runs.
127
+ # ---------------------------------------------------------------------------
128
+ class _ActorPool:
129
+ def __init__(self, class_name: str, config: dict, n_workers: int):
130
+ registry = get_registry()
131
+ # Spawn all actors concurrently — actor.remote() returns immediately;
132
+ # we don't ray.get on the constructor. The first .inputs.remote() call
133
+ # implicitly waits for the actor to be ready.
134
+ self.actors = [
135
+ _ProcessActor.remote(registry, class_name, config)
136
+ for _ in range(n_workers)
137
+ ]
138
+ self._next = 0
139
+
140
+ def assign(self):
141
+ actor = self.actors[self._next % len(self.actors)]
142
+ self._next += 1
143
+ return actor
144
+
145
+ def shutdown(self):
146
+ for a in self.actors:
147
+ try:
148
+ ray.kill(a)
149
+ except Exception:
150
+ pass
151
+ self.actors = []
152
+
153
+
154
+ # Module-level pool registry, keyed by (class_name, config_hash).
155
+ _POOLS: Dict[str, _ActorPool] = {}
156
+
157
+
158
+ def _config_hash(config: Any) -> str:
159
+ """Stable hash of a process_config dict for pool keying."""
160
+ try:
161
+ s = json.dumps(config, sort_keys=True, default=repr)
162
+ except TypeError:
163
+ s = repr(config)
164
+ return hashlib.sha1(s.encode()).hexdigest()[:12]
165
+
166
+
167
+ def _pool_key(class_name: str, config: Any) -> str:
168
+ return f"{class_name}:{_config_hash(config)}"
169
+
170
+
171
+ def _get_or_make_pool(class_name: str, config: dict,
172
+ n_workers: Optional[int]) -> _ActorPool:
173
+ key = _pool_key(class_name, config)
174
+ pool = _POOLS.get(key)
175
+ if pool is None:
176
+ if n_workers is None:
177
+ n_workers = max(1, os.cpu_count() or 4)
178
+ pool = _ActorPool(class_name, config, n_workers)
179
+ _POOLS[key] = pool
180
+ return pool
181
+
182
+
183
+ def shutdown_pools() -> None:
184
+ """Tear down all actor pools. Call at program exit / between test runs."""
185
+ for pool in list(_POOLS.values()):
186
+ pool.shutdown()
187
+ _POOLS.clear()
188
+
189
+
190
+ def pool_stats() -> List[dict]:
191
+ """Diagnostic: list all live pools."""
192
+ return [
193
+ {"key": k, "n_actors": len(p.actors)}
194
+ for k, p in _POOLS.items()
195
+ ]
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Client — what the orchestrator sees as a Process.
200
+ # ---------------------------------------------------------------------------
201
+ class RayProcess(Process):
202
+ """A Process whose update() runs on a pooled remote Ray actor.
203
+
204
+ Config:
205
+ process_class : str
206
+ Name of a Process subclass registered via register_process_class().
207
+ process_config : dict
208
+ Config dict passed to the underlying Process subclass.
209
+ pool_size : int (optional)
210
+ Number of actors in the pool for this (class, config). Defaults
211
+ to os.cpu_count(). The first RayProcess instantiation for a
212
+ given (class, config) sizes the pool — subsequent instances
213
+ reuse the existing pool and ignore this field.
214
+ """
215
+
216
+ config_schema = {
217
+ "process_class": "string",
218
+ "process_config": "node",
219
+ "pool_size": "maybe[integer]",
220
+ }
221
+
222
+ def initialize(self, config):
223
+ if not ray.is_initialized():
224
+ ray.init(ignore_reinit_error=True, log_to_driver=False)
225
+
226
+ class_name = config["process_class"]
227
+ if class_name not in _PROCESS_REGISTRY:
228
+ raise KeyError(
229
+ f"Process class {class_name!r} not in RayProcess registry. "
230
+ f"Call register_process_class({class_name!r}, <cls>) first."
231
+ )
232
+
233
+ pool = _get_or_make_pool(
234
+ class_name,
235
+ config["process_config"],
236
+ config.get("pool_size"),
237
+ )
238
+ self.actor = pool.assign()
239
+
240
+ # Cache port schemas — one round-trip per client at init. (We could
241
+ # cache per-pool to drop this, but it's a single call and the result
242
+ # could in principle differ if the underlying Process introspects
243
+ # config-specific port shapes.)
244
+ self._inputs = ray.get(self.actor.inputs.remote())
245
+ self._outputs = ray.get(self.actor.outputs.remote())
246
+
247
+ def inputs(self):
248
+ return self._inputs
249
+
250
+ def outputs(self):
251
+ return self._outputs
252
+
253
+ def update(self, state, interval):
254
+ # Blocking get: releases the GIL while the actor runs. ParallelComposite
255
+ # gives us N concurrent in-flight calls = N actors busy in parallel.
256
+ return ray.get(self.actor.update.remote(state, float(interval)))
257
+
258
+ def end(self):
259
+ # Pool actors persist across RayProcess instances — DON'T kill them
260
+ # here. Use shutdown_pools() to tear down explicitly.
261
+ pass
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Smoke test — wraps IncreaseProcess (a built-in toy Process) in a Ray pool
266
+ # and runs a few updates. Useful as both a sanity check and an example.
267
+ # ---------------------------------------------------------------------------
268
+ if __name__ == "__main__":
269
+ from process_bigraph import allocate_core
270
+ from process_bigraph.processes.examples import IncreaseProcess
271
+
272
+ register_process_class("IncreaseProcess", IncreaseProcess)
273
+
274
+ proc_a = RayProcess(
275
+ {"process_class": "IncreaseProcess",
276
+ "process_config": {"rate": 0.5},
277
+ "pool_size": 2},
278
+ core=allocate_core(),
279
+ )
280
+ proc_b = RayProcess(
281
+ {"process_class": "IncreaseProcess",
282
+ "process_config": {"rate": 0.5},
283
+ "pool_size": 2}, # ignored — pool already exists
284
+ core=allocate_core(),
285
+ )
286
+ print("pool stats:", pool_stats())
287
+ for proc, label in [(proc_a, "A"), (proc_b, "B")]:
288
+ upd = proc.update({"level": 4.0}, interval=1.0)
289
+ print(f"{label} update :", upd)
290
+ shutdown_pools()
291
+ print("after shutdown:", pool_stats())
@@ -0,0 +1,91 @@
1
+ from typing import Any, Dict, Union
2
+ import uuid
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi_utils.cbv import cbv
6
+ from fastapi_utils.inferring_router import InferringRouter
7
+
8
+ from bigraph_schema import Edge
9
+
10
+
11
+ def make_router(core):
12
+ router = InferringRouter()
13
+ processes = {}
14
+
15
+ @cbv(router)
16
+ class ProcessRouter():
17
+ def __init__(self):
18
+ self.core = core
19
+ self.processes = processes
20
+
21
+ def find_process_class(self, process):
22
+ return self.core.link_registry.get(process, Edge)
23
+
24
+ @router.get('/import-types')
25
+ def get_import_types(self):
26
+ pass
27
+
28
+ @router.get('/type-packages')
29
+ def get_type_packages(self):
30
+ pass
31
+
32
+ @router.get('/list-types')
33
+ def get_list_types(self):
34
+ return list(self.core.registry.keys())
35
+
36
+ @router.get('/list-processes')
37
+ def get_list_processes(self):
38
+ return list(self.core.link_registry.keys())
39
+
40
+ @router.get('/process/{process}/config-schema')
41
+ def get_config_schema(self, process: str):
42
+ process_class = self.find_process_class(process)
43
+ if process_class is None:
44
+ return {'process-not-found': 'true'}
45
+ else:
46
+ return process_class.config_schema
47
+
48
+ @router.post('/process/{process}/initialize')
49
+ def post_initialize(self, process: str, config: dict):
50
+ process_id = uuid.uuid4()
51
+ process_class = self.find_process_class(process)
52
+ process_instance = process_class(
53
+ config,
54
+ core=self.core)
55
+ self.processes[str(process_id)] = process_instance
56
+
57
+ print(self.processes)
58
+ print(process_id)
59
+ return process_id
60
+
61
+ @router.get('/process/{process}/inputs/{process_id}')
62
+ def get_inputs(self, process: str, process_id: str):
63
+ print(self.processes)
64
+ return self.processes[process_id].inputs()
65
+
66
+ @router.get('/process/{process}/outputs/{process_id}')
67
+ def get_outputs(self, process: str, process_id: str):
68
+ return self.processes[process_id].outputs()
69
+
70
+ @router.post('/process/{process}/update/{process_id}')
71
+ def post_update(self, process: str, process_id: str, data: dict):
72
+ state = data['state']
73
+ interval = data['interval']
74
+
75
+ return self.processes[process_id].update(
76
+ state,
77
+ interval)
78
+
79
+ @router.post('/process/{process}/end/{process_id}')
80
+ def post_end(self, process: str, process_id: str):
81
+ del self.processes[process_id]
82
+
83
+ return router
84
+
85
+
86
+ def start_server(core):
87
+ app = FastAPI()
88
+ router = make_router(core)
89
+ app.include_router(router)
90
+
91
+ return app
@@ -0,0 +1,46 @@
1
+ """
2
+ CLI entry point for hosting process-bigraph processes over a network protocol.
3
+
4
+ Usage:
5
+ python -m process_bigraph.server.start --host 0.0.0.0 --port 22222
6
+ python -m process_bigraph.server.start --protocol rest --port 22222
7
+
8
+ Currently supported protocols: rest (FastAPI/uvicorn). Future: grpc, etc.
9
+ Each protocol's server-side dependencies are gated behind an optional extra.
10
+ """
11
+
12
+ import fire
13
+
14
+
15
+ def start(host: str = "0.0.0.0", port: int = 22222, protocol: str = "rest"):
16
+ """Start a process-bigraph protocol server.
17
+
18
+ Args:
19
+ host: bind address (default 0.0.0.0)
20
+ port: bind port (default 22222)
21
+ protocol: which transport to expose (default 'rest'). Each protocol
22
+ has its own server-side dependency extra:
23
+ rest: pip install process-bigraph[server-rest]
24
+ """
25
+ from process_bigraph import allocate_core
26
+
27
+ if protocol == "rest":
28
+ try:
29
+ import uvicorn
30
+ from process_bigraph.server.rest import start_server
31
+ except ImportError as e:
32
+ raise ImportError(
33
+ "REST server requires the [server-rest] extra: "
34
+ "pip install process-bigraph[server-rest]"
35
+ ) from e
36
+ core = allocate_core()
37
+ app = start_server(core)
38
+ uvicorn.run(app, host=host, port=port)
39
+ else:
40
+ raise ValueError(
41
+ f"Unknown protocol {protocol!r}. Supported: rest"
42
+ )
43
+
44
+
45
+ if __name__ == "__main__":
46
+ fire.Fire(start)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.3.1
3
+ Version: 1.4.0
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,14 @@ Requires-Dist: pint>=0.24.4
12
12
  Requires-Dist: scipy>=1.8
13
13
  Requires-Dist: pandas
14
14
  Requires-Dist: sympy
15
+ Provides-Extra: ray
16
+ Requires-Dist: ray>=2.10; extra == "ray"
17
+ Provides-Extra: server-rest
18
+ Requires-Dist: fastapi>=0.116.1; extra == "server-rest"
19
+ Requires-Dist: fastapi-utils>=0.8.0; extra == "server-rest"
20
+ Requires-Dist: uvicorn>=0.35.0; extra == "server-rest"
21
+ Requires-Dist: fire; extra == "server-rest"
22
+ Requires-Dist: typing-inspect>=0.9.0; extra == "server-rest"
15
23
  Dynamic: license-file
16
24
 
17
25
  # Process-Bigraph
@@ -27,7 +27,11 @@ process_bigraph/processes/parameter_scan.py
27
27
  process_bigraph/processes/reaction.py
28
28
  process_bigraph/protocols/__init__.py
29
29
  process_bigraph/protocols/parallel.py
30
+ process_bigraph/protocols/ray.py
30
31
  process_bigraph/protocols/rest.py
31
32
  process_bigraph/protocols/socket.py
33
+ process_bigraph/server/__init__.py
34
+ process_bigraph/server/rest.py
35
+ process_bigraph/server/start.py
32
36
  process_bigraph/types/__init__.py
33
37
  process_bigraph/types/process.py
@@ -0,0 +1,16 @@
1
+ bigraph-schema>=1.2.3
2
+ matplotlib
3
+ pint>=0.24.4
4
+ scipy>=1.8
5
+ pandas
6
+ sympy
7
+
8
+ [ray]
9
+ ray>=2.10
10
+
11
+ [server-rest]
12
+ fastapi>=0.116.1
13
+ fastapi-utils>=0.8.0
14
+ uvicorn>=0.35.0
15
+ fire
16
+ typing-inspect>=0.9.0
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "process-bigraph"
7
+ version = "1.4.0"
8
+ description = "protocol and execution for compositional systems biology"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "bigraph-schema>=1.2.3",
13
+ "matplotlib",
14
+ "pint>=0.24.4",
15
+ "scipy>=1.8",
16
+ "pandas",
17
+ "sympy",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ # Distributed Ray actor backend for processes (RayProcess + actor pool).
22
+ # Install with: pip install process-bigraph[ray]
23
+ ray = [
24
+ "ray>=2.10",
25
+ ]
26
+
27
+ # REST server (FastAPI/uvicorn) that hosts processes over HTTP, paired with
28
+ # the existing RestProcess client.
29
+ # Install with: pip install process-bigraph[server-rest]
30
+ server-rest = [
31
+ "fastapi>=0.116.1",
32
+ "fastapi-utils>=0.8.0",
33
+ "uvicorn>=0.35.0",
34
+ "fire",
35
+ "typing-inspect>=0.9.0",
36
+ ]
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "ipdb>=0.13.13",
41
+ "ipykernel",
42
+ "jupyter",
43
+ "notebook",
44
+ "pytest",
45
+ "pytest-cov",
46
+ "httpx",
47
+ # Test the optional extras without forcing them on everyone.
48
+ "ray>=2.10",
49
+ "fastapi>=0.116.1",
50
+ "fastapi-utils>=0.8.0",
51
+ "uvicorn>=0.35.0",
52
+ "fire",
53
+ "typing-inspect>=0.9.0",
54
+ ]
55
+
56
+ [tool.setuptools]
57
+ packages = [
58
+ "process_bigraph",
59
+ "process_bigraph.processes",
60
+ "process_bigraph.protocols",
61
+ "process_bigraph.server",
62
+ "process_bigraph.experiments",
63
+ "process_bigraph.types",
64
+ ]
@@ -1,6 +0,0 @@
1
- bigraph-schema>=1.2.3
2
- matplotlib
3
- pint>=0.24.4
4
- scipy>=1.8
5
- pandas
6
- sympy
@@ -1,37 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=68", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "process-bigraph"
7
- version = "1.3.1"
8
- description = "protocol and execution for compositional systems biology"
9
- readme = "README.md"
10
- requires-python = ">=3.11"
11
- dependencies = [
12
- "bigraph-schema>=1.2.3",
13
- "matplotlib",
14
- "pint>=0.24.4",
15
- "scipy>=1.8",
16
- "pandas",
17
- "sympy",
18
- ]
19
-
20
- [dependency-groups]
21
- dev = [
22
- "ipdb>=0.13.13",
23
- "ipykernel",
24
- "jupyter",
25
- "notebook",
26
- "pytest",
27
- "pytest-cov",
28
- ]
29
-
30
- [tool.setuptools]
31
- packages = [
32
- "process_bigraph",
33
- "process_bigraph.processes",
34
- "process_bigraph.protocols",
35
- "process_bigraph.experiments",
36
- "process_bigraph.types",
37
- ]
File without changes