process-bigraph 1.9.0__tar.gz → 1.10.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 (82) hide show
  1. {process_bigraph-1.9.0/process_bigraph.egg-info → process_bigraph-1.10.0}/PKG-INFO +36 -20
  2. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/README.md +35 -19
  3. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/__init__.py +39 -1
  4. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/composite.py +136 -122
  5. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/composite_discovery.py +10 -2
  6. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/composite_generator.py +37 -51
  7. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/composite_spec.py +24 -1
  8. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/draft_process.py +52 -0
  9. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/experiments/minimal_gillespie.py +1 -54
  10. process_bigraph-1.10.0/process_bigraph/holes.py +168 -0
  11. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/nextflow.py +0 -37
  12. process_bigraph-1.10.0/process_bigraph/orchestration.py +127 -0
  13. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/examples.py +0 -28
  14. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/ray.py +20 -1
  15. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/scheduling.py +221 -20
  16. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/templates.py +14 -8
  17. {process_bigraph-1.9.0 → process_bigraph-1.10.0/process_bigraph.egg-info}/PKG-INFO +36 -20
  18. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph.egg-info/SOURCES.txt +2 -1
  19. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/pyproject.toml +1 -1
  20. process_bigraph-1.9.0/process_bigraph/run.py +0 -29
  21. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/AUTHORS.md +0 -0
  22. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/LICENSE +0 -0
  23. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/artifacts.py +0 -0
  24. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/audit_contracts.py +0 -0
  25. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/bundle.py +0 -0
  26. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/composite_audit.py +0 -0
  27. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/config_helpers.py +0 -0
  28. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/contract_drift.py +0 -0
  29. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/contract_strict.py +0 -0
  30. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/core_introspection.py +0 -0
  31. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/emitter.py +0 -0
  32. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/events.py +0 -0
  33. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/experiments/__init__.py +0 -0
  34. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/nextflow_deploy.py +0 -0
  35. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/plumbing.py +0 -0
  36. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/__init__.py +0 -0
  37. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/bigraphical_reactive_system.py +0 -0
  38. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/cached.py +0 -0
  39. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/dynamic_structure.py +0 -0
  40. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/growth_division.py +0 -0
  41. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/parameter_scan.py +0 -0
  42. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/reaction.py +0 -0
  43. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/processes/simulation.py +0 -0
  44. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/__init__.py +0 -0
  45. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/_git_worker.py +0 -0
  46. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/clusters/__init__.py +0 -0
  47. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/clusters/ec2_ssm.py +0 -0
  48. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/git.py +0 -0
  49. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/parallel.py +0 -0
  50. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/pool.py +0 -0
  51. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/rest.py +0 -0
  52. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/protocols/session.py +0 -0
  53. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/run_composite.py +0 -0
  54. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/run_step.py +0 -0
  55. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/server/__init__.py +0 -0
  56. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/server/rest.py +0 -0
  57. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/server/start.py +0 -0
  58. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/types/__init__.py +0 -0
  59. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/types/process.py +0 -0
  60. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualization.py +0 -0
  61. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualizations/__init__.py +0 -0
  62. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualizations/distribution.py +0 -0
  63. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualizations/heatmap.py +0 -0
  64. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualizations/param_vs_observable.py +0 -0
  65. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualizations/phase_space.py +0 -0
  66. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualizations/time_series.py +0 -0
  67. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/visualizations/timeseries_from_observables.py +0 -0
  68. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/wiring_translator.py +0 -0
  69. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/workflow/__init__.py +0 -0
  70. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/workflow/backend.py +0 -0
  71. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/workflow/provision.py +0 -0
  72. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/workflow/recipe.py +0 -0
  73. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph/workflow/tasks.py +0 -0
  74. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph.egg-info/dependency_links.txt +0 -0
  75. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph.egg-info/requires.txt +0 -0
  76. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/process_bigraph.egg-info/top_level.txt +0 -0
  77. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/setup.cfg +0 -0
  78. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/tests/test_composite_generator_analyses.py +0 -0
  79. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/tests/test_multirate_scheduling.py +0 -0
  80. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/tests/test_process_decorator.py +0 -0
  81. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/tests/test_reaction_step.py +0 -0
  82. {process_bigraph-1.9.0 → process_bigraph-1.10.0}/tests/test_units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.9.0
3
+ Version: 1.10.0
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -128,28 +128,44 @@ print(gather_emitter_results(composite))
128
128
  # {('emitter',): [{'level': 1.0, 'time': 0.0}, {'level': 1.5, ...}, ... {'level': 7.59, 'time': 5.0}]}
129
129
  ```
130
130
 
131
- **2. A draft process — a present-but-inert placeholder node.** It declares a contract
132
- (ports + description) but has no `update`, so a composite containing it still runs; the
133
- draft just no-ops. Complementary to a *site* (step 3): a draft is a node that is *there
134
- but inert*, a site is an empty *hole*.
131
+ **2. A draft — an open hole whose sort is a contract.** A not-yet-implemented
132
+ process-slot is a *hole*: an open **site** (step 3) whose `_sort` names a
133
+ `ProcessContract` (its typed face + conditions). You design and review the topology
134
+ with the hole in place, then fill it with any process the contract *admits*.
135
+ `Composite` refuses to build while a required hole is open, and reports **which** hole,
136
+ **what** contract it requires, and **which** registered processes would fill it.
135
137
 
136
138
  ```python
137
- from process_bigraph import DraftProcess, draft_process
138
-
139
- @draft_process(name="PTH secretion",
140
- inputs={'ca_sense': 'float'}, outputs={'pth_out': 'float'},
141
- contract={'summary': 'senses serum Ca, secretes PTH', 'senses': 'calcium', 'makes': 'PTH'})
142
- class PTHSecretion(DraftProcess):
143
- pass
144
-
145
- core.register_link('PTHSecretion', PTHSecretion)
146
- print(PTHSecretion({}, core=core).describe())
147
- # DRAFT — senses serum Ca, secretes PTH · makes: PTH · senses: calcium · status: draft - no update dynamics yet
139
+ from bigraph_schema.contract import ProcessContract
140
+ from process_bigraph import find_candidates, UnfilledHoleError
141
+
142
+ # register the intended interface as a named contract …
143
+ core.register_contract('PTHSecretion', ProcessContract(
144
+ summary='senses serum Ca, secretes PTH',
145
+ face={'inputs': {'ca_sense': 'float'}, 'outputs': {'pth_out': 'float'}}))
146
+
147
+ # … and leave the slot as a hole sorted by it
148
+ template = core.access({'parathyroid': {'ca': 2.5, 'pth': 0.0,
149
+ 'secretion': {'_type': 'site', '_sort': 'PTHSecretion'}}})
150
+
151
+ # Composite(..., unfilled) → UnfilledHoleError, not an opaque KeyError:
152
+ # not ground — required site(s) left unfilled: 'parathyroid/secretion'
153
+ # • parathyroid/secretion
154
+ # requires:
155
+ # inputs: ca_sense: float
156
+ # outputs: pth_out: float
157
+ # candidates:
158
+ # ✓ <any registered process whose contract admits the hole>
148
159
  ```
149
160
 
150
- A module-scope draft auto-registers, so it appears in the vivarium-workbench dashboard
151
- (Modules → Processes) marked **DRAFT** with its ports and contract — no code change
152
- needed. Replace it with a real `Process` once the mechanism is committed.
161
+ Fill it (step 3) with a process `find_candidates(core, site)` reports as admissible,
162
+ and build. The full design → inspect → fill → build lifecycle — and the rejection
163
+ report in full — is in
164
+ [docs/concepts/designing-with-holes.md](docs/concepts/designing-with-holes.md).
165
+
166
+ > **Deprecated:** `@draft_process` / `DraftProcess` (an inert placeholder *node*) still
167
+ > work but now emit `DraftProcessDeprecationWarning` — model a draft as a hole instead.
168
+ > `draft_to_contract(cls)` bridges an existing draft's interface to a `ProcessContract`.
153
169
 
154
170
  **3. A template — a composite with an open site (hole).** A template is a document that
155
171
  isn't *ground*: it has open **sites** (`{"_type": "site"}`). `Composite` refuses to run
@@ -184,7 +200,7 @@ def model(rate): # any conforming composite
184
200
  sim = Composite({'state': template_document(core, template, {'study/model': model(0.5)})}, core=core)
185
201
  sim.run(4.0)
186
202
  print(sim.state['study']['verdict']) # pass (a slower model → fail)
187
- # template_document(core, template, {}) → ValueError: not ground — required site 'study/model'
203
+ # template_document(core, template, {}) → UnfilledHoleError: not ground — required site 'study/model'
188
204
  ```
189
205
 
190
206
  **4. An investigation — one site per member study; gating is *filling*.** Fill a
@@ -101,28 +101,44 @@ print(gather_emitter_results(composite))
101
101
  # {('emitter',): [{'level': 1.0, 'time': 0.0}, {'level': 1.5, ...}, ... {'level': 7.59, 'time': 5.0}]}
102
102
  ```
103
103
 
104
- **2. A draft process — a present-but-inert placeholder node.** It declares a contract
105
- (ports + description) but has no `update`, so a composite containing it still runs; the
106
- draft just no-ops. Complementary to a *site* (step 3): a draft is a node that is *there
107
- but inert*, a site is an empty *hole*.
104
+ **2. A draft — an open hole whose sort is a contract.** A not-yet-implemented
105
+ process-slot is a *hole*: an open **site** (step 3) whose `_sort` names a
106
+ `ProcessContract` (its typed face + conditions). You design and review the topology
107
+ with the hole in place, then fill it with any process the contract *admits*.
108
+ `Composite` refuses to build while a required hole is open, and reports **which** hole,
109
+ **what** contract it requires, and **which** registered processes would fill it.
108
110
 
109
111
  ```python
110
- from process_bigraph import DraftProcess, draft_process
111
-
112
- @draft_process(name="PTH secretion",
113
- inputs={'ca_sense': 'float'}, outputs={'pth_out': 'float'},
114
- contract={'summary': 'senses serum Ca, secretes PTH', 'senses': 'calcium', 'makes': 'PTH'})
115
- class PTHSecretion(DraftProcess):
116
- pass
117
-
118
- core.register_link('PTHSecretion', PTHSecretion)
119
- print(PTHSecretion({}, core=core).describe())
120
- # DRAFT — senses serum Ca, secretes PTH · makes: PTH · senses: calcium · status: draft - no update dynamics yet
112
+ from bigraph_schema.contract import ProcessContract
113
+ from process_bigraph import find_candidates, UnfilledHoleError
114
+
115
+ # register the intended interface as a named contract …
116
+ core.register_contract('PTHSecretion', ProcessContract(
117
+ summary='senses serum Ca, secretes PTH',
118
+ face={'inputs': {'ca_sense': 'float'}, 'outputs': {'pth_out': 'float'}}))
119
+
120
+ # … and leave the slot as a hole sorted by it
121
+ template = core.access({'parathyroid': {'ca': 2.5, 'pth': 0.0,
122
+ 'secretion': {'_type': 'site', '_sort': 'PTHSecretion'}}})
123
+
124
+ # Composite(..., unfilled) → UnfilledHoleError, not an opaque KeyError:
125
+ # not ground — required site(s) left unfilled: 'parathyroid/secretion'
126
+ # • parathyroid/secretion
127
+ # requires:
128
+ # inputs: ca_sense: float
129
+ # outputs: pth_out: float
130
+ # candidates:
131
+ # ✓ <any registered process whose contract admits the hole>
121
132
  ```
122
133
 
123
- A module-scope draft auto-registers, so it appears in the vivarium-workbench dashboard
124
- (Modules → Processes) marked **DRAFT** with its ports and contract — no code change
125
- needed. Replace it with a real `Process` once the mechanism is committed.
134
+ Fill it (step 3) with a process `find_candidates(core, site)` reports as admissible,
135
+ and build. The full design → inspect → fill → build lifecycle — and the rejection
136
+ report in full — is in
137
+ [docs/concepts/designing-with-holes.md](docs/concepts/designing-with-holes.md).
138
+
139
+ > **Deprecated:** `@draft_process` / `DraftProcess` (an inert placeholder *node*) still
140
+ > work but now emit `DraftProcessDeprecationWarning` — model a draft as a hole instead.
141
+ > `draft_to_contract(cls)` bridges an existing draft's interface to a `ProcessContract`.
126
142
 
127
143
  **3. A template — a composite with an open site (hole).** A template is a document that
128
144
  isn't *ground*: it has open **sites** (`{"_type": "site"}`). `Composite` refuses to run
@@ -157,7 +173,7 @@ def model(rate): # any conforming composite
157
173
  sim = Composite({'state': template_document(core, template, {'study/model': model(0.5)})}, core=core)
158
174
  sim.run(4.0)
159
175
  print(sim.state['study']['verdict']) # pass (a slower model → fail)
160
- # template_document(core, template, {}) → ValueError: not ground — required site 'study/model'
176
+ # template_document(core, template, {}) → UnfilledHoleError: not ground — required site 'study/model'
161
177
  ```
162
178
 
163
179
  **4. An investigation — one site per member study; gating is *filling*.** Fill a
@@ -14,6 +14,35 @@ def allocate_core(*args, strict=False, **kwargs):
14
14
  """
15
15
  core = _allocate_core(*args, **kwargs)
16
16
  core.strict_config = bool(strict)
17
+ _register_builtin_emitters(core)
18
+ return core
19
+
20
+
21
+ def _register_builtin_emitters(core):
22
+ """Ensure process-bigraph's built-in emitters are registered under their
23
+ SHORT names (``RAMEmitter`` etc.), not only their fully-qualified names.
24
+
25
+ Discovery normally registers both, but when several emitter packages
26
+ co-register in an unlucky import order the short alias is silently dropped
27
+ (see ``process_bigraph.emitter._VIVA_REEXPORTS``) — leaving only the FQN
28
+ ``process_bigraph.emitter.RAMEmitter`` and breaking the framework's own
29
+ default sink ``local:RAMEmitter``. Registering the built-ins explicitly here
30
+ is order-independent and idempotent (we only fill a missing short alias).
31
+ """
32
+ try:
33
+ from process_bigraph.emitter import (
34
+ Emitter, ConsoleEmitter, RAMEmitter, JSONEmitter)
35
+ except Exception:
36
+ return
37
+ registry = getattr(core, 'link_registry', None)
38
+ for cls in (Emitter, ConsoleEmitter, RAMEmitter, JSONEmitter):
39
+ name = cls.__name__
40
+ if registry is not None and name in registry:
41
+ continue
42
+ try:
43
+ core.register_link(name, cls)
44
+ except Exception:
45
+ pass
17
46
  return core
18
47
 
19
48
  # The installed distribution's version, exposed so a run manifest can record
@@ -41,7 +70,16 @@ from process_bigraph.composite_spec import ( # noqa: F401
41
70
  normalize_type, CANONICAL_TYPES,
42
71
  )
43
72
  from process_bigraph.bundle import load_bundle # noqa: F401
44
- from process_bigraph.draft_process import DraftProcess, draft_process # noqa: F401
73
+ from process_bigraph.draft_process import ( # noqa: F401
74
+ DraftProcess, draft_process, draft_to_contract, DraftProcessDeprecationWarning,
75
+ )
76
+ # Holes-in-place: the public lifecycle for a process-slot left as a hole whose
77
+ # sort is a ProcessContract — design, inspect, see what fits, fill, build.
78
+ from process_bigraph.holes import UnfilledHoleError # noqa: F401
79
+ from process_bigraph.templates import ( # noqa: F401
80
+ open_sites, required_open_sites, is_ground_document, fill_template, template_document,
81
+ )
82
+ from bigraph_schema.matching import find_candidates # noqa: F401
45
83
  from process_bigraph.emitter import Emitter, gather_emitter_results, generate_emitter_state # noqa: F401
46
84
  from process_bigraph.types import StepLink, ProcessLink, CompositeLink # noqa: F401
47
85
  from process_bigraph import core_introspection # noqa: F401
@@ -1264,6 +1264,7 @@ class Composite(Process):
1264
1264
  'parallel_steps': 'boolean{false}',
1265
1265
  # Runtime contract checking: off (default, zero-cost) | record | raise.
1266
1266
  'contract_strict': 'string{off}',
1267
+ 'orchestration': 'string{temporal}',
1267
1268
  # Time-scheduled processes: when True, the per-tick `run` loop
1268
1269
  # fans out invocations across a thread pool. Right tool when
1269
1270
  # processes' update() releases the GIL (network I/O, numpy /
@@ -2384,13 +2385,16 @@ class Composite(Process):
2384
2385
  # Build the step execution dependency graph
2385
2386
  self.step_dependencies, self.node_dependencies = build_step_network(self.step_paths)
2386
2387
 
2387
- # Reject dependency cycles among zero-time steps. The paper
2388
- # (Supplement 1, 3.8.4) guarantees the step network is a DAG:
2389
- # "Cycles are disallowed, ensuring deterministic zero-time DAG
2390
- # evaluation." A cycle has no valid evaluation order, so the
2391
- # scheduler would otherwise fall back to a hash-seed-dependent
2392
- # tie-break and the same document would give different results in
2393
- # different processes (issue #233).
2388
+ # Reject only NON-DETERMINISTIC dependency cycles among zero-time
2389
+ # steps. The paper (Supplement 1, 3.8.4) guarantees the step network
2390
+ # is a DAG, but in practice ``determine_steps`` resolves a cycle by
2391
+ # firing the highest-priority runnable step first: that is
2392
+ # deterministic when the cyclic steps carry distinct priorities, and
2393
+ # ``find_step_cycle`` allows those (with a warning). It returns a
2394
+ # cycle here only when two of its members share a priority, so the
2395
+ # tie would fall through to a hash-seed-dependent order and the same
2396
+ # document would give different results in different processes
2397
+ # (issue #233).
2394
2398
  cycle = find_step_cycle(self.step_dependencies, self.node_dependencies)
2395
2399
  if cycle is not None:
2396
2400
  def _name(step_key):
@@ -2399,13 +2403,16 @@ class Composite(Process):
2399
2403
  return str(step_key)
2400
2404
  rendered = ' -> '.join(_name(step_key) for step_key in cycle)
2401
2405
  raise ValueError(
2402
- f'step dependency cycle detected: {rendered}. '
2403
- f'Steps evaluate in zero time as a DAG, so a cycle has no '
2404
- f'deterministic evaluation order (process-bigraph paper, '
2405
- f'Supplement 1, 3.8.4). Break the cycle by removing one of '
2406
- f'the mutual input/output wirings between these steps, or '
2407
- f'model the feedback as a Process with a non-zero time '
2408
- f'interval.')
2406
+ f'non-deterministic step dependency cycle detected: '
2407
+ f'{rendered}. These steps form a zero-time feedback cycle AND '
2408
+ f'share a priority, so their evaluation order would depend on '
2409
+ f'PYTHONHASHSEED-driven set-iteration order and differ between '
2410
+ f'processes (process-bigraph paper, Supplement 1, 3.8.4; '
2411
+ f'issue #233). Resolve it by giving the cyclic steps '
2412
+ f'distinct priorities (the scheduler then orders them '
2413
+ f'deterministically), removing one of the mutual input/output '
2414
+ f'wirings, or modelling the feedback as a Process with a '
2415
+ f'non-zero time interval.')
2409
2416
 
2410
2417
  # Initialize trigger fulfillment state and steps remaining
2411
2418
  self.reset_step_state(self.step_paths)
@@ -2728,7 +2735,7 @@ class Composite(Process):
2728
2735
  # control flows on the same thread.
2729
2736
  _cc_token = current_composite_var.set(self)
2730
2737
  try:
2731
- return self._run_inner(interval, force_complete, run_start)
2738
+ return self._orchestration_strategy().advance(interval, force_complete, run_start)
2732
2739
  except BaseException as exc:
2733
2740
  status, error = 'error', f'{type(exc).__name__}: {exc}'
2734
2741
  raise
@@ -2775,101 +2782,101 @@ class Composite(Process):
2775
2782
  if span is not None:
2776
2783
  span.end(status, error)
2777
2784
 
2778
- def _run_inner(self, interval: float, force_complete: bool,
2779
- run_start: float) -> None:
2780
- end_time = self.state['global_time'] + interval
2781
-
2782
- # Run any steps that are ready (from init or previous triggers)
2785
+ def _orchestration_strategy(self):
2786
+ """Resolve (and cache) this Composite's orchestration strategy from the
2787
+ registry. Defaults to ``temporal`` — the event-scheduling loop in
2788
+ :meth:`advance_tick` — so a Composite that declares no ``orchestration``
2789
+ runs exactly as before."""
2790
+ strat = getattr(self, "_orchestration", None)
2791
+ if strat is None:
2792
+ from process_bigraph.orchestration import get_orchestration
2793
+ name = self.config.get("orchestration") or "temporal"
2794
+ strat = self._orchestration = get_orchestration(name)(self)
2795
+ return strat
2796
+
2797
+ def run_step_network(self) -> None:
2798
+ """Drain the ready-step DAG to completion: ``run_steps`` self-cascades
2799
+ through ``cycle_step_state``/``trigger_steps``, so this runs every
2800
+ currently-ready step in dependency order. The step-DAG primitive shared
2801
+ by the temporal and workflow orchestration strategies."""
2783
2802
  if self.to_run:
2784
2803
  self.run_steps(self.to_run)
2785
2804
  self.to_run = []
2786
2805
 
2787
- em = self._em
2788
- while self.state['global_time'] < end_time or force_complete:
2789
- full_step = math.inf
2790
- self._run_ticks = getattr(self, '_run_ticks', 0) + 1
2791
- em.heartbeat(global_time=self.state['global_time'])
2792
-
2793
- # Partition processes: ones whose runtime opts into batched
2794
- # tick_lifecycle vs the regular per-process path. The
2795
- # runtime-managed group(s) get one method call per runtime,
2796
- # which (a) skips N _cached_view + N invoke calls in this
2797
- # tick's invoke pass, and (b) returns one combined Defer that
2798
- # apply_updates walks once instead of N times.
2799
- managed_paths, runtime_groups = self._partition_processes_by_runtime()
2800
-
2801
- # Run each plain (non-runtime-managed) process and compute the
2802
- # minimum time step that advances simulation.
2803
- if self._parallel_processes and len(self.process_paths) > 1:
2804
- full_step = self._run_processes_layer_parallel(
2805
- end_time, full_step, force_complete,
2806
- skip_paths=managed_paths)
2807
- else:
2808
- for path in self.process_paths:
2809
- if path in managed_paths:
2810
- continue
2811
- process = get_path(self.state, path)
2812
- full_step = self.run_process(
2813
- path, process, end_time, full_step, force_complete)
2814
-
2815
- # Now dispatch each runtime group via its tick_lifecycle hook.
2816
- # Each call returns one combined Defer (placed at a common-path
2817
- # slot in self.front) so the downstream apply_updates walks
2818
- # the schema once for the entire batch.
2819
- for rt, group in runtime_groups:
2820
- full_step = self._run_tick_lifecycle(
2821
- rt, group, end_time, full_step, force_complete)
2822
-
2823
- if full_step == math.inf:
2824
- # No process ran — jump to the next scheduled process time
2825
- next_event = end_time
2826
- for path in self.front.keys():
2827
- if self.front[path]['time'] < next_event:
2828
- next_event = self.front[path]['time']
2829
- self.state['global_time'] = next_event
2830
-
2831
- elif self.state['global_time'] + full_step <= end_time:
2832
- # At least one process ran — advance time and apply its update
2833
- self.state['global_time'] += full_step
2834
- updates = []
2835
- paths = []
2836
-
2837
- for path, advance in self.front.items():
2838
- if advance['time'] <= self.state['global_time'] and advance['update']:
2839
- updates.append(advance['update'])
2840
- advance['update'] = {}
2841
- paths.append(path)
2842
-
2843
- fw_start = _time.monotonic()
2844
- self._flush_protocol_runtimes()
2845
- update_paths = self.apply_updates(updates)
2846
- update_paths.append(('global_time',)) # updated global time can trigger steps
2847
- self.expire_process_paths(update_paths)
2848
- self.steps_run = set() # Reset for new timestep
2849
- # Opt-in halt after structural change. EcoliSim sets
2850
- # ``_halt_after_structural=True`` to mirror v1's halt
2851
- # via DivisionDetected. Dynamic-structure tests leave
2852
- # the flag unset for natural cascading.
2853
- if (getattr(self, '_halt_after_structural', False)
2854
- and getattr(self, '_last_apply_structural', False)):
2855
- self.framework_time += _time.monotonic() - fw_start
2856
- return
2857
- self.trigger_steps(update_paths)
2806
+ def advance_tick(self, end_time: float, force_complete: bool):
2807
+ """Execute ONE temporal tick toward ``end_time`` — the body of the
2808
+ event-scheduling loop. Returns ``(halted, force_complete)``: ``halted``
2809
+ is True when an opt-in structural halt fired and the run must stop now;
2810
+ ``force_complete`` is returned because the tick may clear it. The
2811
+ ``temporal`` strategy drives this in a loop while ``Composite`` owns the
2812
+ tick mechanics (front, invoke, apply), keeping strategies thin."""
2813
+ full_step = math.inf
2814
+ self._run_ticks = getattr(self, '_run_ticks', 0) + 1
2815
+ self._em.heartbeat(global_time=self.state['global_time'])
2816
+
2817
+ # Partition processes: runtime-batched (tick_lifecycle) vs per-process.
2818
+ managed_paths, runtime_groups = self._partition_processes_by_runtime()
2819
+
2820
+ if self._parallel_processes and len(self.process_paths) > 1:
2821
+ full_step = self._run_processes_layer_parallel(
2822
+ end_time, full_step, force_complete,
2823
+ skip_paths=managed_paths)
2824
+ else:
2825
+ for path in self.process_paths:
2826
+ if path in managed_paths:
2827
+ continue
2828
+ process = get_path(self.state, path)
2829
+ full_step = self.run_process(
2830
+ path, process, end_time, full_step, force_complete)
2831
+
2832
+ for rt, group in runtime_groups:
2833
+ full_step = self._run_tick_lifecycle(
2834
+ rt, group, end_time, full_step, force_complete)
2835
+
2836
+ if full_step == math.inf:
2837
+ # No process ran — jump to the next scheduled process time
2838
+ next_event = end_time
2839
+ for path in self.front.keys():
2840
+ if self.front[path]['time'] < next_event:
2841
+ next_event = self.front[path]['time']
2842
+ self.state['global_time'] = next_event
2843
+
2844
+ elif self.state['global_time'] + full_step <= end_time:
2845
+ # At least one process ran — advance time and apply its update
2846
+ self.state['global_time'] += full_step
2847
+ updates = []
2848
+ paths = []
2849
+
2850
+ for path, advance in self.front.items():
2851
+ if advance['time'] <= self.state['global_time'] and advance['update']:
2852
+ updates.append(advance['update'])
2853
+ advance['update'] = {}
2854
+ paths.append(path)
2855
+
2856
+ fw_start = _time.monotonic()
2857
+ self._flush_protocol_runtimes()
2858
+ update_paths = self.apply_updates(updates)
2859
+ update_paths.append(('global_time',)) # updated global time can trigger steps
2860
+ self.expire_process_paths(update_paths)
2861
+ self.steps_run = set() # Reset for new timestep
2862
+ # Opt-in halt after structural change (EcoliSim parity). On halt the
2863
+ # run stops immediately, skipping the post-loop bookkeeping — the
2864
+ # driving loop returns without finalizing framework_time.
2865
+ if (getattr(self, '_halt_after_structural', False)
2866
+ and getattr(self, '_last_apply_structural', False)):
2858
2867
  self.framework_time += _time.monotonic() - fw_start
2868
+ return True, force_complete
2869
+ self.trigger_steps(update_paths)
2870
+ self.framework_time += _time.monotonic() - fw_start
2859
2871
 
2860
- else:
2861
- # All remaining process events are beyond end_time
2862
- self.state['global_time'] = end_time
2872
+ else:
2873
+ # All remaining process events are beyond end_time
2874
+ self.state['global_time'] = end_time
2863
2875
 
2864
- if force_complete and self.state['global_time'] == end_time:
2865
- force_complete = False
2876
+ if force_complete and self.state['global_time'] == end_time:
2877
+ force_complete = False
2866
2878
 
2867
- total = _time.monotonic() - run_start
2868
- # Framework time = total minus process update time
2869
- # (the process_update_time was accumulated in process_update and,
2870
- # for batched-runtime processes, in _run_tick_lifecycle)
2871
- self.framework_time = total - self.process_update_time
2872
- self._last_run_total_time = total
2879
+ return False, force_complete
2873
2880
 
2874
2881
  def timing_summary(self) -> TimingSummary:
2875
2882
  """Return a ``TimingSummary`` describing the most recent ``run()`` call.
@@ -2899,15 +2906,20 @@ class Composite(Process):
2899
2906
  per_process=dict(self._per_process_time),
2900
2907
  )
2901
2908
 
2902
- @staticmethod
2903
- def _raise_not_ground(unfilled) -> None:
2904
- """Raise the shared "not ground" error naming the open site paths."""
2905
- named = ', '.join(repr('/'.join(path)) for path in unfilled)
2906
- raise ValueError(
2907
- f'composite document is not ground — required site(s) left '
2908
- f'unfilled: {named}. An open site is a hole where a process '
2909
- f'or value should be; fill it before constructing the '
2910
- f'composite (bigraph_schema `fill_sites` / `build`).')
2909
+ def _raise_unfilled(self, unfilled_pairs) -> None:
2910
+ """Raise a contract-aware :class:`~process_bigraph.holes.UnfilledHoleError`
2911
+ for the open ``(path, site)`` pairs.
2912
+
2913
+ Names each hole's required contract (typed face + conditions) and the
2914
+ registered processes that would fill it, in place of the opaque
2915
+ ``KeyError: 'data'`` an unfilled address site would otherwise raise deep
2916
+ in realization. The error subclasses ``ValueError``, so existing
2917
+ ``except ValueError`` callers keep catching it, and its message keeps the
2918
+ "not ground — required site(s) left unfilled" lineage.
2919
+ """
2920
+ from process_bigraph.holes import build_unfilled_hole_error
2921
+ ordered = sorted(unfilled_pairs, key=lambda pair: pair[0])
2922
+ raise build_unfilled_hole_error(self.core, ordered)
2911
2923
 
2912
2924
  def _require_ground_input(
2913
2925
  self,
@@ -2944,10 +2956,12 @@ class Composite(Process):
2944
2956
  # A site is a state-level hole: inspect the document as written,
2945
2957
  # so a declared `schema` type cannot hide a `state` site.
2946
2958
  accessed = self.core.access(initial_state)
2947
- unfilled = sorted({
2948
- tuple(path)
2949
- for path, site in interfaces(accessed)[0]._places
2950
- if getattr(site, '_default', None) is None})
2959
+ # Keep one (path, site) per unique path; collect_sites/interfaces may
2960
+ # address a site more than once.
2961
+ unfilled = {}
2962
+ for path, site in interfaces(accessed)[0]._places:
2963
+ if getattr(site, '_default', None) is None:
2964
+ unfilled.setdefault(tuple(path), site)
2951
2965
  except Exception:
2952
2966
  # If the raw document cannot be inspected for any reason, do not
2953
2967
  # mask that behind this check — fall through to realization and
@@ -2955,7 +2969,7 @@ class Composite(Process):
2955
2969
  return
2956
2970
 
2957
2971
  if unfilled:
2958
- self._raise_not_ground(unfilled)
2972
+ self._raise_unfilled(list(unfilled.items()))
2959
2973
 
2960
2974
  def _require_ground_document(self) -> None:
2961
2975
  """Reject a document that still has unfilled template holes.
@@ -2979,14 +2993,14 @@ class Composite(Process):
2979
2993
  from bigraph_schema.assembly import collect_sites
2980
2994
 
2981
2995
  # ``collect_sites`` addresses each site twice (bare key and path);
2982
- # collapse to unique paths.
2983
- unfilled = sorted({
2984
- tuple(path)
2985
- for path, site in collect_sites(self.schema).values()
2986
- if getattr(site, '_default', None) is None})
2996
+ # collapse to one (path, site) per unique path.
2997
+ unfilled = {}
2998
+ for path, site in collect_sites(self.schema).values():
2999
+ if getattr(site, '_default', None) is None:
3000
+ unfilled.setdefault(tuple(path), site)
2987
3001
 
2988
3002
  if unfilled:
2989
- self._raise_not_ground(unfilled)
3003
+ self._raise_unfilled(list(unfilled.items()))
2990
3004
 
2991
3005
  def _require_advancing_interval(
2992
3006
  self,
@@ -83,8 +83,10 @@ def discover_all(
83
83
 
84
84
  Spec entries get ``kind: spec`` and pass through the existing spec
85
85
  payload. Generator entries get ``kind: generator`` and a compact dict
86
- {name, description, parameters, module} so dashboards can render them
87
- without holding a reference to the live function object.
86
+ {name, description, parameters, visualizations, emitters, module,
87
+ default_n_steps, methods, fidelity} so dashboards can render them (and
88
+ report their modeling provenance) without holding a reference to the live
89
+ function object.
88
90
 
89
91
  Discovery imports the host packages of every generator (see
90
92
  :func:`process_bigraph.composite_generator.discover_generators`). If
@@ -118,6 +120,12 @@ def discover_all(
118
120
  "emitters": list(entry.emitters),
119
121
  "module": entry.module,
120
122
  "default_n_steps": entry.default_n_steps,
123
+ # Modeling-provenance metadata — so a consumer that only sees this
124
+ # serialized dict (e.g. a dashboard across a worker boundary) can
125
+ # still report how the composite is built and whether it wraps a
126
+ # genuine simulator. Always present (empty list / "" when undeclared).
127
+ "methods": list(getattr(entry, "methods", []) or []),
128
+ "fidelity": getattr(entry, "fidelity", "") or "",
121
129
  }
122
130
  return out
123
131