process-bigraph 1.1.7__tar.gz → 1.2.1__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 (46) hide show
  1. {process_bigraph-1.1.7/process_bigraph.egg-info → process_bigraph-1.2.1}/PKG-INFO +1 -1
  2. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/composite.py +192 -118
  3. {process_bigraph-1.1.7 → process_bigraph-1.2.1/process_bigraph.egg-info}/PKG-INFO +1 -1
  4. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/pyproject.toml +1 -1
  5. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/.github/workflows/notebook_to_html.yml +0 -0
  6. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/.github/workflows/pytest.yml +0 -0
  7. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/.gitignore +0 -0
  8. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/AUTHORS.md +0 -0
  9. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/CLA.md +0 -0
  10. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/CODE_OF_CONDUCT.md +0 -0
  11. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/CONTRIBUTING.md +0 -0
  12. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/LICENSE +0 -0
  13. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/README.md +0 -0
  14. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/doc/_static/process-bigraph.png +0 -0
  15. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/notebooks/process-bigraphs.ipynb +0 -0
  16. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/notebooks/visualize_processes.ipynb +0 -0
  17. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/__init__.py +0 -0
  18. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/bundle.py +0 -0
  19. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/emitter.py +0 -0
  20. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/experiments/__init__.py +0 -0
  21. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  22. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/nextflow.py +0 -0
  23. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/plumbing.py +0 -0
  24. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/processes/__init__.py +0 -0
  25. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/processes/dynamic_structure.py +0 -0
  26. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/processes/examples.py +0 -0
  27. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/processes/growth_division.py +0 -0
  28. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/processes/math_expression.py +0 -0
  29. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/processes/parameter_scan.py +0 -0
  30. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/processes/reaction.py +0 -0
  31. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/protocols/__init__.py +0 -0
  32. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/protocols/parallel.py +0 -0
  33. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/protocols/rest.py +0 -0
  34. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/protocols/socket.py +0 -0
  35. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/run.py +0 -0
  36. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/run_step.py +0 -0
  37. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/types/__init__.py +0 -0
  38. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/types/process.py +0 -0
  39. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph/units.py +0 -0
  40. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph.egg-info/SOURCES.txt +0 -0
  41. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph.egg-info/dependency_links.txt +0 -0
  42. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph.egg-info/requires.txt +0 -0
  43. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/process_bigraph.egg-info/top_level.txt +0 -0
  44. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/pytest.ini +0 -0
  45. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/release.sh +0 -0
  46. {process_bigraph-1.1.7 → process_bigraph-1.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.1.7
3
+ Version: 1.2.1
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -32,6 +32,7 @@ from bigraph_schema import (
32
32
  is_schema_key, strip_schema_keys)
33
33
 
34
34
  from bigraph_schema.protocols import local_lookup_module
35
+ from bigraph_schema.methods.events import NodeAdded, NodeRemoved, Divided
35
36
 
36
37
 
37
38
  # =========================
@@ -1281,6 +1282,14 @@ class Composite(Process):
1281
1282
  # extensions: yes; pure-Python loops: no).
1282
1283
  'parallel_steps': 'boolean{false}',
1283
1284
  'parallel_workers': 'maybe[integer]',
1285
+ # When True, ``initialize`` trusts ``state`` as-is and skips the
1286
+ # per-process ``link_state`` + ``combine`` pass that normally
1287
+ # seeds initial values. Set this when loading from a saved
1288
+ # bundle (e.g. daughter_state from a workflow generation):
1289
+ # the saved state already contains everything processes would
1290
+ # have contributed, and re-combining their ``initial_state()``
1291
+ # overwrites correctly-divided values with mother-shaped ones.
1292
+ 'skip_process_state': 'boolean{false}',
1284
1293
  }
1285
1294
 
1286
1295
 
@@ -1336,37 +1345,43 @@ class Composite(Process):
1336
1345
  self.edge_paths = {**self.process_paths, **self.step_paths}
1337
1346
 
1338
1347
  # Initialize each process/step's state and accumulate it into a unified state tree.
1339
- edge_schema = {}
1340
- edge_state = {}
1341
- for path, edge in self.edge_paths.items():
1342
- # Generate the initial state for this specific edge (process or step).
1343
- initial_schema, initial_state = self.core.link_state(
1344
- edge,
1345
- path)
1346
-
1347
- # Merge the new edge state with the global state tree, checking for conflicts.
1348
- try:
1349
- edge_schema, edge_state = self.core.combine(
1348
+ # ``skip_process_state`` short-circuits this when the caller supplied
1349
+ # state that already contains everything processes would have seeded
1350
+ # (e.g. a daughter_state bundle from a previous workflow generation).
1351
+ # Without the skip, ``combine`` overwrites correctly-divided values
1352
+ # with full mother-shaped ones from each process's ``initial_state()``.
1353
+ if not self.config.get('skip_process_state', False):
1354
+ edge_schema = {}
1355
+ edge_state = {}
1356
+ for path, edge in self.edge_paths.items():
1357
+ # Generate the initial state for this specific edge (process or step).
1358
+ initial_schema, initial_state = self.core.link_state(
1359
+ edge,
1360
+ path)
1361
+
1362
+ # Merge the new edge state with the global state tree, checking for conflicts.
1363
+ try:
1364
+ edge_schema, edge_state = self.core.combine(
1365
+ edge_schema, edge_state,
1366
+ initial_schema, initial_state)
1367
+
1368
+ except Exception as e:
1369
+ import sys as _sys
1370
+ _sys.stderr.write(f'[INIT_COMBINE_FAIL] edge={path}\n')
1371
+ _sys.stderr.write(f'[INIT_COMBINE_FAIL] new_schema={initial_schema}\n')
1372
+ _sys.stderr.write(f'[INIT_COMBINE_FAIL] err={e}\n')
1373
+ _sys.stderr.flush()
1374
+ raise Exception(
1375
+ f'initial state from edge does not match initial state from other edges:\n'
1376
+ f'{path}\n{edge}\n{edge_state}\n'
1377
+ f'{e}'
1378
+ )
1379
+
1380
+ # Apply the merged edge_state into the global state and update instance paths.
1381
+ if edge_state:
1382
+ self.schema, self.state = self.core.combine(
1350
1383
  edge_schema, edge_state,
1351
- initial_schema, initial_state)
1352
-
1353
- except Exception as e:
1354
- import sys as _sys
1355
- _sys.stderr.write(f'[INIT_COMBINE_FAIL] edge={path}\n')
1356
- _sys.stderr.write(f'[INIT_COMBINE_FAIL] new_schema={initial_schema}\n')
1357
- _sys.stderr.write(f'[INIT_COMBINE_FAIL] err={e}\n')
1358
- _sys.stderr.flush()
1359
- raise Exception(
1360
- f'initial state from edge does not match initial state from other edges:\n'
1361
- f'{path}\n{edge}\n{edge_state}\n'
1362
- f'{e}'
1363
- )
1364
-
1365
- # Apply the merged edge_state into the global state and update instance paths.
1366
- if edge_state:
1367
- self.schema, self.state = self.core.combine(
1368
- edge_schema, edge_state,
1369
- self.schema, self.state)
1384
+ self.schema, self.state)
1370
1385
 
1371
1386
  # Wire the input/output schema for the Composite from the bridge config.
1372
1387
  self.process_schema = {
@@ -1464,6 +1479,94 @@ class Composite(Process):
1464
1479
  # do we want to do anything with these?
1465
1480
  removed_front = self.front.pop(removed_key)
1466
1481
 
1482
+ def _apply_structural_events(self, events: List[Any]) -> None:
1483
+ """Update process/step indexes from apply-emitted structural events.
1484
+
1485
+ Replaces the full-state ``find_instance_paths`` rescan when apply
1486
+ tells us exactly which subtrees changed. For each:
1487
+ - ``NodeAdded``: scan the new subtree only and merge any
1488
+ discovered Process/Step instances into the existing index.
1489
+ - ``NodeRemoved``: drop entries at-or-under the removed path.
1490
+ - ``Divided``: drop the mother's entries, scan each daughter
1491
+ subtree.
1492
+
1493
+ The compiled-apply cache is invalidated unconditionally because
1494
+ ``apply(dict)``'s ``_divide`` branch mutates schemas in place.
1495
+
1496
+ Step network rebuild is conditional on step_paths actually
1497
+ changing — value-only structural events that don't add/remove
1498
+ any Step instances skip the rebuild.
1499
+ """
1500
+ self.core.invalidate_compiled_apply()
1501
+
1502
+ previous_step_paths = (set(self.step_paths.keys())
1503
+ if hasattr(self, 'step_paths') else set())
1504
+ if not hasattr(self, 'step_paths'):
1505
+ self.step_paths = {}
1506
+
1507
+ def _index_subtree(root_path: tuple, subtree_state: Any) -> None:
1508
+ """Walk subtree, merge Process/Step instances into indexes."""
1509
+ # find_instances takes a dict; if subtree is a dict, walk;
1510
+ # if it's a leaf or wrapped instance, check at this level.
1511
+ if not isinstance(subtree_state, dict):
1512
+ return
1513
+ instance = subtree_state.get('instance')
1514
+ from process_bigraph.composite import Process as _Proc
1515
+ from process_bigraph.composite import Step as _Step
1516
+ if isinstance(instance, _Proc):
1517
+ self.process_paths[root_path] = subtree_state
1518
+ return # Don't recurse into a process's internal state
1519
+ if isinstance(instance, _Step):
1520
+ self.step_paths[root_path] = subtree_state
1521
+ return
1522
+ # Recurse into non-instance dict
1523
+ for key, child in subtree_state.items():
1524
+ if is_schema_key(key):
1525
+ continue
1526
+ _index_subtree(root_path + (key,), child)
1527
+
1528
+ def _drop_under(root_path: tuple) -> None:
1529
+ """Remove process/step/front entries at-or-under root_path."""
1530
+ for path in list(self.process_paths.keys()):
1531
+ if path == root_path or path[:len(root_path)] == root_path:
1532
+ del self.process_paths[path]
1533
+ for path in list(self.step_paths.keys()):
1534
+ if path == root_path or path[:len(root_path)] == root_path:
1535
+ del self.step_paths[path]
1536
+ for path in list(self.front.keys()):
1537
+ if path == root_path or path[:len(root_path)] == root_path:
1538
+ del self.front[path]
1539
+
1540
+ for event in events:
1541
+ if isinstance(event, NodeAdded):
1542
+ added_path = event.path + (event.key,)
1543
+ # Read fresh subtree from realized state — event.state is
1544
+ # pre-realize (no instances yet).
1545
+ subtree = get_path(self.state, list(added_path))
1546
+ if subtree is not None:
1547
+ _index_subtree(added_path, subtree)
1548
+ elif isinstance(event, NodeRemoved):
1549
+ removed_path = event.path + (event.key,)
1550
+ _drop_under(removed_path)
1551
+ elif isinstance(event, Divided):
1552
+ mother_path = event.path + (event.mother_key,)
1553
+ _drop_under(mother_path)
1554
+ for d_key in event.daughter_keys:
1555
+ d_path = event.path + (d_key,)
1556
+ subtree = get_path(self.state, list(d_path))
1557
+ if subtree is not None:
1558
+ _index_subtree(d_path, subtree)
1559
+
1560
+ # Step network only needs rebuild if step_paths changed
1561
+ current_step_paths = set(self.step_paths.keys())
1562
+ if current_step_paths != previous_step_paths:
1563
+ self.build_step_network()
1564
+
1565
+ # Sync front buffer: drop entries for paths no longer present
1566
+ all_paths = set(self.process_paths.keys()) | current_step_paths
1567
+ for removed_key in set(self.front.keys()).difference(all_paths):
1568
+ self.front.pop(removed_key)
1569
+
1467
1570
  def _build_view_project_cache(self) -> None:
1468
1571
  """Precompile view/project operations for each process path.
1469
1572
 
@@ -1768,7 +1871,12 @@ class Composite(Process):
1768
1871
  from process_bigraph.bundle import load_bundle
1769
1872
 
1770
1873
  document = load_bundle(bundle_dir, as_numpy=True)
1771
- config = {**document, **kwargs}
1874
+ # The bundle's state is authoritative — it was saved post-init
1875
+ # so it already contains every contribution the per-process
1876
+ # ``link_state`` pass would re-add. Combining ``initial_state()``
1877
+ # on top here would clobber correctly-divided values (e.g. a
1878
+ # daughter's halved bulk array) with mother-shaped originals.
1879
+ config = {'skip_process_state': True, **document, **kwargs}
1772
1880
  return cls(config, core=core)
1773
1881
 
1774
1882
 
@@ -2036,12 +2144,13 @@ class Composite(Process):
2036
2144
  step = get_path(self.state, step_path)
2037
2145
  state = self._cached_view(step_path)
2038
2146
  instance = step.get('instance')
2039
- clean_state = strip_schema_keys(state)
2147
+ # _view_resolved emits only port-key entries; no schema
2148
+ # keys to strip. perform_update sees the same dict.
2040
2149
  if instance is not None and hasattr(instance, 'perform_update'):
2041
- if not instance.perform_update(clean_state):
2150
+ if not instance.perform_update(state):
2042
2151
  return None
2043
2152
  return self.process_update(
2044
- step_path, step, clean_state, -1.0, 'outputs',
2153
+ step_path, step, state, -1.0, 'outputs',
2045
2154
  already_clean=True)
2046
2155
  # list() forces all futures to resolve before continuing
2047
2156
  updates = [u for u in pool.map(_run_one, step_paths) if u is not None]
@@ -2056,17 +2165,15 @@ class Composite(Process):
2056
2165
  state = self._cached_view(step_path)
2057
2166
 
2058
2167
  instance = step.get('instance')
2059
- # Strip schema keys once; reused for both the
2060
- # perform_update gate and process_update below
2061
- # (invoke trusts the caller and skips its own gate).
2062
- clean_state = strip_schema_keys(state)
2168
+ # _view_resolved emits only port-key entries; no schema
2169
+ # keys to strip. perform_update sees the same dict.
2063
2170
  if instance is not None and hasattr(instance, 'perform_update'):
2064
- if not instance.perform_update(clean_state):
2171
+ if not instance.perform_update(state):
2065
2172
  continue
2066
2173
 
2067
2174
  # Steps are always invoked with interval = -1.0
2068
2175
  step_update = self.process_update(
2069
- step_path, step, clean_state, -1.0, 'outputs',
2176
+ step_path, step, state, -1.0, 'outputs',
2070
2177
  already_clean=True)
2071
2178
 
2072
2179
  updates.append(step_update)
@@ -2074,16 +2181,6 @@ class Composite(Process):
2074
2181
  update_paths = self.apply_updates(updates)
2075
2182
  self.expire_process_paths(update_paths)
2076
2183
 
2077
- # Opt-in halt: caller (e.g. EcoliSim) sets
2078
- # ``_halt_after_structural`` to stop the cascade after a
2079
- # divide/add/remove apply so newly-spawned processes don't
2080
- # run on the tick that birthed them. Default behavior is
2081
- # to continue cascading (test_dynamic_structure relies on
2082
- # this for spawn chains).
2083
- if (getattr(self, '_halt_after_structural', False)
2084
- and getattr(self, '_last_apply_structural', False)):
2085
- return
2086
-
2087
2184
  to_run = self.cycle_step_state()
2088
2185
 
2089
2186
  if to_run:
@@ -2155,19 +2252,6 @@ class Composite(Process):
2155
2252
  update_paths.append(('global_time',)) # updated global time can trigger steps
2156
2253
  self.expire_process_paths(update_paths)
2157
2254
  self.steps_run = set() # Reset for new timestep
2158
- # Caller can request an early-return after a structural
2159
- # apply (e.g. division) by setting
2160
- # ``self._halt_after_structural = True`` *before* the
2161
- # cascade fires. EcoliSim uses this to stop after
2162
- # divide so daughters save with post-divide-pre-tick
2163
- # values, matching v1 handoff (mother's last tick
2164
- # didn't run on daughters). Dynamic-structure tests
2165
- # leave the flag False and get the cascading
2166
- # spawn/divide trigger_steps behavior as before.
2167
- if (getattr(self, '_halt_after_structural', False)
2168
- and getattr(self, '_last_apply_structural', False)):
2169
- self.framework_time += _time.monotonic() - fw_start
2170
- return
2171
2255
  self.trigger_steps(update_paths)
2172
2256
  self.framework_time += _time.monotonic() - fw_start
2173
2257
 
@@ -2244,7 +2328,12 @@ class Composite(Process):
2244
2328
 
2245
2329
  # Only proceed if the next step occurs within the target range
2246
2330
  if future <= end_time:
2247
- update = self.process_update(path, process, state, process_interval)
2331
+ # state came from _cached_view (or future_front whose source
2332
+ # is also _cached_view) — _view_resolved only emits port-key
2333
+ # entries, so the dict has no schema keys to strip.
2334
+ update = self.process_update(
2335
+ path, process, state, process_interval,
2336
+ already_clean=True)
2248
2337
 
2249
2338
  # Store the update to apply when simulation reaches `future` time
2250
2339
  self.front[path]['time'] = future
@@ -2352,39 +2441,6 @@ class Composite(Process):
2352
2441
  return True
2353
2442
  return False
2354
2443
 
2355
- @staticmethod
2356
- def _walk_update(state: Any, path: tuple = ()) -> tuple:
2357
- """Single-pass walk over an update tree.
2358
-
2359
- Combines hierarchy_depth and _has_structural_keys into one
2360
- traversal — both are called on every apply_updates phase 1
2361
- invocation and were duplicating the same recursive walk.
2362
-
2363
- Returns (paths_list, has_structural) where paths_list mirrors
2364
- what hierarchy_depth would have returned (list of leaf path
2365
- tuples) and has_structural is True if any _add/_remove/_type
2366
- sentinel was found anywhere in the tree.
2367
- """
2368
- if not isinstance(state, dict):
2369
- return [path], False
2370
- paths = []
2371
- has_structural = False
2372
- for key, value in state.items():
2373
- if isinstance(key, str) and key.startswith('_'):
2374
- # Schema key — note any structural sentinels.
2375
- # _divide is structural because it replaces a mother
2376
- # with two daughters whose process instances need to
2377
- # be re-discovered by find_instance_paths.
2378
- if key in ('_add', '_remove', '_type', '_divide'):
2379
- has_structural = True
2380
- paths.append(path)
2381
- continue
2382
- sub_paths, sub_struct = Composite._walk_update(value, path + (key,))
2383
- paths.extend(sub_paths)
2384
- if sub_struct:
2385
- has_structural = True
2386
- return paths, has_structural
2387
-
2388
2444
  def apply_updates(self, updates: List["Defer"]) -> List[Union[str, Tuple[str, ...]]]:
2389
2445
  """
2390
2446
  Apply a series of deferred updates and record the resulting bridge outputs.
@@ -2401,10 +2457,12 @@ class Composite(Process):
2401
2457
  """
2402
2458
  update_paths = []
2403
2459
  had_structural_changes = False
2460
+ had_structural_sentinels = False # _add/_remove/_divide detected
2404
2461
 
2405
- # Phase 1: Resolve all deferred updates and collect them
2462
+ # Phase 1: Resolve all deferred updates and collect them.
2463
+ # Path discovery + structural detection happen during reconcile
2464
+ # (Phase 2) via a ReconcileSummary sink — no separate walk pass.
2406
2465
  resolved_updates = []
2407
- had_structural_sentinels = False # _add/_remove/_divide detected
2408
2466
  for defer in updates:
2409
2467
  series = defer.get()
2410
2468
  if series is None:
@@ -2413,18 +2471,6 @@ class Composite(Process):
2413
2471
  series = [series]
2414
2472
 
2415
2473
  for update_schema, update_state in series:
2416
- # Single-pass walk: collects paths AND detects structural
2417
- # change sentinels in one traversal. (Cache attempts here
2418
- # have foundered on the fact that the update *value* shape
2419
- # — not just the schema id — drives the leaf paths, and
2420
- # dynamic-structure processes emit varying shapes per
2421
- # tick. Walk-on-every-call costs ~1.3 ms/tick but is
2422
- # always correct.)
2423
- walk_paths, walk_struct = self._walk_update(update_state)
2424
- update_paths.extend(walk_paths)
2425
- if walk_struct and not had_structural_sentinels:
2426
- had_structural_sentinels = True
2427
-
2428
2474
  # read_bridge fast-paths to None when no bridge outputs
2429
2475
  # are configured (vEcoli's case) — no walk happens.
2430
2476
  bridge_update = self.read_bridge(update_state)
@@ -2463,9 +2509,22 @@ class Composite(Process):
2463
2509
  combined_schema,
2464
2510
  )
2465
2511
 
2466
- # Reconcile all update states using the combined schema
2467
- all_states = [state for _, state in resolved_updates]
2468
- combined_update = self.core.reconcile(combined_schema, all_states)
2512
+ # Reconcile all update states using the combined schema.
2513
+ # Install a summary sink so reconcile populates leaf paths
2514
+ # and the structural-sentinel flag during its existing walk
2515
+ # — eliminates the redundant per-defer _walk_update pass.
2516
+ from bigraph_schema.methods.events import (
2517
+ ReconcileSummary, install_reconcile_sink, uninstall_reconcile_sink)
2518
+ summary = ReconcileSummary(paths=[])
2519
+ prev_sink = install_reconcile_sink(summary)
2520
+ try:
2521
+ all_states = [state for _, state in resolved_updates]
2522
+ combined_update = self.core.reconcile(combined_schema, all_states)
2523
+ finally:
2524
+ uninstall_reconcile_sink(prev_sink)
2525
+ update_paths = summary.paths
2526
+ if summary.has_structural:
2527
+ had_structural_sentinels = True
2469
2528
 
2470
2529
  if combined_update:
2471
2530
  # An update that lands inside an existing process's
@@ -2491,10 +2550,18 @@ class Composite(Process):
2491
2550
  self.schema, combined_schema)
2492
2551
  else:
2493
2552
  apply_schema = combined_schema
2553
+ # Collect structural events (NodeAdded/NodeRemoved/
2554
+ # Divided) emitted by sentinel handlers during apply.
2555
+ # On structural ticks, used to update process_paths/
2556
+ # step_paths indexes incrementally instead of re-scanning
2557
+ # the full state via find_instance_paths.
2558
+ structural_events = [] if had_structural_sentinels else None
2494
2559
  self.state, merges = self.core.apply(
2495
2560
  apply_schema,
2496
2561
  self.state,
2497
- combined_update)
2562
+ combined_update,
2563
+ update_has_structural=had_structural_sentinels,
2564
+ events=structural_events)
2498
2565
  # For structural sentinels, apply may have mutated
2499
2566
  # apply_schema in place (e.g. _divide pops/inserts
2500
2567
  # keys in a dict schema). Propagate back to self.schema
@@ -2520,11 +2587,18 @@ class Composite(Process):
2520
2587
  if had_structural_sentinels:
2521
2588
  # Real structural change: processes may have been
2522
2589
  # added/removed/replaced. Realize new state (to
2523
- # instantiate added process declarations) then
2524
- # rediscover instances and rebuild step network.
2590
+ # instantiate added process declarations) then update
2591
+ # the process/step indexes from the events apply emitted.
2525
2592
  self.schema, self.state = self.core.realize(
2526
2593
  self.schema, self.state)
2527
- self.find_instance_paths(self.state)
2594
+ if structural_events:
2595
+ self._apply_structural_events(structural_events)
2596
+ else:
2597
+ # Fallback: had_structural_sentinels was set but no
2598
+ # events emitted (e.g. _update_touches_process_path
2599
+ # detected an in-process update with no _add/_remove/
2600
+ # _divide sentinel). Full rescan is correct here.
2601
+ self.find_instance_paths(self.state)
2528
2602
  self._build_view_project_cache()
2529
2603
  if hasattr(self, 'expire_layer_walk_cache'):
2530
2604
  self.expire_layer_walk_cache()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.1.7
3
+ Version: 1.2.1
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "process-bigraph"
3
- version = "1.1.7"
3
+ version = "1.2.1"
4
4
  description = "protocol and execution for compositional systems biology"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
File without changes
File without changes