process-bigraph 1.8.4__tar.gz → 1.8.5__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 (76) hide show
  1. {process_bigraph-1.8.4/process_bigraph.egg-info → process_bigraph-1.8.5}/PKG-INFO +2 -1
  2. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/composite.py +137 -77
  3. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/composite_generator.py +15 -0
  4. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/emitter.py +10 -0
  5. process_bigraph-1.8.5/process_bigraph/events.py +824 -0
  6. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/nextflow.py +23 -1
  7. process_bigraph-1.8.5/process_bigraph/nextflow_deploy.py +430 -0
  8. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/ray.py +24 -3
  9. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/run_composite.py +74 -3
  10. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/run_step.py +57 -1
  11. {process_bigraph-1.8.4 → process_bigraph-1.8.5/process_bigraph.egg-info}/PKG-INFO +2 -1
  12. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph.egg-info/SOURCES.txt +2 -0
  13. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph.egg-info/requires.txt +1 -0
  14. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/pyproject.toml +6 -1
  15. process_bigraph-1.8.5/tests/test_composite_generator_analyses.py +14 -0
  16. process_bigraph-1.8.4/process_bigraph/nextflow_deploy.py +0 -140
  17. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/AUTHORS.md +0 -0
  18. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/LICENSE +0 -0
  19. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/README.md +0 -0
  20. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/__init__.py +0 -0
  21. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/artifacts.py +0 -0
  22. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/bundle.py +0 -0
  23. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/composite_discovery.py +0 -0
  24. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/composite_spec.py +0 -0
  25. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/config_helpers.py +0 -0
  26. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/core_introspection.py +0 -0
  27. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/draft_process.py +0 -0
  28. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/experiments/__init__.py +0 -0
  29. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  30. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/plumbing.py +0 -0
  31. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/__init__.py +0 -0
  32. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/bigraphical_reactive_system.py +0 -0
  33. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/cached.py +0 -0
  34. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/dynamic_structure.py +0 -0
  35. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/examples.py +0 -0
  36. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/growth_division.py +0 -0
  37. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/parameter_scan.py +0 -0
  38. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/reaction.py +0 -0
  39. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/processes/simulation.py +0 -0
  40. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/__init__.py +0 -0
  41. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/_git_worker.py +0 -0
  42. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/clusters/__init__.py +0 -0
  43. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/clusters/ec2_ssm.py +0 -0
  44. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/git.py +0 -0
  45. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/parallel.py +0 -0
  46. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/pool.py +0 -0
  47. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/rest.py +0 -0
  48. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/protocols/session.py +0 -0
  49. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/run.py +0 -0
  50. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/scheduling.py +0 -0
  51. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/server/__init__.py +0 -0
  52. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/server/rest.py +0 -0
  53. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/server/start.py +0 -0
  54. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/templates.py +0 -0
  55. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/types/__init__.py +0 -0
  56. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/types/process.py +0 -0
  57. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualization.py +0 -0
  58. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualizations/__init__.py +0 -0
  59. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualizations/distribution.py +0 -0
  60. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualizations/heatmap.py +0 -0
  61. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualizations/param_vs_observable.py +0 -0
  62. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualizations/phase_space.py +0 -0
  63. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualizations/time_series.py +0 -0
  64. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/visualizations/timeseries_from_observables.py +0 -0
  65. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/wiring_translator.py +0 -0
  66. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/workflow/__init__.py +0 -0
  67. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/workflow/backend.py +0 -0
  68. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/workflow/provision.py +0 -0
  69. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/workflow/recipe.py +0 -0
  70. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph/workflow/tasks.py +0 -0
  71. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph.egg-info/dependency_links.txt +0 -0
  72. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/process_bigraph.egg-info/top_level.txt +0 -0
  73. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/setup.cfg +0 -0
  74. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/tests/test_process_decorator.py +0 -0
  75. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/tests/test_reaction_step.py +0 -0
  76. {process_bigraph-1.8.4 → process_bigraph-1.8.5}/tests/test_units.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.8.4
3
+ Version: 1.8.5
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
9
  Requires-Dist: bigraph-schema>=1.6.0
10
+ Requires-Dist: requests>=2.31
10
11
  Requires-Dist: matplotlib
11
12
  Requires-Dist: pint>=0.24.4
12
13
  Requires-Dist: scipy>=1.8
@@ -18,7 +18,6 @@ import math
18
18
  import inspect
19
19
  import time as _time
20
20
  import contextvars
21
- import numpy as np
22
21
 
23
22
 
24
23
  # Set by ``Composite.run`` for the duration of the run loop so Steps
@@ -38,76 +37,14 @@ def get_current_composite():
38
37
 
39
38
 
40
39
  # ---------------------------------------------------------------------------
41
- # Optional invocation tracing
40
+ # Observability
42
41
  # ---------------------------------------------------------------------------
43
- # When the env var ``PROCESS_BIGRAPH_TRACE_FILE`` is set to a writable path,
44
- # every invocation of ``Composite.process_update`` (i.e. every Process/Step
45
- # call routed through the framework) writes one JSONL record describing the
46
- # call: path, class, global_time, interval, an input summary, and an output
47
- # summary. Useful for diagnosing per-step divergence between two runs by
48
- # diffing two trace files. No overhead when the env var is unset.
49
- _TRACE_PATH = os.environ.get('PROCESS_BIGRAPH_TRACE_FILE')
50
- _TRACE_FH = open(_TRACE_PATH, 'a', buffering=1) if _TRACE_PATH else None
51
-
52
- # Default for Composite._profile_per_process (per-path invoke attribution in
53
- # TimingSummary.per_process). Off by default — the write is on the hot path —
54
- # and flippable per-run via the env var without a code change.
55
- _PROFILE_PER_PROCESS_DEFAULT = bool(
56
- os.environ.get('PROCESS_BIGRAPH_PROFILE_PROCESSES'))
57
-
58
-
59
- def _summarize_value(value, depth=0):
60
- """Lightweight, JSON-safe summary of an update fragment.
61
-
62
- Tries to surface the values most useful for diffing two traces:
63
- - Scalars and short strings inlined.
64
- - Numpy arrays summarized as ``{shape, sum, mean, head}``.
65
- - Dicts recursed (capped depth).
66
- - Lists/tuples shown as their first few items.
67
- """
68
- if depth > 3:
69
- return f'<{type(value).__name__}>'
70
- if value is None or isinstance(value, (bool, int, float, str)):
71
- return value
72
- if isinstance(value, np.ndarray):
73
- try:
74
- return {
75
- '_np': True,
76
- 'shape': list(value.shape),
77
- 'dtype': str(value.dtype),
78
- 'sum': float(value.sum()) if value.dtype.kind in 'fi' else None,
79
- 'head': value.flatten()[:5].tolist() if value.size else [],
80
- }
81
- except Exception:
82
- return f'<ndarray shape={value.shape}>'
83
- if isinstance(value, dict):
84
- return {k: _summarize_value(v, depth + 1) for k, v in list(value.items())[:32]}
85
- if isinstance(value, (list, tuple)):
86
- return [_summarize_value(v, depth + 1) for v in value[:10]]
87
- return f'<{type(value).__name__}>'
88
-
89
-
90
- def _trace_invoke(path, instance, state, interval, update):
91
- """Append one JSONL record describing a process/step invocation."""
92
- if _TRACE_FH is None:
93
- return
94
- try:
95
- gt = state.get('global_time') if isinstance(state, dict) else None
96
- rec = {
97
- 'path': list(path) if isinstance(path, tuple) else path,
98
- 'cls': type(instance).__name__,
99
- 'gt': gt,
100
- 'interval': interval,
101
- 'input': _summarize_value(state),
102
- 'output': _summarize_value(update),
103
- }
104
- _TRACE_FH.write(json.dumps(rec, default=str) + '\n')
105
- except Exception as exc:
106
- # Tracing must never break a sim. Log a single line and continue.
107
- try:
108
- _TRACE_FH.write(json.dumps({'trace_error': str(exc)}) + '\n')
109
- except Exception:
110
- pass
42
+ # Invocation tracing, per-process profiling, the run heartbeat and exception
43
+ # context all live in ``process_bigraph.events`` (one module, one set of
44
+ # switches -- see its docstring). ``_summarize_value`` is re-exported here for
45
+ # callers that imported it from ``composite``.
46
+ from process_bigraph import events as _events
47
+ from process_bigraph.events import _summarize_value # noqa: F401 (re-export)
111
48
 
112
49
  from dataclasses import dataclass, field
113
50
  from typing import (
@@ -1302,6 +1239,16 @@ class Composite(Process):
1302
1239
  # Initialization & Configuration
1303
1240
  # ==============================
1304
1241
 
1242
+ @property
1243
+ def _em(self) -> '_events.EventEmitter':
1244
+ """The process-wide event emitter (process_bigraph.events); resolved
1245
+ lazily so a Composite built before ``events.configure()`` still
1246
+ picks up the configured sinks."""
1247
+ em = self.__dict__.get('_events')
1248
+ if em is None:
1249
+ em = self._events = _events.get_emitter()
1250
+ return em
1251
+
1305
1252
  def initialize(self, config: Optional[Dict[str, Any]] = None) -> None:
1306
1253
  """
1307
1254
  Initialize the composite model from its config.
@@ -1505,7 +1452,19 @@ class Composite(Process):
1505
1452
  # ``run()`` to get the per-path breakdown. Defaults to the
1506
1453
  # ``PROCESS_BIGRAPH_PROFILE_PROCESSES`` env var so a run can be profiled
1507
1454
  # without code changes.
1508
- self._profile_per_process: bool = _PROFILE_PER_PROCESS_DEFAULT
1455
+ self._events = _events.get_emitter()
1456
+ self._profile_per_process: bool = self._events.detail_timing
1457
+ # ``run.start``/``run.end`` rate limit (per Composite instance). A
1458
+ # protocol runtime drives one Composite.run per remote step, so an
1459
+ # unthrottled pair is 2 x cells x ticks events. The first run and any
1460
+ # run that ends in error always emit; otherwise at most one pair per
1461
+ # ``heartbeat_s``, and the runs skipped in between are folded into the
1462
+ # next ``run.end`` (``runs=N`` with the summed totals).
1463
+ self._run_emitted_at: Optional[float] = None
1464
+ self._runs_folded: int = 0
1465
+ self._folded_total: float = 0.0
1466
+ self._folded_process_time: float = 0.0
1467
+ self._folded_ticks: int = 0
1509
1468
 
1510
1469
  # Precompile view/project operations for fast runtime access.
1511
1470
  self._compiled_links = {}
@@ -1548,7 +1507,13 @@ class Composite(Process):
1548
1507
  for rt in runtimes:
1549
1508
  flush = getattr(rt, 'flush_pending', None)
1550
1509
  if flush is not None:
1551
- flush()
1510
+ try:
1511
+ flush()
1512
+ except BaseException as exc:
1513
+ self._em.exception(
1514
+ exc, event_name='runtime.error', runtime=type(rt).__name__,
1515
+ global_time=self.state.get('global_time'))
1516
+ raise
1552
1517
 
1553
1518
  def _partition_processes_by_runtime(self):
1554
1519
  """Inspect every process_path and split into:
@@ -2617,6 +2582,29 @@ class Composite(Process):
2617
2582
  self._per_process_time = {}
2618
2583
  run_start = _time.monotonic()
2619
2584
 
2585
+ # Observability (process_bigraph.events): a ``run`` span when spans
2586
+ # are on, ``run.start``/``run.end`` always (no-ops when no sink is
2587
+ # configured), heartbeat counters reset per run.
2588
+ em = self._events = _events.get_emitter()
2589
+ if em.detail_timing:
2590
+ self._profile_per_process = True
2591
+ self._run_ticks = 0 # per-Composite, so nested runs do not clobber the outer count
2592
+ # Rate limit: emit this pair if it is the first run on this Composite or
2593
+ # ``heartbeat_s`` has elapsed since the last emitted pair. An error at
2594
+ # the end overrides the decision (see ``finally``), because a failure is
2595
+ # never the event to drop.
2596
+ emit_pair = (
2597
+ self._run_emitted_at is None
2598
+ or (run_start - self._run_emitted_at) >= em.heartbeat_s)
2599
+ span = em.start_span('run', interval=interval) if (
2600
+ em.enabled and em.detail_spans and emit_pair) else None
2601
+ if emit_pair:
2602
+ em.event('run.start', interval=interval,
2603
+ n_processes=len(self.process_paths), n_steps=len(self.step_paths),
2604
+ parallel=bool(getattr(self, '_parallel_processes', False)),
2605
+ global_time=self.state.get('global_time'))
2606
+ status, error = 'ok', None
2607
+
2620
2608
  # Set the current-Composite contextvar so nested Steps can
2621
2609
  # reach the parent Composite (e.g. for CompositeDivision to
2622
2610
  # read parent cell tree state without going through wires).
@@ -2625,8 +2613,51 @@ class Composite(Process):
2625
2613
  _cc_token = current_composite_var.set(self)
2626
2614
  try:
2627
2615
  return self._run_inner(interval, force_complete, run_start)
2616
+ except BaseException as exc:
2617
+ status, error = 'error', f'{type(exc).__name__}: {exc}'
2618
+ raise
2628
2619
  finally:
2629
2620
  current_composite_var.reset(_cc_token)
2621
+ if em.enabled:
2622
+ total = _time.monotonic() - run_start
2623
+ # An error always reports, even when the rate limit had
2624
+ # suppressed this run's ``run.start``.
2625
+ report = emit_pair or status == 'error'
2626
+ if not report:
2627
+ self._runs_folded += 1
2628
+ self._folded_total += total
2629
+ self._folded_process_time += self.process_update_time
2630
+ self._folded_ticks += self._run_ticks
2631
+ else:
2632
+ runs = self._runs_folded + 1
2633
+ agg_total = self._folded_total + total
2634
+ agg_process = self._folded_process_time + self.process_update_time
2635
+ payload = {
2636
+ 'status': status, 'error': error,
2637
+ 'runs': runs,
2638
+ 'total': round(agg_total, 6),
2639
+ 'process_time': round(agg_process, 6),
2640
+ 'framework_time': round(agg_total - agg_process, 6),
2641
+ 'ticks': self._folded_ticks + self._run_ticks,
2642
+ 'global_time': self.state.get('global_time'),
2643
+ }
2644
+ if not emit_pair:
2645
+ # The pair is unbalanced: this end has no matching
2646
+ # start, because the error overrode the rate limit.
2647
+ payload['start_suppressed'] = True
2648
+ if self._per_process_time:
2649
+ top = sorted(self._per_process_time.items(), key=lambda kv: kv[1], reverse=True)[:5]
2650
+ payload['top5'] = [
2651
+ ['/'.join(str(p) for p in k) if isinstance(k, tuple) else str(k), round(v, 6)]
2652
+ for k, v in top]
2653
+ em.event('run.end', level='error' if status == 'error' else 'info', **payload)
2654
+ self._run_emitted_at = _time.monotonic()
2655
+ self._runs_folded = 0
2656
+ self._folded_total = 0.0
2657
+ self._folded_process_time = 0.0
2658
+ self._folded_ticks = 0
2659
+ if span is not None:
2660
+ span.end(status, error)
2630
2661
 
2631
2662
  def _run_inner(self, interval: float, force_complete: bool,
2632
2663
  run_start: float) -> None:
@@ -2637,8 +2668,11 @@ class Composite(Process):
2637
2668
  self.run_steps(self.to_run)
2638
2669
  self.to_run = []
2639
2670
 
2671
+ em = self._em
2640
2672
  while self.state['global_time'] < end_time or force_complete:
2641
2673
  full_step = math.inf
2674
+ self._run_ticks = getattr(self, '_run_ticks', 0) + 1
2675
+ em.heartbeat(global_time=self.state['global_time'])
2642
2676
 
2643
2677
  # Partition processes: ones whose runtime opts into batched
2644
2678
  # tick_lifecycle vs the regular per-process path. The
@@ -2991,7 +3025,11 @@ class Composite(Process):
2991
3025
 
2992
3026
  if len(due) > 1:
2993
3027
  pool = self._get_step_executor(len(due))
2994
- results = list(pool.map(_invoke, due))
3028
+ # Carry the observability span context (a ContextVar) into the
3029
+ # worker threads: each call runs in its own copy of this thread's
3030
+ # context, so events emitted from a worker carry the run's ids.
3031
+ snapshot = contextvars.copy_context()
3032
+ results = list(pool.map(lambda item: snapshot.copy().run(_invoke, item), due))
2995
3033
  else:
2996
3034
  results = [_invoke(due[0])]
2997
3035
 
@@ -3034,9 +3072,25 @@ class Composite(Process):
3034
3072
  # Strip schema-specific metadata from the state (once, unless caller did)
3035
3073
  clean_state = states if already_clean else strip_schema_keys(states)
3036
3074
 
3037
- # Invoke the process and retrieve a wrapped SyncUpdate object
3075
+ # Invoke the process and retrieve a wrapped SyncUpdate object.
3076
+ # On failure, record WHICH process/step, at what global_time, with a
3077
+ # summary of the state it was handed (process_bigraph.events), then
3078
+ # re-raise the ORIGINAL exception unchanged -- callers may classify
3079
+ # control-flow exceptions by type, so the type must propagate as is.
3038
3080
  t0 = _time.monotonic()
3039
- update = process['instance'].invoke(clean_state, interval)
3081
+ try:
3082
+ update = process['instance'].invoke(clean_state, interval)
3083
+ except BaseException as exc:
3084
+ self._em.exception(
3085
+ exc,
3086
+ path='/'.join(str(p) for p in path) if isinstance(path, (list, tuple)) else str(path),
3087
+ cls=type(process['instance']).__name__,
3088
+ address=process.get('address'),
3089
+ is_step=(interval == -1.0),
3090
+ interval=interval,
3091
+ global_time=self.state.get('global_time') if isinstance(self.state, dict) else None,
3092
+ state_summary=_events.summarize_state(clean_state))
3093
+ raise
3040
3094
  dt = _time.monotonic() - t0
3041
3095
  self.process_update_time += dt
3042
3096
  # Per-process attribution is opt-in (a dict write on the hot path);
@@ -3046,7 +3100,7 @@ class Composite(Process):
3046
3100
  if self._profile_per_process:
3047
3101
  key = tuple(path) if isinstance(path, (list, tuple)) else (path,)
3048
3102
  self._per_process_time[key] = self._per_process_time.get(key, 0.0) + dt
3049
- if _TRACE_FH is not None:
3103
+ if self._em.detail_invoke:
3050
3104
  # Resolve SyncUpdate / Defer to plain dict for the trace; the
3051
3105
  # invoke return is opaque (SyncUpdate wraps a dict). We only need
3052
3106
  # a snapshot for the trace, so pull .get() if available.
@@ -3054,7 +3108,7 @@ class Composite(Process):
3054
3108
  resolved = update.get() if hasattr(update, 'get') else update
3055
3109
  except Exception:
3056
3110
  resolved = '<unresolvable>'
3057
- _trace_invoke(path, process['instance'], clean_state, interval, resolved)
3111
+ self._em.invoke(path, process['instance'], clean_state, interval, resolved)
3058
3112
 
3059
3113
  # Return a deferred object that will project the update when requested.
3060
3114
  # The projector is the module-level ``_project_process_update`` (not a
@@ -3219,8 +3273,14 @@ class Composite(Process):
3219
3273
  finally:
3220
3274
  uninstall_reconcile_sink(prev_sink)
3221
3275
  update_paths = summary.paths
3276
+ self._em.count(leaf_paths_touched=len(update_paths))
3222
3277
  if summary.has_structural:
3223
3278
  had_structural_sentinels = True
3279
+ self._em.count(structural_changes=1)
3280
+ self._em.event(
3281
+ 'structure.changed', n_paths=len(update_paths),
3282
+ sample_paths=['/'.join(str(p) for p in path) for path in update_paths[:8]],
3283
+ global_time=self.state.get('global_time'))
3224
3284
 
3225
3285
  if combined_update:
3226
3286
  # NOTE: previously this path also forced
@@ -42,6 +42,11 @@ class GeneratorEntry:
42
42
  # a Study is built on top of this composite the dashboard merges these
43
43
  # defaults into its visualizations list; Studies can still declare extras.
44
44
  visualizations: list[dict] = field(default_factory=list)
45
+ # Canonical analyses that ship with this composite. Each entry is a
46
+ # Study-spec analysis dict ({name, params?}) or a scale-grouped block,
47
+ # matching CompositeSpec.analyses. Merged under a run's config-declared
48
+ # analyses when a composite runs standalone (config wins).
49
+ analyses: list[dict] = field(default_factory=list)
45
50
  # Emitter(s) this composite ships as its default observation sink. Each
46
51
  # entry is a lightweight ``{address, config, paths?}`` dict: ``address`` is
47
52
  # the registered emitter link (e.g. ``"local:ParquetEmitter"``), ``config``
@@ -80,6 +85,7 @@ def _entry_for(spec) -> GeneratorEntry:
80
85
  module=spec.module,
81
86
  default_n_steps=spec.default_n_steps,
82
87
  visualizations=spec.visualizations,
88
+ analyses=spec.analyses,
83
89
  emitters=spec.emitters,
84
90
  core_extensions=spec.core_extensions,
85
91
  )
@@ -155,6 +161,7 @@ class _RegistryView:
155
161
  module=getattr(entry, "module", ""),
156
162
  default_n_steps=getattr(entry, "default_n_steps", None),
157
163
  visualizations=list(getattr(entry, "visualizations", []) or []),
164
+ analyses=list(getattr(entry, "analyses", []) or []),
158
165
  emitters=list(getattr(entry, "emitters", []) or []),
159
166
  core_extensions=list(getattr(entry, "core_extensions", []) or []),
160
167
  )
@@ -180,6 +187,7 @@ def composite_generator(
180
187
  description: str = "",
181
188
  parameters: dict[str, dict] | None = None,
182
189
  visualizations: list[dict] | None = None,
190
+ analyses: list[dict] | None = None,
183
191
  emitters: list[dict] | None = None,
184
192
  default_n_steps: int | None = None,
185
193
  core_extensions: list[Callable[[Any], Any]] | None = None,
@@ -209,6 +217,12 @@ def composite_generator(
209
217
  composite author considers canonical) without having to hand-author them
210
218
  in every Study spec.
211
219
 
220
+ `analyses` declares the canonical analyses that ship with this composite.
221
+ Each entry is a Study-spec analysis dict (``{name, params?}``) or a
222
+ scale-grouped block, matching ``CompositeSpec.analyses``. Mirrors
223
+ ``visualizations``: a run built on this composite standalone merges these
224
+ defaults under any config-declared analyses (config wins).
225
+
212
226
  `emitters` (optional) declares the default observation sink(s) this
213
227
  composite ships with. Each entry is a lightweight
214
228
  ``{"address": "local:ParquetEmitter", "config": {...}, "paths": [...]}``
@@ -266,6 +280,7 @@ def composite_generator(
266
280
  description=description,
267
281
  parameters=parameters,
268
282
  visualizations=visualizations,
283
+ analyses=analyses,
269
284
  emitters=validated_emitters,
270
285
  default_n_steps=default_n_steps,
271
286
  core_extensions=core_extensions,
@@ -584,6 +584,16 @@ class ConsoleEmitter(Emitter):
584
584
  def tree_copy(state):
585
585
  '''Deep copy utility for nested simulation state (excluding Edge instances).'''
586
586
  if isinstance(state, dict):
587
+ # A realized process/step node carries its live Edge under 'instance'.
588
+ # Its remaining keys (address/config/inputs/outputs/interval/_type) are
589
+ # static wiring, not observable state — deep-copying them every tick is
590
+ # a large, pointless cost for composites that embed a process under each
591
+ # agent (e.g. cells/<id>/chemotaxis: ~34 of 49 nodes/cell are the process
592
+ # spec, a ~15x blow-up paid every emit). The live Edge was already
593
+ # dropped below; drop the whole node so process specs never reach the
594
+ # emitted history.
595
+ if isinstance(state.get('instance'), Edge):
596
+ return None
587
597
  return {k: v for k, v in ((k, tree_copy(v)) for k, v in state.items()) if v is not None}
588
598
  if isinstance(state, np.ndarray):
589
599
  return state.copy()