process-bigraph 1.8.5__tar.gz → 1.9.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 (80) hide show
  1. {process_bigraph-1.8.5/process_bigraph.egg-info → process_bigraph-1.9.0}/PKG-INFO +6 -4
  2. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/README.md +4 -2
  3. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/__init__.py +17 -1
  4. process_bigraph-1.9.0/process_bigraph/audit_contracts.py +128 -0
  5. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/composite.py +204 -6
  6. process_bigraph-1.9.0/process_bigraph/composite_audit.py +193 -0
  7. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/composite_spec.py +77 -0
  8. process_bigraph-1.9.0/process_bigraph/contract_drift.py +253 -0
  9. process_bigraph-1.9.0/process_bigraph/contract_strict.py +176 -0
  10. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/events.py +40 -5
  11. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/scheduling.py +85 -0
  12. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/types/process.py +32 -4
  13. {process_bigraph-1.8.5 → process_bigraph-1.9.0/process_bigraph.egg-info}/PKG-INFO +6 -4
  14. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph.egg-info/SOURCES.txt +5 -0
  15. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph.egg-info/requires.txt +1 -1
  16. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/pyproject.toml +3 -2
  17. process_bigraph-1.9.0/tests/test_multirate_scheduling.py +70 -0
  18. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/AUTHORS.md +0 -0
  19. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/LICENSE +0 -0
  20. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/artifacts.py +0 -0
  21. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/bundle.py +0 -0
  22. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/composite_discovery.py +0 -0
  23. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/composite_generator.py +0 -0
  24. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/config_helpers.py +0 -0
  25. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/core_introspection.py +0 -0
  26. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/draft_process.py +0 -0
  27. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/emitter.py +0 -0
  28. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/experiments/__init__.py +0 -0
  29. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  30. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/nextflow.py +0 -0
  31. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/nextflow_deploy.py +0 -0
  32. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/plumbing.py +0 -0
  33. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/__init__.py +0 -0
  34. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/bigraphical_reactive_system.py +0 -0
  35. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/cached.py +0 -0
  36. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/dynamic_structure.py +0 -0
  37. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/examples.py +0 -0
  38. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/growth_division.py +0 -0
  39. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/parameter_scan.py +0 -0
  40. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/reaction.py +0 -0
  41. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/processes/simulation.py +0 -0
  42. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/__init__.py +0 -0
  43. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/_git_worker.py +0 -0
  44. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/clusters/__init__.py +0 -0
  45. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/clusters/ec2_ssm.py +0 -0
  46. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/git.py +0 -0
  47. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/parallel.py +0 -0
  48. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/pool.py +0 -0
  49. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/ray.py +0 -0
  50. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/rest.py +0 -0
  51. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/protocols/session.py +0 -0
  52. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/run.py +0 -0
  53. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/run_composite.py +0 -0
  54. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/run_step.py +0 -0
  55. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/server/__init__.py +0 -0
  56. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/server/rest.py +0 -0
  57. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/server/start.py +0 -0
  58. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/templates.py +0 -0
  59. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/types/__init__.py +0 -0
  60. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualization.py +0 -0
  61. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualizations/__init__.py +0 -0
  62. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualizations/distribution.py +0 -0
  63. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualizations/heatmap.py +0 -0
  64. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualizations/param_vs_observable.py +0 -0
  65. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualizations/phase_space.py +0 -0
  66. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualizations/time_series.py +0 -0
  67. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/visualizations/timeseries_from_observables.py +0 -0
  68. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/wiring_translator.py +0 -0
  69. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/workflow/__init__.py +0 -0
  70. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/workflow/backend.py +0 -0
  71. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/workflow/provision.py +0 -0
  72. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/workflow/recipe.py +0 -0
  73. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph/workflow/tasks.py +0 -0
  74. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph.egg-info/dependency_links.txt +0 -0
  75. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/process_bigraph.egg-info/top_level.txt +0 -0
  76. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/setup.cfg +0 -0
  77. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/tests/test_composite_generator_analyses.py +0 -0
  78. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/tests/test_process_decorator.py +0 -0
  79. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/tests/test_reaction_step.py +0 -0
  80. {process_bigraph-1.8.5 → process_bigraph-1.9.0}/tests/test_units.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.8.5
3
+ Version: 1.9.0
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  License-File: AUTHORS.md
9
- Requires-Dist: bigraph-schema>=1.6.0
9
+ Requires-Dist: bigraph-schema>=1.8.1
10
10
  Requires-Dist: requests>=2.31
11
11
  Requires-Dist: matplotlib
12
12
  Requires-Dist: pint>=0.24.4
@@ -119,7 +119,8 @@ core.register_link('Grow', Grow) # register it in the local
119
119
  composite = Composite({'state': {
120
120
  'level': 1.0, # a shared store
121
121
  'grow': {'_type': 'process', 'address': 'local:Grow', 'config': {'rate': 0.5},
122
- 'interval': 1.0, 'inputs': {'level': ['level']}, 'outputs': {'level': ['level']}},
122
+ 'interval': 1.0, # seconds; a unit string like '1 min' also works
123
+ 'inputs': {'level': ['level']}, 'outputs': {'level': ['level']}},
123
124
  'emitter': emitter_from_wires({'level': ['level'], 'time': ['global_time']}),
124
125
  }}, core=core)
125
126
  composite.run(5.0)
@@ -234,7 +235,8 @@ rendered to HTML and published automatically on GitHub Pages.
234
235
 
235
236
  - **Tutorial 3 — Declarative Math**
236
237
  *Defining mathematical relationships, signal pipelines, and events using `MathExpressionStep`*
237
- https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
238
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
239
+ Requires the companion package [`viva-basic-processes`](https://github.com/vivarium-collective/viva-basic-processes), published to PyPI as `pbg-basic-processes` (`pip install "pbg-basic-processes[plotting]>=0.3.0"`), which provides `MathExpressionStep`, the `Tick` process, and the plotting helpers.
238
240
 
239
241
  - **Tutorial 4 — Composing a Biological Model**
240
242
  *The central dogma from four small processes: composition over shared molecular state, biological units in the port types, and adding regulation by adding a wire*
@@ -92,7 +92,8 @@ core.register_link('Grow', Grow) # register it in the local
92
92
  composite = Composite({'state': {
93
93
  'level': 1.0, # a shared store
94
94
  'grow': {'_type': 'process', 'address': 'local:Grow', 'config': {'rate': 0.5},
95
- 'interval': 1.0, 'inputs': {'level': ['level']}, 'outputs': {'level': ['level']}},
95
+ 'interval': 1.0, # seconds; a unit string like '1 min' also works
96
+ 'inputs': {'level': ['level']}, 'outputs': {'level': ['level']}},
96
97
  'emitter': emitter_from_wires({'level': ['level'], 'time': ['global_time']}),
97
98
  }}, core=core)
98
99
  composite.run(5.0)
@@ -207,7 +208,8 @@ rendered to HTML and published automatically on GitHub Pages.
207
208
 
208
209
  - **Tutorial 3 — Declarative Math**
209
210
  *Defining mathematical relationships, signal pipelines, and events using `MathExpressionStep`*
210
- https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
211
+ https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
212
+ Requires the companion package [`viva-basic-processes`](https://github.com/vivarium-collective/viva-basic-processes), published to PyPI as `pbg-basic-processes` (`pip install "pbg-basic-processes[plotting]>=0.3.0"`), which provides `MathExpressionStep`, the `Tick` process, and the plotting helpers.
211
213
 
212
214
  - **Tutorial 4 — Composing a Biological Model**
213
215
  *The central dogma from four small processes: composition over shared molecular state, biological units in the port types, and adding regulation by adding a wire*
@@ -1,4 +1,20 @@
1
- from bigraph_schema import allocate_core # noqa: F401
1
+ from bigraph_schema import allocate_core as _allocate_core
2
+
3
+
4
+ def allocate_core(*args, strict=False, **kwargs):
5
+ """Allocate a core (see :func:`bigraph_schema.allocate_core`).
6
+
7
+ Adds process-bigraph's opt-in strict-config mode (issue #232): with
8
+ ``strict=True`` the returned core carries ``strict_config = True``, so
9
+ instantiating a Process/Step with a config key that is not declared in
10
+ its ``config_schema`` raises a clear error instead of silently ignoring
11
+ the key. The default (``strict=False``) preserves existing behavior
12
+ exactly. You can also toggle it on an existing core with
13
+ ``core.strict_config = True``.
14
+ """
15
+ core = _allocate_core(*args, **kwargs)
16
+ core.strict_config = bool(strict)
17
+ return core
2
18
 
3
19
  # The installed distribution's version, exposed so a run manifest can record
4
20
  # *which* engine produced an artifact. Read from installed metadata rather
@@ -0,0 +1,128 @@
1
+ """CI audit gate for declared process contracts (spec section 5).
2
+
3
+ Audits every registered process's DECLARED contract (static — no instantiation)
4
+ and fails (exit 1) on any error-severity finding, or on a declared-coverage
5
+ floor. Read the class `.contract` directly (contract_of drops amendments). In
6
+ process-bigraph's own CI few processes declare contracts, so the error-gate is
7
+ largely a regression guard; the real value is downstream workspaces running
8
+ `python -m process_bigraph.audit_contracts` against their registries, and the
9
+ --require-declared floor makes coverage visible.
10
+ """
11
+ import argparse
12
+ import json as _json
13
+ import sys
14
+
15
+ try:
16
+ from bigraph_schema.contract_audit import audit_contract, completeness
17
+ from bigraph_schema.assembly import contract_of
18
+ from bigraph_schema.contract import resolve_contract
19
+ AUDIT_AVAILABLE = True
20
+ except Exception: # noqa: BLE001
21
+ AUDIT_AVAILABLE = False
22
+
23
+ from process_bigraph import allocate_core
24
+
25
+ _INCOMPLETE_BELOW = 0.5
26
+
27
+
28
+ def _class_key(cls):
29
+ return f'{getattr(cls, "__module__", "?")}.{getattr(cls, "__qualname__", getattr(cls, "__name__", "?"))}'
30
+
31
+
32
+ def _contract_for(core, address, cls):
33
+ """Declared contract of a class, amendments preserved. Reads the class
34
+ `.contract` first (contract_of drops amendments), then falls back."""
35
+ declared = getattr(cls, 'contract', None)
36
+ if declared is not None:
37
+ try:
38
+ return resolve_contract(cls) # normalizes ProcessContract/dict; never raises
39
+ except Exception: # noqa: BLE001
40
+ pass
41
+ try:
42
+ return contract_of(core, address)
43
+ except Exception: # noqa: BLE001
44
+ return None
45
+
46
+
47
+ def audit_all(core, *, require_declared=0, strict_resolve=False):
48
+ from process_bigraph.core_introspection import list_processes
49
+ reports = {}
50
+ for address in list_processes(core):
51
+ try:
52
+ cls = core.link_registry.get(address)
53
+ except Exception: # noqa: BLE001 - per-entry import failure
54
+ cls = None
55
+ if cls is None:
56
+ reports.setdefault(f'unresolvable:{address}',
57
+ {'address': address, 'cls': address, 'status': 'unresolvable',
58
+ 'grade': None, 'findings': [], 'declared': False})
59
+ continue
60
+ key = _class_key(cls)
61
+ if key in reports:
62
+ continue # alias dedup
63
+ contract = _contract_for(core, address, cls)
64
+ if contract is None:
65
+ reports[key] = {'address': address, 'cls': key, 'status': 'not-declared', 'grade': None, 'findings': [], 'declared': False}
66
+ continue
67
+ try:
68
+ report = audit_contract(core, contract)
69
+ grade = completeness(contract)
70
+ findings = [{'severity': f.severity, 'code': f.code, 'where': f.where, 'message': f.message}
71
+ for f in report.findings]
72
+ except Exception as error: # noqa: BLE001
73
+ reports[key] = {'address': address, 'cls': key, 'status': 'unresolvable', 'grade': None,
74
+ 'findings': [{'severity': 'error', 'code': 'audit_raised', 'where': key, 'message': str(error)}], 'declared': False}
75
+ continue
76
+ has_contract_attr = getattr(cls, 'contract', None) is not None
77
+ is_declared = has_contract_attr or bool(contract.conditions())
78
+ if any(f['severity'] == 'error' for f in findings):
79
+ status = 'fail'
80
+ elif not is_declared:
81
+ status = 'not-declared'
82
+ elif grade < _INCOMPLETE_BELOW:
83
+ status = 'incomplete'
84
+ else:
85
+ status = 'pass'
86
+ reports[key] = {'address': address, 'cls': key, 'status': status, 'grade': round(grade, 3), 'findings': findings, 'declared': is_declared}
87
+
88
+ rows = list(reports.values())
89
+ errors = sum(1 for r in rows for f in r['findings'] if f['severity'] == 'error')
90
+ if strict_resolve:
91
+ errors += sum(1 for r in rows if r['status'] == 'unresolvable')
92
+ declared_count = sum(1 for r in rows if r.get('declared'))
93
+ exit_code = 1 if (errors > 0 or (require_declared > 0 and declared_count < require_declared)) else 0
94
+ return {'reports': rows, 'declared': declared_count, 'errors': errors, 'exit_code': exit_code}
95
+
96
+
97
+ def _build_parser():
98
+ p = argparse.ArgumentParser(prog='python -m process_bigraph.audit_contracts',
99
+ description='Audit registered process contracts; exit 1 on any error finding.')
100
+ p.add_argument('--require-declared', type=int, default=0, metavar='N',
101
+ help='fail if fewer than N processes declare a real contract (default 0 = off).')
102
+ p.add_argument('--strict-resolve', action='store_true', help='treat unresolvable processes as failures.')
103
+ p.add_argument('--json', action='store_true', help='emit the full report as JSON.')
104
+ return p
105
+
106
+
107
+ def main(argv=None):
108
+ args = _build_parser().parse_args(argv)
109
+ if not AUDIT_AVAILABLE:
110
+ print('contract audit unavailable: bigraph-schema >= 1.7.0 required', file=sys.stderr)
111
+ return 2
112
+ core = allocate_core()
113
+ result = audit_all(core, require_declared=args.require_declared, strict_resolve=args.strict_resolve)
114
+ if args.json:
115
+ print(_json.dumps(result, indent=2))
116
+ return result['exit_code']
117
+ for r in sorted(result['reports'], key=lambda x: (x['status'] != 'fail', x['cls'])):
118
+ grade = '' if r['grade'] is None else f" {int(r['grade'] * 100)}%"
119
+ print(f"{r['status']:13} {r['cls']}{grade}")
120
+ for f in r['findings']:
121
+ print(f" {f['severity']} {f['code']} {f['where']}: {f['message']}")
122
+ print(f"\n{result['declared']} declared, {result['errors']} error finding(s) "
123
+ f"-> {'FAIL' if result['exit_code'] else 'OK'}")
124
+ return result['exit_code']
125
+
126
+
127
+ if __name__ == '__main__':
128
+ sys.exit(main())
@@ -76,6 +76,7 @@ from process_bigraph.scheduling import ( # noqa: F401
76
76
  build_trigger_state,
77
77
  find_downstream,
78
78
  determine_steps,
79
+ find_step_cycle,
79
80
  )
80
81
 
81
82
 
@@ -312,6 +313,36 @@ def interval_time_precision(timestep: float) -> int:
312
313
  # Process Classes
313
314
  # ===============
314
315
 
316
+ def validate_config_keys(instance, config, core):
317
+ """Reject config keys not declared in ``config_schema`` when strict mode is on.
318
+
319
+ Opt-in "strict config" mode (issue #232). By default a Process/Step
320
+ silently ignores config keys that aren't part of its ``config_schema``,
321
+ so a typo'd or unsupported key passes unnoticed. When the core carries
322
+ ``strict_config`` truthy (set it directly or via
323
+ :func:`process_bigraph.allocate_core(strict=True)`), any such undeclared
324
+ key raises a clear :class:`ValueError` naming the offending key(s) and the
325
+ process type.
326
+
327
+ This is a no-op when ``strict_config`` is falsy/absent on the core, so the
328
+ default behavior of every existing workspace is unchanged.
329
+ """
330
+ if config is None or core is None:
331
+ return
332
+ if not getattr(core, 'strict_config', False):
333
+ return
334
+ declared = set((getattr(instance, 'config_schema', None) or {}).keys())
335
+ unknown = [key for key in config if key not in declared]
336
+ if unknown:
337
+ type_name = type(instance).__name__
338
+ raise ValueError(
339
+ f"{type_name} received unknown config key(s) {sorted(unknown)!r} "
340
+ f"that are not declared in its config_schema "
341
+ f"(declared keys: {sorted(declared)!r}). Strict-config mode is on "
342
+ f"(core.strict_config is set / allocate_core(strict=True)); pass "
343
+ f"only declared keys, fix the typo, or disable strict mode.")
344
+
345
+
315
346
  class SyncUpdate:
316
347
  """
317
348
  Wrapper for synchronous process updates.
@@ -348,6 +379,11 @@ class Open(Edge):
348
379
  self._pending_command: Optional[
349
380
  Tuple[str, Optional[tuple], Optional[dict]]] = None
350
381
 
382
+ # Opt-in strict-config check (issue #232): reject undeclared config
383
+ # keys before the edge fills/initializes. No-op unless the core has
384
+ # strict_config set, so default behavior is unchanged.
385
+ validate_config_keys(self, config, core)
386
+
351
387
  super().__init__(config, core=core)
352
388
 
353
389
  def pre_send_command(
@@ -1148,6 +1184,17 @@ def _project_process_update(update_results: Any, args: Tuple[Any, str, Any]) ->
1148
1184
  to the previous per-call closure.
1149
1185
  """
1150
1186
  composite, ports_key, process_path = args
1187
+ strict = getattr(composite, '_contract_strict', 'off')
1188
+ if strict != 'off':
1189
+ # v1: only a dict delta is post-checked; list-form (multi-update) is left unchecked.
1190
+ compiled = composite._compiled_contracts.get(process_path)
1191
+ if compiled is not None and isinstance(update_results, dict):
1192
+ from process_bigraph.contract_strict import check_post, handle_violations
1193
+ inputs = composite._cached_view(process_path)
1194
+ fails = check_post(compiled, inputs, update_results)
1195
+ handle_violations(fails, mode=strict, phase='post', path=process_path,
1196
+ cls='process', emitter=composite._em,
1197
+ global_time=composite.state.get('global_time', 0.0) if isinstance(composite.state, dict) else 0.0)
1151
1198
  if not isinstance(update_results, list):
1152
1199
  update_results = [update_results]
1153
1200
  return [
@@ -1215,6 +1262,8 @@ class Composite(Process):
1215
1262
  # step hot path releases the GIL (numpy / scipy / numba / C
1216
1263
  # extensions: yes; pure-Python loops: no).
1217
1264
  'parallel_steps': 'boolean{false}',
1265
+ # Runtime contract checking: off (default, zero-cost) | record | raise.
1266
+ 'contract_strict': 'string{off}',
1218
1267
  # Time-scheduled processes: when True, the per-tick `run` loop
1219
1268
  # fans out invocations across a thread pool. Right tool when
1220
1269
  # processes' update() releases the GIL (network I/O, numpy /
@@ -1279,6 +1328,16 @@ class Composite(Process):
1279
1328
  if 'global_time' not in initial_state:
1280
1329
  initial_state['global_time'] = 0.0
1281
1330
 
1331
+ # Reject open template holes BEFORE realizing, over the document as
1332
+ # given. Realization cannot cope with a site where it expects a type
1333
+ # or an address: a declared `schema` entry silently swallows a `state`
1334
+ # site (it realizes the declared type and drops the hole, so the
1335
+ # composite runs on the type default with no error — case A), and an
1336
+ # open `address` site crashes deep in `realize_link` with an opaque
1337
+ # `KeyError: 'data'` (case B). Checking the raw document first turns
1338
+ # both into the same clear "not ground" error that names the site.
1339
+ self._require_ground_input(initial_schema, initial_state)
1340
+
1282
1341
  # Generate internal schema and state structures using the core engine.
1283
1342
  # Top-level realize: realize_root defaults to () so no merges escape.
1284
1343
  self.schema, self.state, _ = self.core.realize(
@@ -1423,6 +1482,8 @@ class Composite(Process):
1423
1482
  # field in interface_schema for the rationale (threading vs.
1424
1483
  # multiprocessing for inner parallelism).
1425
1484
  self._parallel_steps = bool(self.config.get('parallel_steps', False))
1485
+ self._contract_strict = self.config.get('contract_strict') or getattr(self.core, 'contract_strict', 'off')
1486
+ self._compiled_contracts = {}
1426
1487
  self._parallel_processes = bool(self.config.get('parallel_processes', False))
1427
1488
  self._parallel_workers = self.config.get('parallel_workers')
1428
1489
  self._step_executor = None # lazy: created on first run_steps need
@@ -1916,16 +1977,27 @@ class Composite(Process):
1916
1977
  path for any path not present.
1917
1978
  """
1918
1979
  self._compiled_links = {}
1980
+ self._compiled_contracts = {}
1919
1981
 
1920
1982
  for path in list(self.process_paths) + list(self.step_paths):
1921
1983
  compiled = self.core.precompile_link(
1922
1984
  self.schema, self.state, path)
1923
1985
  if compiled is not None:
1924
1986
  self._compiled_links[path] = compiled
1987
+ if self._contract_strict != 'off':
1988
+ try:
1989
+ from process_bigraph.contract_strict import compile_contract
1990
+ node = self.state
1991
+ for key in path:
1992
+ node = node[key]
1993
+ self._compiled_contracts[path] = compile_contract(node['instance'].describe_contract())
1994
+ except Exception: # noqa: BLE001 - a process without a readable contract is simply unchecked
1995
+ self._compiled_contracts[path] = None
1925
1996
 
1926
1997
  def _invalidate_caches(self) -> None:
1927
1998
  """Invalidate precompiled link caches, forcing rebuild on next use."""
1928
1999
  self._compiled_links = {}
2000
+ self._compiled_contracts = {}
1929
2001
  self._runtime_partition_cache = None
1930
2002
 
1931
2003
  def _cached_view(self, path: Tuple[str, ...]) -> Dict[str, Any]:
@@ -2312,6 +2384,29 @@ class Composite(Process):
2312
2384
  # Build the step execution dependency graph
2313
2385
  self.step_dependencies, self.node_dependencies = build_step_network(self.step_paths)
2314
2386
 
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).
2394
+ cycle = find_step_cycle(self.step_dependencies, self.node_dependencies)
2395
+ if cycle is not None:
2396
+ def _name(step_key):
2397
+ if isinstance(step_key, (tuple, list)):
2398
+ return '/'.join(str(part) for part in step_key)
2399
+ return str(step_key)
2400
+ rendered = ' -> '.join(_name(step_key) for step_key in cycle)
2401
+ 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.')
2409
+
2315
2410
  # Initialize trigger fulfillment state and steps remaining
2316
2411
  self.reset_step_state(self.step_paths)
2317
2412
 
@@ -2569,6 +2664,27 @@ class Composite(Process):
2569
2664
  incrementally based on their configured interval. Updates are applied and
2570
2665
  steps are triggered accordingly.
2571
2666
 
2667
+ Update-timing contract (matters only when intervals are unequal)
2668
+ ----------------------------------------------------------------
2669
+ A process reads its input ports from the shared state **at the start of
2670
+ its interval** (the moment it becomes due), computes its update right
2671
+ then, and the delta is applied when ``global_time`` reaches
2672
+ ``due_time + interval`` — the "stash" model (Supplement 1, §3.8.6).
2673
+ This is **not** the idealized read-at-event-time ``t*`` rule of
2674
+ Supplement 1, §3.8.2: the two agree when every process shares one
2675
+ interval and diverge otherwise, because a slow process applies a delta
2676
+ computed from inputs as they stood one interval earlier. See
2677
+ :meth:`run_process` and ``docs/concepts/composites-and-templates.md``
2678
+ ("Update timing with unequal intervals").
2679
+
2680
+ Chunking caveat: the read-at-start guarantee holds only for a process
2681
+ whose whole next interval fits inside this ``run`` call. A process whose
2682
+ next due time falls past ``global_time + interval`` is deferred to the
2683
+ next ``run`` call and re-reads its inputs there, so ``run(T)`` once can
2684
+ differ from several shorter ``run`` calls over the same span. For
2685
+ chunking-invariant results, pass an ``interval`` at least as large as the
2686
+ largest process interval, or keep intervals equal.
2687
+
2572
2688
  After completion, ``self.process_update_time`` holds the cumulative time
2573
2689
  spent inside process ``invoke()`` calls, and ``self.framework_time`` holds
2574
2690
  the time spent in framework operations (view, project, apply, realize).
@@ -2783,6 +2899,64 @@ class Composite(Process):
2783
2899
  per_process=dict(self._per_process_time),
2784
2900
  )
2785
2901
 
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`).')
2911
+
2912
+ def _require_ground_input(
2913
+ self,
2914
+ initial_schema: Dict[str, Any],
2915
+ initial_state: Dict[str, Any],
2916
+ ) -> None:
2917
+ """Reject unfilled template holes in the raw document, before realizing.
2918
+
2919
+ The post-realize :meth:`_require_ground_document` check sees only the
2920
+ sites realization *kept*, which is too late for two cases:
2921
+
2922
+ - **A — an open site silently swallowed.** When ``schema`` declares a
2923
+ store's type, ``realize`` takes the type from ``schema`` and drops
2924
+ the ``site`` in ``state``; the realized schema no longer contains a
2925
+ hole, so the post-check passes and the composite runs on the type's
2926
+ default value with no error — a template that was never filled runs
2927
+ as if it had been.
2928
+ - **B — an open ``address`` site.** A process/step node whose
2929
+ ``address`` is still a site cannot be realized at all: ``realize``
2930
+ fails inside ``realize_link`` with an opaque ``KeyError: 'data'``
2931
+ before the groundness check can report the real problem.
2932
+
2933
+ Checking the document *as given* — the ``state`` as the caller wrote
2934
+ it, where sites still live — catches both and names the offending
2935
+ site, exactly as the baseline (state-only) case already does. The
2936
+ post-realize check stays as a backstop.
2937
+
2938
+ Checked over **sites** (a ``_default``-carrying site is optional and
2939
+ does not block), consistent with :meth:`_require_ground_document`; see
2940
+ :func:`process_bigraph.templates.required_open_sites`.
2941
+ """
2942
+ try:
2943
+ from bigraph_schema.assembly import interfaces
2944
+ # A site is a state-level hole: inspect the document as written,
2945
+ # so a declared `schema` type cannot hide a `state` site.
2946
+ 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})
2951
+ except Exception:
2952
+ # If the raw document cannot be inspected for any reason, do not
2953
+ # mask that behind this check — fall through to realization and
2954
+ # the post-realize backstop, preserving prior behaviour.
2955
+ return
2956
+
2957
+ if unfilled:
2958
+ self._raise_not_ground(unfilled)
2959
+
2786
2960
  def _require_ground_document(self) -> None:
2787
2961
  """Reject a document that still has unfilled template holes.
2788
2962
 
@@ -2792,6 +2966,10 @@ class Composite(Process):
2792
2966
  Checking here turns what would be a confusing failure deep in the run
2793
2967
  into a clear one at construction.
2794
2968
 
2969
+ This runs *after* realization as a backstop; the primary check is
2970
+ :meth:`_require_ground_input`, which inspects the raw document before
2971
+ realization can swallow or crash on an open site.
2972
+
2795
2973
  Deliberately checked over **sites**, not ``is_ground``: any composite
2796
2974
  containing a process has unwired ports by construction, so
2797
2975
  ``is_ground`` is never true for a real composite and would reject
@@ -2808,12 +2986,7 @@ class Composite(Process):
2808
2986
  if getattr(site, '_default', None) is None})
2809
2987
 
2810
2988
  if unfilled:
2811
- named = ', '.join(repr('/'.join(path)) for path in unfilled)
2812
- raise ValueError(
2813
- f'composite document is not ground — required site(s) left '
2814
- f'unfilled: {named}. An open site is a hole where a process '
2815
- f'or value should be; fill it before constructing the '
2816
- f'composite (bigraph_schema `fill_sites` / `build`).')
2989
+ self._raise_not_ground(unfilled)
2817
2990
 
2818
2991
  def _require_advancing_interval(
2819
2992
  self,
@@ -2870,6 +3043,23 @@ class Composite(Process):
2870
3043
  This updates the `self.front` to store when the process is due next,
2871
3044
  and captures its update as a deferred computation.
2872
3045
 
3046
+ Read-at-start contract: when the process is due
3047
+ (``process_time <= global_time``), its input state is sliced **now**
3048
+ via ``_cached_view(path)`` and ``process_update`` is called immediately,
3049
+ but the resulting ``Defer`` is stashed in ``self.front[path]['update']``
3050
+ and only applied once ``global_time`` reaches ``future`` (= due time +
3051
+ interval). So inputs are read at the **start** of the interval and the
3052
+ delta lands at the **end**. With unequal intervals this differs from the
3053
+ read-at-event-time ``t*`` rule of Supplement 1 §3.8.2; it is the §3.8.6
3054
+ stash model. See :meth:`run` and the concepts doc for the full contract.
3055
+
3056
+ Note: the ``'future'`` branch below pops a per-process state stash that
3057
+ §3.8.6 uses to keep a long interval chunking-invariant across successive
3058
+ ``run`` calls. Nothing currently writes that key, so a process whose next
3059
+ interval overruns the current ``run`` window is simply deferred and
3060
+ re-reads its inputs on the next ``run`` call (see the chunking caveat on
3061
+ :meth:`run`).
3062
+
2873
3063
  Args:
2874
3064
  path: The path to the process in the state/schema tree.
2875
3065
  process: The dictionary representing the process (must contain 'interval').
@@ -3077,6 +3267,14 @@ class Composite(Process):
3077
3267
  # summary of the state it was handed (process_bigraph.events), then
3078
3268
  # re-raise the ORIGINAL exception unchanged -- callers may classify
3079
3269
  # control-flow exceptions by type, so the type must propagate as is.
3270
+ if self._contract_strict != 'off':
3271
+ compiled = self._compiled_contracts.get(path)
3272
+ if compiled is not None:
3273
+ from process_bigraph.contract_strict import check_pre, handle_violations
3274
+ fails = check_pre(compiled, clean_state)
3275
+ handle_violations(fails, mode=self._contract_strict, phase='pre', path=path,
3276
+ cls=type(process['instance']).__name__, emitter=self._em,
3277
+ global_time=self.state.get('global_time', 0.0) if isinstance(self.state, dict) else 0.0)
3080
3278
  t0 = _time.monotonic()
3081
3279
  try:
3082
3280
  update = process['instance'].invoke(clean_state, interval)