process-bigraph 1.1.6__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 (47) hide show
  1. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/.gitignore +1 -0
  2. {process_bigraph-1.1.6/process_bigraph.egg-info → process_bigraph-1.2.1}/PKG-INFO +7 -1
  3. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/README.md +6 -0
  4. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/composite.py +197 -90
  5. process_bigraph-1.2.1/process_bigraph/emitter.py +610 -0
  6. {process_bigraph-1.1.6 → process_bigraph-1.2.1/process_bigraph.egg-info}/PKG-INFO +7 -1
  7. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/pyproject.toml +1 -1
  8. process_bigraph-1.1.6/process_bigraph/emitter.py +0 -248
  9. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/.github/workflows/notebook_to_html.yml +0 -0
  10. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/.github/workflows/pytest.yml +0 -0
  11. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/AUTHORS.md +0 -0
  12. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/CLA.md +0 -0
  13. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/CODE_OF_CONDUCT.md +0 -0
  14. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/CONTRIBUTING.md +0 -0
  15. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/LICENSE +0 -0
  16. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/doc/_static/process-bigraph.png +0 -0
  17. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/notebooks/process-bigraphs.ipynb +0 -0
  18. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/notebooks/visualize_processes.ipynb +0 -0
  19. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/__init__.py +0 -0
  20. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/bundle.py +0 -0
  21. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/experiments/__init__.py +0 -0
  22. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  23. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/nextflow.py +0 -0
  24. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/plumbing.py +0 -0
  25. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/processes/__init__.py +0 -0
  26. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/processes/dynamic_structure.py +0 -0
  27. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/processes/examples.py +0 -0
  28. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/processes/growth_division.py +0 -0
  29. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/processes/math_expression.py +0 -0
  30. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/processes/parameter_scan.py +0 -0
  31. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/processes/reaction.py +0 -0
  32. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/protocols/__init__.py +0 -0
  33. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/protocols/parallel.py +0 -0
  34. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/protocols/rest.py +0 -0
  35. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/protocols/socket.py +0 -0
  36. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/run.py +0 -0
  37. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/run_step.py +0 -0
  38. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/types/__init__.py +0 -0
  39. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/types/process.py +0 -0
  40. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph/units.py +0 -0
  41. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph.egg-info/SOURCES.txt +0 -0
  42. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph.egg-info/dependency_links.txt +0 -0
  43. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph.egg-info/requires.txt +0 -0
  44. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/process_bigraph.egg-info/top_level.txt +0 -0
  45. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/pytest.ini +0 -0
  46. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/release.sh +0 -0
  47. {process_bigraph-1.1.6 → process_bigraph-1.2.1}/setup.cfg +0 -0
@@ -1,4 +1,5 @@
1
1
  .python-version
2
+ .claude/settings.local.json
2
3
  __pycache__/
3
4
  .idea/
4
5
  *.ipynb_checkpoints/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.1.6
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
@@ -110,6 +110,12 @@ rendered to HTML and published automatically on GitHub Pages.
110
110
 
111
111
  More tutorials are added continuously and appear automatically in the index.
112
112
 
113
+ ### Topic Guides
114
+
115
+ - **Emitters — Recording Simulation Results**
116
+ *Built-in emitters (RAM, console, JSON, SQLite), how to wire them, retrieve results, store runs long-term, and write your own.*
117
+ [doc/emitters.md](doc/emitters.md)
118
+
113
119
  ---
114
120
 
115
121
  ## 🧪 Reference Implementation: spatio-flux
@@ -90,6 +90,12 @@ rendered to HTML and published automatically on GitHub Pages.
90
90
 
91
91
  More tutorials are added continuously and appear automatically in the index.
92
92
 
93
+ ### Topic Guides
94
+
95
+ - **Emitters — Recording Simulation Results**
96
+ *Built-in emitters (RAM, console, JSON, SQLite), how to wire them, retrieve results, store runs long-term, and write your own.*
97
+ [doc/emitters.md](doc/emitters.md)
98
+
93
99
  ---
94
100
 
95
101
  ## 🧪 Reference Implementation: spatio-flux
@@ -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 = {
@@ -1439,6 +1454,11 @@ class Composite(Process):
1439
1454
  - self.process_paths
1440
1455
  - self.step_paths
1441
1456
  """
1457
+ # Structural change incoming — drop the compiled-apply cache:
1458
+ # ``apply(dict)`` mutates schemas in place for ``_divide``
1459
+ # sentinels, so cached compiled functions may now reference
1460
+ # stale schema layouts.
1461
+ self.core.invalidate_compiled_apply()
1442
1462
  self.process_paths = find_instance_paths(state, 'process_bigraph.composite.Process')
1443
1463
  if hasattr(self, 'step_paths'):
1444
1464
  previous_step_paths = self.step_paths.keys()
@@ -1459,6 +1479,94 @@ class Composite(Process):
1459
1479
  # do we want to do anything with these?
1460
1480
  removed_front = self.front.pop(removed_key)
1461
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
+
1462
1570
  def _build_view_project_cache(self) -> None:
1463
1571
  """Precompile view/project operations for each process path.
1464
1572
 
@@ -1763,7 +1871,12 @@ class Composite(Process):
1763
1871
  from process_bigraph.bundle import load_bundle
1764
1872
 
1765
1873
  document = load_bundle(bundle_dir, as_numpy=True)
1766
- 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}
1767
1880
  return cls(config, core=core)
1768
1881
 
1769
1882
 
@@ -2031,12 +2144,13 @@ class Composite(Process):
2031
2144
  step = get_path(self.state, step_path)
2032
2145
  state = self._cached_view(step_path)
2033
2146
  instance = step.get('instance')
2034
- 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.
2035
2149
  if instance is not None and hasattr(instance, 'perform_update'):
2036
- if not instance.perform_update(clean_state):
2150
+ if not instance.perform_update(state):
2037
2151
  return None
2038
2152
  return self.process_update(
2039
- step_path, step, clean_state, -1.0, 'outputs',
2153
+ step_path, step, state, -1.0, 'outputs',
2040
2154
  already_clean=True)
2041
2155
  # list() forces all futures to resolve before continuing
2042
2156
  updates = [u for u in pool.map(_run_one, step_paths) if u is not None]
@@ -2051,17 +2165,15 @@ class Composite(Process):
2051
2165
  state = self._cached_view(step_path)
2052
2166
 
2053
2167
  instance = step.get('instance')
2054
- # Strip schema keys once; reused for both the
2055
- # perform_update gate and process_update below
2056
- # (invoke trusts the caller and skips its own gate).
2057
- 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.
2058
2170
  if instance is not None and hasattr(instance, 'perform_update'):
2059
- if not instance.perform_update(clean_state):
2171
+ if not instance.perform_update(state):
2060
2172
  continue
2061
2173
 
2062
2174
  # Steps are always invoked with interval = -1.0
2063
2175
  step_update = self.process_update(
2064
- step_path, step, clean_state, -1.0, 'outputs',
2176
+ step_path, step, state, -1.0, 'outputs',
2065
2177
  already_clean=True)
2066
2178
 
2067
2179
  updates.append(step_update)
@@ -2216,7 +2328,12 @@ class Composite(Process):
2216
2328
 
2217
2329
  # Only proceed if the next step occurs within the target range
2218
2330
  if future <= end_time:
2219
- 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)
2220
2337
 
2221
2338
  # Store the update to apply when simulation reaches `future` time
2222
2339
  self.front[path]['time'] = future
@@ -2324,39 +2441,6 @@ class Composite(Process):
2324
2441
  return True
2325
2442
  return False
2326
2443
 
2327
- @staticmethod
2328
- def _walk_update(state: Any, path: tuple = ()) -> tuple:
2329
- """Single-pass walk over an update tree.
2330
-
2331
- Combines hierarchy_depth and _has_structural_keys into one
2332
- traversal — both are called on every apply_updates phase 1
2333
- invocation and were duplicating the same recursive walk.
2334
-
2335
- Returns (paths_list, has_structural) where paths_list mirrors
2336
- what hierarchy_depth would have returned (list of leaf path
2337
- tuples) and has_structural is True if any _add/_remove/_type
2338
- sentinel was found anywhere in the tree.
2339
- """
2340
- if not isinstance(state, dict):
2341
- return [path], False
2342
- paths = []
2343
- has_structural = False
2344
- for key, value in state.items():
2345
- if isinstance(key, str) and key.startswith('_'):
2346
- # Schema key — note any structural sentinels.
2347
- # _divide is structural because it replaces a mother
2348
- # with two daughters whose process instances need to
2349
- # be re-discovered by find_instance_paths.
2350
- if key in ('_add', '_remove', '_type', '_divide'):
2351
- has_structural = True
2352
- paths.append(path)
2353
- continue
2354
- sub_paths, sub_struct = Composite._walk_update(value, path + (key,))
2355
- paths.extend(sub_paths)
2356
- if sub_struct:
2357
- has_structural = True
2358
- return paths, has_structural
2359
-
2360
2444
  def apply_updates(self, updates: List["Defer"]) -> List[Union[str, Tuple[str, ...]]]:
2361
2445
  """
2362
2446
  Apply a series of deferred updates and record the resulting bridge outputs.
@@ -2373,10 +2457,12 @@ class Composite(Process):
2373
2457
  """
2374
2458
  update_paths = []
2375
2459
  had_structural_changes = False
2460
+ had_structural_sentinels = False # _add/_remove/_divide detected
2376
2461
 
2377
- # 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.
2378
2465
  resolved_updates = []
2379
- had_structural_sentinels = False # _add/_remove/_divide detected
2380
2466
  for defer in updates:
2381
2467
  series = defer.get()
2382
2468
  if series is None:
@@ -2385,13 +2471,6 @@ class Composite(Process):
2385
2471
  series = [series]
2386
2472
 
2387
2473
  for update_schema, update_state in series:
2388
- # Single-pass walk: collects paths AND detects structural
2389
- # change sentinels in one traversal instead of two.
2390
- walk_paths, walk_struct = self._walk_update(update_state)
2391
- update_paths.extend(walk_paths)
2392
- if walk_struct and not had_structural_sentinels:
2393
- had_structural_sentinels = True
2394
-
2395
2474
  # read_bridge fast-paths to None when no bridge outputs
2396
2475
  # are configured (vEcoli's case) — no walk happens.
2397
2476
  bridge_update = self.read_bridge(update_state)
@@ -2430,9 +2509,22 @@ class Composite(Process):
2430
2509
  combined_schema,
2431
2510
  )
2432
2511
 
2433
- # Reconcile all update states using the combined schema
2434
- all_states = [state for _, state in resolved_updates]
2435
- 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
2436
2528
 
2437
2529
  if combined_update:
2438
2530
  # An update that lands inside an existing process's
@@ -2458,10 +2550,18 @@ class Composite(Process):
2458
2550
  self.schema, combined_schema)
2459
2551
  else:
2460
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
2461
2559
  self.state, merges = self.core.apply(
2462
2560
  apply_schema,
2463
2561
  self.state,
2464
- combined_update)
2562
+ combined_update,
2563
+ update_has_structural=had_structural_sentinels,
2564
+ events=structural_events)
2465
2565
  # For structural sentinels, apply may have mutated
2466
2566
  # apply_schema in place (e.g. _divide pops/inserts
2467
2567
  # keys in a dict schema). Propagate back to self.schema
@@ -2487,11 +2587,18 @@ class Composite(Process):
2487
2587
  if had_structural_sentinels:
2488
2588
  # Real structural change: processes may have been
2489
2589
  # added/removed/replaced. Realize new state (to
2490
- # instantiate added process declarations) then
2491
- # rediscover instances and rebuild step network.
2590
+ # instantiate added process declarations) then update
2591
+ # the process/step indexes from the events apply emitted.
2492
2592
  self.schema, self.state = self.core.realize(
2493
2593
  self.schema, self.state)
2494
- 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)
2495
2602
  self._build_view_project_cache()
2496
2603
  if hasattr(self, 'expire_layer_walk_cache'):
2497
2604
  self.expire_layer_walk_cache()