Coverage for src/objectstate/object_state_registry.py: 87%

1007 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 07:22 +0000

1""" 

2ObjectStateRegistry: Singleton registry of all ObjectState instances. 

3Replaces LiveContextService._active_form_managers as the single source of truth. 

4""" 

5 

6from contextlib import contextmanager 

7from contextvars import ContextVar, Token 

8from dataclasses import dataclass, is_dataclass, replace 

9import logging 

10from typing import ( 

11 Any, 

12 Callable, 

13 Dict, 

14 List, 

15 Optional, 

16 Set, 

17 Tuple, 

18 TYPE_CHECKING, 

19 Generator, 

20 Iterable, 

21 TypeAlias, 

22) 

23import copy 

24 

25from objectstate.object_state_metadata import ObjectStateMetadataStore 

26from objectstate.construction_binding import StateConstructionBinding 

27from objectstate.scope_key import ScopeKey 

28from objectstate.parameter_owner import ParameterOwner 

29from objectstate.snapshot_model import Snapshot, StateSnapshot, Timeline 

30from objectstate.time_travel_profile import TimeTravelProfiler 

31from objectstate.transaction_checkpoint import ObjectStateTransactionCheckpoint 

32 

33if TYPE_CHECKING: 

34 from objectstate.object_state import ObjectState 

35 

36TimeTravelStateEntry: TypeAlias = Tuple[str, "ObjectState"] 

37TimeTravelCompleteCallback: TypeAlias = Callable[[List[TimeTravelStateEntry], Optional[str]], None] 

38RegistryCallback: TypeAlias = Callable[..., None] 

39RegistryMutationSignature: TypeAlias = Tuple[ 

40 Tuple[str, int, Dict[str, Any], Dict[str, Any], StateConstructionBinding], ... 

41] 

42logger = logging.getLogger(__name__) 

43 

44 

45class ObjectStateRegistrySubscription: 

46 """Idempotent ownership handle for one registry callback registration.""" 

47 

48 def __init__(self, release: Callable[[], bool] | None) -> None: 

49 self._release = release 

50 

51 def release(self) -> bool: 

52 """Release this registration once, returning whether it was active.""" 

53 

54 release = self._release 

55 if release is None: 

56 return False 

57 self._release = None 

58 return release() 

59 

60 

61@dataclass(frozen=True) 

62class TimeTravelTransaction: 

63 """Nominal request to move the registry to one snapshot.""" 

64 

65 snapshot_id: str 

66 snapshot: Snapshot 

67 source_snapshot_id: Optional[str] 

68 snapshot_scopes: Set[str] 

69 current_scopes: Set[str] 

70 

71 

72@dataclass(frozen=True) 

73class TimeTravelScopeReconciliation: 

74 """Scope membership changes required before restoring snapshot values.""" 

75 

76 moved_to_limbo: Set[str] 

77 restored_scopes: Set[str] 

78 

79 

80@dataclass(frozen=True) 

81class PreparedStateRestore: 

82 """Prevalidated construction and historical values for one scope.""" 

83 

84 snapshot: StateSnapshot 

85 state: "ObjectState" 

86 schema: "ObjectState" 

87 diff: "TimeTravelStateDiff" 

88 construction_changed: bool 

89 

90 

91@dataclass(frozen=True) 

92class TimeTravelScopeChange: 

93 """Per-scope facts produced by restoring one ObjectState.""" 

94 

95 changed_paths: Set[str] 

96 changed_param_keys: Set[str] 

97 meta_changed_keys: Set[str] 

98 is_concrete_dirty: bool 

99 construction_changed: bool = False 

100 

101 @property 

102 def needs_navigation(self) -> bool: 

103 return bool(self.changed_paths or self.meta_changed_keys or self.construction_changed) 

104 

105 

106@dataclass(frozen=True) 

107class TimeTravelStateDiff: 

108 """Per-state snapshot delta used to keep time-travel restores targeted.""" 

109 

110 changed_paths: Set[str] 

111 changed_param_values: Dict[str, Tuple[Any, Any]] 

112 notification_changed_values: Dict[str, Tuple[Any, Any]] 

113 meta_changed_values: Dict[str, Tuple[Any, Any]] 

114 saved_parameters_changed: bool 

115 saved_resolved_changed: bool 

116 provenance_changed: bool 

117 

118 @property 

119 def requires_snapshot_apply(self) -> bool: 

120 return bool( 

121 self.changed_paths 

122 or self.changed_param_values 

123 or self.meta_changed_values 

124 or self.saved_parameters_changed 

125 or self.saved_resolved_changed 

126 or self.provenance_changed 

127 ) 

128 

129 

130@dataclass(frozen=True) 

131class TimeTravelChangeSet: 

132 """Authoritative result of a completed time-travel restore.""" 

133 

134 triggering_scope: Optional[str] 

135 scope_changes: Dict[str, TimeTravelScopeChange] 

136 

137 def states_needing_navigation( 

138 self, states: dict[str, 'ObjectState'] 

139 ) -> list[TimeTravelStateEntry]: 

140 """Return the registered states this restore changed, as completion callbacks take them.""" 

141 return [ 

142 (scope_key, states[scope_key]) 

143 for scope_key, change in self.scope_changes.items() 

144 if change.needs_navigation and scope_key in states 

145 ] 

146 

147 

148@dataclass(frozen=True) 

149class DeferredFieldInvalidation: 

150 """One coalescible live-cache invalidation request.""" 

151 

152 scope_id: str 

153 changed_type: ParameterOwner 

154 field_name: str 

155 invalidate_saved: bool = False 

156 include_origin: bool = True 

157 

158 

159@dataclass 

160class AtomicBlock: 

161 """One open ``atomic`` / ``atomic_success`` block: the undo step being coalesced. 

162 

163 The block belongs to the context that opened it, exactly as a 

164 ``config_context`` layer does: a new thread starts outside every block, and 

165 an asyncio task started inside a block inherits it. 

166 """ 

167 

168 label: str 

169 triggering_scope: str | None 

170 entry_signature: RegistryMutationSignature 

171 depth: int = 1 

172 snapshot_requested: bool = False 

173 

174 

175# Which block, and how many deferrals, the running context is inside. These 

176# answer "whose block is this", so they live where ``config_context`` keeps its 

177# scope (``contextvars``, see ``context_manager``). What the blocks act on, the 

178# registered states, the history and the queue of owed invalidations, is one 

179# per process and stays on ``ObjectStateRegistry``. 

180_atomic_block: ContextVar[AtomicBlock | None] = ContextVar( 

181 "objectstate_atomic_block", default=None 

182) 

183_live_invalidation_deferrals: ContextVar[int] = ContextVar( 

184 "objectstate_live_invalidation_deferrals", default=0 

185) 

186 

187 

188class ObjectStateRegistry: 

189 """Singleton registry of all ObjectState instances. 

190 

191 Replaces LiveContextService._active_form_managers as the single source of truth 

192 for all configuration state. Keyed by scope_id for efficient lookup. 

193 

194 Lifecycle ownership: 

195 - PipelineEditor: registers when step added, unregisters when step removed 

196 - ImageBrowser: registers when opened, unregisters when closed 

197 - Config window: registers PipelineConfig/GlobalPipelineConfig 

198 

199 One registry per process, by design: the registered states, the snapshot 

200 history, the callbacks and the queue of owed live-cache invalidations are 

201 class attributes, so two independent registries cannot exist. 

202 

203 Per context (thread or asyncio task), like ``config_context``: the open 

204 ``atomic`` / ``atomic_success`` block and the ``defer_live_invalidations`` 

205 nesting. A thread outside any block never sees another thread's block. 

206 

207 Thread safety: the process-wide stores above are not synchronized; callers 

208 that mutate the registry from several threads must serialize those calls. 

209 """ 

210 _states: Dict[str, 'ObjectState'] = {} # Keys are always strings (None normalized to "") 

211 

212 # Registration lifecycle callbacks - UI subscribes to sync list items with ObjectState lifecycle 

213 # Callbacks receive (scope_id: str, object_state: ObjectState) 

214 _on_register_callbacks: List[Callable[[str, 'ObjectState'], None]] = [] 

215 _on_unregister_callbacks: List[Callable[[str, 'ObjectState'], None]] = [] 

216 _deferred_invalidations: Set[DeferredFieldInvalidation] = set() 

217 _snapshot_dirty_scopes: Set[str] = set() 

218 

219 # Time-travel completion callbacks - UI subscribes to reopen windows for dirty states 

220 # Callbacks receive (dirty_states, triggering_scope) where: 

221 # - dirty_states: list of (scope_id, ObjectState) tuples with unsaved changes 

222 # - triggering_scope: scope_id that triggered the snapshot (may be None) 

223 _on_time_travel_complete_callbacks: List[TimeTravelCompleteCallback] = [] 

224 

225 # History changed callbacks - fired when history is modified (snapshot added or time-travel) 

226 # Used by TimeTravelWidget to stay in sync without polling 

227 _on_history_changed_callbacks: List[Callable[[], None]] = [] 

228 

229 @classmethod 

230 def add_register_callback( 

231 cls, 

232 callback: Callable[[str, 'ObjectState'], None], 

233 ) -> ObjectStateRegistrySubscription: 

234 """Subscribe to registration events and return its ownership handle.""" 

235 return cls._subscribe_callback(cls._on_register_callbacks, callback) 

236 

237 @classmethod 

238 def remove_register_callback( 

239 cls, 

240 callback: Callable[[str, 'ObjectState'], None], 

241 ) -> bool: 

242 """Unsubscribe from ObjectState registration events.""" 

243 return cls._remove_callback(cls._on_register_callbacks, callback) 

244 

245 @classmethod 

246 def add_unregister_callback( 

247 cls, 

248 callback: Callable[[str, 'ObjectState'], None], 

249 ) -> ObjectStateRegistrySubscription: 

250 """Subscribe to unregistration events and return its ownership handle.""" 

251 return cls._subscribe_callback(cls._on_unregister_callbacks, callback) 

252 

253 @classmethod 

254 def remove_unregister_callback( 

255 cls, 

256 callback: Callable[[str, 'ObjectState'], None], 

257 ) -> bool: 

258 """Unsubscribe from ObjectState unregistration events.""" 

259 return cls._remove_callback(cls._on_unregister_callbacks, callback) 

260 

261 @classmethod 

262 def add_time_travel_complete_callback( 

263 cls, 

264 callback: TimeTravelCompleteCallback, 

265 ) -> ObjectStateRegistrySubscription: 

266 """Subscribe to time-travel completion events. 

267 

268 Callback receives (dirty_states, triggering_scope) where: 

269 - dirty_states: list of (scope_id, ObjectState) tuples with unsaved changes 

270 - triggering_scope: scope_id that triggered the snapshot (may be None) 

271 """ 

272 return cls._subscribe_callback( 

273 cls._on_time_travel_complete_callbacks, 

274 callback, 

275 ) 

276 

277 @classmethod 

278 def remove_time_travel_complete_callback( 

279 cls, 

280 callback: TimeTravelCompleteCallback, 

281 ) -> bool: 

282 """Unsubscribe from time-travel completion events.""" 

283 return cls._remove_callback(cls._on_time_travel_complete_callbacks, callback) 

284 

285 @classmethod 

286 def add_history_changed_callback( 

287 cls, 

288 callback: Callable[[], None], 

289 ) -> ObjectStateRegistrySubscription: 

290 """Subscribe to history changes and return its ownership handle.""" 

291 return cls._subscribe_callback(cls._on_history_changed_callbacks, callback) 

292 

293 @classmethod 

294 def remove_history_changed_callback(cls, callback: Callable[[], None]) -> bool: 

295 """Unsubscribe from history change events.""" 

296 return cls._remove_callback(cls._on_history_changed_callbacks, callback) 

297 

298 @classmethod 

299 def _subscribe_callback( 

300 cls, 

301 callbacks: list[RegistryCallback], 

302 callback: RegistryCallback, 

303 ) -> ObjectStateRegistrySubscription: 

304 if callback in callbacks: 

305 return ObjectStateRegistrySubscription(None) 

306 callbacks.append(callback) 

307 return ObjectStateRegistrySubscription( 

308 lambda: cls._remove_callback(callbacks, callback) 

309 ) 

310 

311 @staticmethod 

312 def _remove_callback( 

313 callbacks: list[RegistryCallback], 

314 callback: RegistryCallback, 

315 ) -> bool: 

316 if callback not in callbacks: 

317 return False 

318 callbacks.remove(callback) 

319 return True 

320 

321 @classmethod 

322 def _fire_history_changed_callbacks(cls) -> None: 

323 """Fire all history changed callbacks.""" 

324 cls._fire_callbacks(cls._on_history_changed_callbacks, "history_changed") 

325 

326 @classmethod 

327 @contextmanager 

328 def defer_live_invalidations(cls) -> Generator[None, None, None]: 

329 """Queue descendant live-cache invalidations for an explicit later flush.""" 

330 token = _live_invalidation_deferrals.set(_live_invalidation_deferrals.get() + 1) 

331 try: 

332 yield 

333 finally: 

334 _live_invalidation_deferrals.reset(token) 

335 

336 @classmethod 

337 def has_deferred_invalidations(cls) -> bool: 

338 """Return whether live-cache invalidations are waiting to be flushed.""" 

339 return bool(cls._deferred_invalidations) 

340 

341 @classmethod 

342 def flush_deferred_invalidations(cls) -> None: 

343 """Apply queued descendant invalidations once, after edit bursts settle.""" 

344 if not cls._deferred_invalidations: 

345 return 

346 

347 requests = tuple(cls._deferred_invalidations) 

348 cls._deferred_invalidations.clear() 

349 token = _live_invalidation_deferrals.set(0) 

350 try: 

351 for request in requests: 

352 cls._invalidate_by_type_and_scope_now( 

353 scope_id=request.scope_id, 

354 changed_type=request.changed_type, 

355 field_name=request.field_name, 

356 invalidate_saved=request.invalidate_saved, 

357 include_origin=request.include_origin, 

358 ) 

359 finally: 

360 _live_invalidation_deferrals.reset(token) 

361 

362 @classmethod 

363 def _fire_callbacks(cls, callbacks: List[Callable], role: str, *args: Any) -> None: 

364 """Fire callback family with uniform error handling.""" 

365 for callback in callbacks: 

366 try: 

367 callback(*args) 

368 except Exception as e: 

369 logger.warning(f"Error in {role} callback: {e}") 

370 

371 @classmethod 

372 def _fire_register_callbacks(cls, scope_key: str, state: 'ObjectState') -> None: 

373 """Fire all registered callbacks for ObjectState registration.""" 

374 cls._fire_callbacks(cls._on_register_callbacks, "register", scope_key, state) 

375 

376 @classmethod 

377 def _fire_unregister_callbacks(cls, scope_key: str, state: 'ObjectState') -> None: 

378 """Fire all registered callbacks for ObjectState unregistration.""" 

379 cls._fire_callbacks(cls._on_unregister_callbacks, "unregister", scope_key, state) 

380 

381 @classmethod 

382 def mark_snapshot_dirty_scope(cls, scope_id: Optional[str]) -> None: 

383 """Mark a scope whose materialized state must be copied into the next snapshot.""" 

384 cls._snapshot_dirty_scopes.add(cls._normalize_scope_id(scope_id)) 

385 

386 @classmethod 

387 def _normalize_scope_id(cls, scope_id: str | None) -> ScopeKey: 

388 """Return the registry key: None and "" both represent the global scope.""" 

389 return ScopeKey(scope_id) 

390 

391 @classmethod 

392 def register(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: 

393 """Register an ObjectState in the registry. 

394 

395 Args: 

396 state: The ObjectState to register. 

397 scope_id=None/"" for GlobalPipelineConfig (global scope). 

398 scope_id=plate_path for PipelineConfig. 

399 scope_id=plate_path::step_N for steps. 

400 _skip_snapshot: Internal flag for time-travel (don't record snapshot). 

401 """ 

402 key = cls._normalize_scope_id(state.scope_id) 

403 

404 if key in cls._states: 

405 logger.warning(f"Overwriting existing ObjectState for scope: {key}") 

406 

407 cls._states[key] = state 

408 cls.mark_snapshot_dirty_scope(key) 

409 logger.debug(f"Registered ObjectState: scope={key}, type={type(state.object_instance).__name__}") 

410 cls.resolve_again_below((key,)) 

411 

412 # Fire callbacks for UI binding 

413 cls._fire_register_callbacks(key, state) 

414 

415 # Structural registration is not a user-edit boundary. Callers that 

416 # create/delete user-visible objects record one explicit operation 

417 # snapshot around the mutation. 

418 

419 @classmethod 

420 def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: 

421 """Unregister an ObjectState from the registry. 

422 

423 Args: 

424 state: The ObjectState to unregister. 

425 _skip_snapshot: Internal flag for time-travel (don't record snapshot). 

426 """ 

427 key = cls._normalize_scope_id(state.scope_id) 

428 if key in cls._states: 

429 removed_state = cls._states.pop(key) 

430 

431 # Move to graveyard instead of deleting - enables time travel to past snapshots 

432 # that reference this ObjectState. Graveyard persists for session lifetime. 

433 cls._graveyard[key] = removed_state 

434 logger.debug(f"Unregistered ObjectState: scope={key} (moved to graveyard)") 

435 cls.resolve_again_below((key,)) 

436 

437 # Fire callbacks for UI binding 

438 cls._fire_unregister_callbacks(key, state) 

439 

440 # Structural unregistration is not a user-edit boundary. The 

441 # graveyard preserves state for explicit operation snapshots. 

442 

443 @classmethod 

444 def global_config_state(cls, config_type: type) -> Optional['ObjectState']: 

445 """The state that holds the global config of ``config_type``: the one 

446 registered at the global scope, when its object is of exactly that type. 

447 

448 ``objectstate.global_config`` reads and replaces the global config through 

449 this state while there is one, so registering it is all it takes to make its 

450 object the global config, and unregistering it (or time travel to a point 

451 where it was not registered) ends that at once. 

452 """ 

453 state = cls._states.get("") 

454 if state is not None and type(state._extraction_target) is config_type: 

455 return state 

456 return None 

457 

458 @classmethod 

459 def resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None: 

460 """Tell registered states at or below ``changed_scopes`` that what they are 

461 resolved from may have changed (a registration, or a stored global config). 

462 

463 Each state compares what it is resolved from now with what its resolved values 

464 were computed from (``ObjectState.resolve_again_if_sources_changed``), so a 

465 state whose ancestors are the same does no work. This call only makes the 

466 comparison happen now, so subscribers hear of changed values without waiting 

467 for the next read. 

468 """ 

469 changed_scopes = tuple(changed_scopes) 

470 for state in tuple(cls._states.values()): 

471 if any(scope.contains(state.scope_id) for scope in changed_scopes): 

472 state.resolve_again_if_sources_changed() 

473 

474 @classmethod 

475 def registered_ancestors( 

476 cls, scope_id: str | None 

477 ) -> tuple[tuple[ScopeKey, 'ObjectState'], ...]: 

478 """The states registered at the global scope, each prefix of ``scope_id`` and 

479 ``scope_id`` itself, least specific first. 

480 

481 Resolved values of a state at ``scope_id`` are computed from these states. 

482 """ 

483 return tuple( 

484 (ancestor_key, cls._states[ancestor_key]) 

485 for ancestor_key in cls._normalize_scope_id(scope_id).ancestors() 

486 if ancestor_key in cls._states 

487 ) 

488 

489 @classmethod 

490 def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapshot: bool = False) -> int: 

491 """Unregister an ObjectState and all its descendants from the registry. 

492 

493 This is used when deleting a plate - we need to cascade delete all child 

494 ObjectStates (steps, functions) to prevent memory leaks. 

495 

496 Example: 

497 When deleting plate "/path/to/plate", this unregisters: 

498 - "/path/to/plate" (the plate's PipelineConfig) 

499 - "/path/to/plate::step_0" (step ObjectStates) 

500 - "/path/to/plate::step_0::func_0" (function ObjectStates) 

501 - etc. 

502 

503 Args: 

504 scope_id: The scope to unregister (along with all descendants). 

505 _skip_snapshot: Internal flag for time-travel (don't record snapshot). 

506 

507 Returns: 

508 Number of ObjectStates unregistered. 

509 """ 

510 scope_key = cls._normalize_scope_id(scope_id) 

511 

512 # Find all scopes to delete: exact match + descendants 

513 scopes_to_delete = [ 

514 key 

515 for key in cls._states.keys() 

516 if key == scope_key or (scope_key and scope_key.contains(key)) 

517 ] 

518 

519 # Delete all matching scopes and fire callbacks 

520 # NOTE: Unlike plain unregister(), this is often used for hierarchical deletes. 

521 # When history is enabled we must preserve removed states for time-travel (undo/redo), 

522 # otherwise snapshots may reference scopes that can no longer be resurrected. 

523 removed = [(key, cls._states.pop(key)) for key in scopes_to_delete] 

524 cls.resolve_again_below(scopes_to_delete) 

525 for key, state in removed: 

526 if cls._history_enabled: 

527 cls._graveyard[key] = state 

528 logger.debug(f"Unregistered ObjectState (cascade): scope={key} (moved to graveyard)") 

529 else: 

530 logger.debug(f"Unregistered ObjectState (cascade): scope={key}") 

531 # Fire callbacks for UI binding 

532 cls._fire_unregister_callbacks(key, state) 

533 

534 if scopes_to_delete: 

535 logger.info(f"Cascade unregistered {len(scopes_to_delete)} ObjectState(s) for scope={scope_key}") 

536 # Structural unregister is not a user-edit boundary. User-visible 

537 # deletes wrap the full mutation in ObjectStateRegistry.atomic(...). 

538 

539 return len(scopes_to_delete) 

540 

541 @classmethod 

542 def get_by_scope(cls, scope_id: Optional[str]) -> Optional['ObjectState']: 

543 """Get ObjectState by scope_id. 

544 

545 Args: 

546 scope_id: The scope identifier (e.g., "/path::step_0", or None/"" for global scope). 

547 

548 Returns: 

549 ObjectState if found, None otherwise. 

550 """ 

551 return cls._states.get(cls._normalize_scope_id(scope_id)) 

552 

553 @classmethod 

554 def get_object(cls, scope_id: Optional[str]) -> Optional[Any]: 

555 """Get object_instance for a scope, or None if not registered/in limbo. 

556 

557 Convenience method for the common pattern of accessing the stored object. 

558 When ObjectState uses delegation (via __objectstate_delegate__), this still 

559 returns the original object_instance (e.g., orchestrator), not the delegate. 

560 

561 Args: 

562 scope_id: The scope identifier. 

563 

564 Returns: 

565 The object_instance if ObjectState is registered, None otherwise. 

566 """ 

567 state = cls.get_by_scope(scope_id) 

568 return state.object_instance if state else None 

569 

570 @classmethod 

571 def get_all(cls) -> List['ObjectState']: 

572 """Get all registered ObjectStates. 

573 

574 Returns: 

575 List of all ObjectState instances. Used by LiveContextService.collect(). 

576 """ 

577 return list(cls._states.values()) 

578 

579 @classmethod 

580 def clear(cls) -> None: 

581 """Clear all registered state and history. For testing/session reset only.""" 

582 cls._states.clear() 

583 cls._time_travel_limbo.clear() 

584 cls._graveyard.clear() 

585 cls._deferred_invalidations.clear() 

586 _live_invalidation_deferrals.set(0) 

587 cls._snapshot_dirty_scopes.clear() 

588 cls._snapshots.clear() 

589 cls._timelines.clear() 

590 cls._current_head = None 

591 cls._current_timeline = cls.MAIN_BRANCH 

592 _atomic_block.set(None) 

593 logger.debug("Cleared ObjectState registry, history, limbo, and graveyard") 

594 

595 # ========== TOKEN MANAGEMENT AND CHANGE NOTIFICATION ========== 

596 

597 _token: int = 0 # Cache invalidation token 

598 _change_callbacks: List[Callable[[], None]] = [] # Change listeners 

599 _resolved_changed_callbacks: List[Callable[[str, Set[str]], None]] = [] 

600 

601 @classmethod 

602 def get_token(cls) -> int: 

603 """Get current cache invalidation token.""" 

604 return cls._token 

605 

606 @classmethod 

607 def increment_token(cls, notify: bool = True) -> None: 

608 """Increment cache invalidation token. 

609 

610 Args: 

611 notify: If True (default), notify listeners of the change. 

612 Set to False when you need to invalidate caches but will 

613 notify listeners later (e.g., after sibling refresh completes). 

614 """ 

615 cls._token += 1 

616 if notify: 

617 cls._notify_change() 

618 

619 @classmethod 

620 def _notify_change(cls) -> None: 

621 """Notify all listeners that something changed. 

622 

623 UI-agnostic: No PyQt imports. If a callback's object was deleted, 

624 the RuntimeError is caught and the callback is removed. 

625 """ 

626 logger.debug(f"🔔 _notify_change: notifying {len(cls._change_callbacks)} listeners") 

627 dead_callbacks = [] 

628 for callback in cls._change_callbacks: 

629 try: 

630 callback() 

631 except RuntimeError as e: 

632 # "wrapped C/C++ object has been deleted" - mark for removal 

633 if "deleted" in str(e).lower(): 

634 logger.debug(f" ⚠️ Callback's object was deleted, removing: {e}") 

635 dead_callbacks.append(callback) 

636 else: 

637 logger.warning(f"Change callback failed: {e}") 

638 except Exception as e: 

639 logger.warning(f"Change callback failed: {e}") 

640 

641 # Clean up dead callbacks 

642 for cb in dead_callbacks: 

643 cls._change_callbacks.remove(cb) 

644 

645 @classmethod 

646 def connect_listener( 

647 cls, 

648 callback: Callable[[], None], 

649 ) -> ObjectStateRegistrySubscription: 

650 """Connect a listener callback that's called on any change. 

651 

652 The callback should debounce and call collect() to get fresh values. 

653 """ 

654 subscription = cls._subscribe_callback(cls._change_callbacks, callback) 

655 if callback in cls._change_callbacks: 

656 logger.debug(f"Connected change listener: {callback}") 

657 return subscription 

658 

659 @classmethod 

660 def disconnect_listener(cls, callback: Callable[[], None]) -> bool: 

661 """Disconnect a change listener.""" 

662 removed = cls._remove_callback(cls._change_callbacks, callback) 

663 if removed: 

664 logger.debug(f"Disconnected change listener: {callback}") 

665 return removed 

666 

667 @classmethod 

668 def add_resolved_changed_callback( 

669 cls, 

670 callback: Callable[[str, Set[str]], None], 

671 ) -> ObjectStateRegistrySubscription: 

672 """Subscribe to resolved changes and return its ownership handle.""" 

673 return cls._subscribe_callback(cls._resolved_changed_callbacks, callback) 

674 

675 @classmethod 

676 def remove_resolved_changed_callback( 

677 cls, 

678 callback: Callable[[str, Set[str]], None], 

679 ) -> bool: 

680 """Unsubscribe from registry-wide resolved-value changes.""" 

681 return cls._remove_callback(cls._resolved_changed_callbacks, callback) 

682 

683 @classmethod 

684 def notify_resolved_changed( 

685 cls, 

686 scope_id: Optional[str], 

687 changed_paths: Set[str], 

688 ) -> None: 

689 """Publish resolved changes through the registry-level authority.""" 

690 if not changed_paths: 

691 return 

692 scope_key = cls._normalize_scope_id(scope_id) 

693 cls._fire_callbacks( 

694 cls._resolved_changed_callbacks, 

695 "resolved_changed", 

696 scope_key, 

697 set(changed_paths), 

698 ) 

699 

700 # ========== ANCESTOR OBJECT COLLECTION ========== 

701 

702 @classmethod 

703 def get_ancestor_objects(cls, scope_id: Optional[str], use_saved: bool = False) -> List[Any]: 

704 """Get objects from this scope and all ancestors, least→most specific. 

705 

706 Replaces LiveContextService.collect() + merge_ancestor_values() for simpler 

707 context stack building. 

708 

709 Args: 

710 scope_id: The scope to get ancestors for (e.g., "/plate::step_0") 

711 use_saved: If True, return saved baseline (object_instance) instead of 

712 live state (to_object()). Used when computing _saved_resolved 

713 to ensure saved baseline only depends on other saved baselines. 

714 

715 Returns: 

716 List of objects from ancestor scopes, ordered least→most specific. 

717 Each object is from state.object_instance (saved) or state.to_object() (live). 

718 """ 

719 return [ 

720 state.saved_object if use_saved else state.to_object(update_delegate=False) 

721 for _, state in cls.registered_ancestors(scope_id) 

722 ] 

723 

724 @classmethod 

725 def get_ancestor_objects_with_scopes( 

726 cls, 

727 scope_id: Optional[str], 

728 use_saved: bool = False, 

729 skip_delegate_sync: bool = False, 

730 ) -> List[Tuple[str, Any]]: 

731 """Get (scope_id, object) tuples from this scope and all ancestors. 

732 

733 Similar to get_ancestor_objects() but includes the scope_id for each object. 

734 Used for provenance tracking to determine which scope provided a resolved value. 

735 

736 ARCHITECTURAL NOTE: This method provides object instances for type structure/MRO resolution, 

737 NOT for reading current parameter values. Actual parameter values come from the parameters dict. 

738 Using object_instance instead of to_object() avoids: 

739 - Expensive reconstruction from parameters 

740 - Side effects from delegate auto-sync 

741 - Re-entrant calls that invalidate caches mid-computation 

742 

743 Args: 

744 scope_id: The scope to get ancestors for (e.g., "/plate::step_0") 

745 use_saved: If True, use saved baseline. If False, use current live state. 

746 skip_delegate_sync: If True, access _extraction_target directly without delegate sync. 

747 Use True during cache recomputation to avoid re-entrant invalidation. 

748 Use False (default) when you need current values with auto-sync. 

749 

750 Returns: 

751 List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific. 

752 """ 

753 return [ 

754 ( 

755 ancestor_key, 

756 state.object_for_resolution(use_saved, sync_delegate=not skip_delegate_sync), 

757 ) 

758 for ancestor_key, state in cls.registered_ancestors(scope_id) 

759 ] 

760 

761 # ========== SCOPE + TYPE + FIELD AWARE INVALIDATION ========== 

762 

763 @classmethod 

764 def invalidate_by_type_and_scope( 

765 cls, 

766 scope_id: Optional[str], 

767 changed_type: ParameterOwner, 

768 field_name: str, 

769 invalidate_saved: bool = False, 

770 include_origin: bool = True, 

771 ) -> None: 

772 """Invalidate a specific field in states that could inherit from changed_type at scope_id. 

773 

774 PERFORMANCE: Three-tier filtering: 

775 1. SCOPE: State must be at or below changed scope (descendants inherit) 

776 2. TYPE: State's type tree must include changed_type 

777 3. FIELD: Only invalidate the specific field that changed 

778 

779 If changing GlobalPipelineConfig.napari_streaming_config.window_size: 

780 - Only states with napari_streaming_config in their tree 

781 - Only the 'window_size' field is invalidated, not all 20+ fields 

782 - Steps without napari_streaming_config are NOT touched 

783 

784 Args: 

785 scope_id: The scope that changed (None/"" for global scope) 

786 changed_type: The type of the ObjectState that was modified 

787 field_name: The specific field that changed 

788 invalidate_saved: If True, also invalidate saved_resolved cache for descendants 

789 include_origin: Whether to invalidate the state at ``scope_id`` as 

790 well as its descendants. 

791 """ 

792 changed_scope = cls._normalize_scope_id(scope_id) 

793 if _live_invalidation_deferrals.get() > 0: 

794 cls._deferred_invalidations.add( 

795 DeferredFieldInvalidation( 

796 scope_id=changed_scope, 

797 changed_type=changed_type, 

798 field_name=field_name, 

799 invalidate_saved=invalidate_saved, 

800 include_origin=include_origin, 

801 ) 

802 ) 

803 return 

804 

805 cls._invalidate_by_type_and_scope_now( 

806 scope_id=changed_scope, 

807 changed_type=changed_type, 

808 field_name=field_name, 

809 invalidate_saved=invalidate_saved, 

810 include_origin=include_origin, 

811 ) 

812 

813 @classmethod 

814 def _invalidate_by_type_and_scope_now( 

815 cls, 

816 scope_id: Optional[str], 

817 changed_type: ParameterOwner, 

818 field_name: str, 

819 invalidate_saved: bool = False, 

820 include_origin: bool = True, 

821 ) -> None: 

822 """Immediate implementation for scope/type/field-aware invalidation.""" 

823 from objectstate.lazy_factory import get_base_type_for_lazy 

824 changed_scope = cls._normalize_scope_id(scope_id) 

825 

826 # Normalize to base type for comparison (LazyX → X) 

827 if isinstance(changed_type, type): 

828 base_changed_type = get_base_type_for_lazy(changed_type) or changed_type 

829 else: 

830 base_changed_type = changed_type 

831 

832 

833 for state in cls._states.values(): 

834 state_scope = cls._normalize_scope_id(state.scope_id) 

835 is_self = state_scope == changed_scope 

836 if is_self and not include_origin: 

837 continue 

838 

839 # SCOPE CHECK: must be at or below changed scope 

840 if not changed_scope.contains(state_scope): 

841 continue 

842 

843 # TYPE + FIELD CHECK: find matching nested state and invalidate field 

844 cls._invalidate_field_in_matching_states(state, base_changed_type, field_name, invalidate_saved) 

845 

846 @classmethod 

847 def _invalidate_field_in_matching_states( 

848 cls, 

849 state: 'ObjectState', 

850 target_base_type: ParameterOwner, 

851 field_name: str, 

852 invalidate_saved: bool = False 

853 ) -> None: 

854 """Find fields in state that could inherit from target_base_type and invalidate them. 

855 

856 With flat storage, we scan _path_to_type to find all dotted paths whose 

857 container type matches or inherits from target_base_type. 

858 

859 A field should be invalidated if: 

860 1. Its container type matches target_base_type exactly, OR 

861 2. Its container type inherits from target_base_type (has target_base_type in MRO) 

862 

863 This handles sibling inheritance: when PartitionFilterConfig.partition_filter changes, 

864 both 'partition_filter_config.partition_filter' and 

865 'step_partition_filter_config.partition_filter' 

866 are invalidated (since StepPartitionFilterConfig inherits from PartitionFilterConfig). 

867 

868 Args: 

869 state: ObjectState to check 

870 target_base_type: Normalized base type to match 

871 field_name: Field to invalidate 

872 invalidate_saved: If True, also invalidate saved_resolved cache for this field 

873 """ 

874 invalidated_paths: set[str] = set() 

875 _debug = logger.isEnabledFor(logging.DEBUG) 

876 

877 # DEBUG: Log entry into type matching 

878 if _debug: 

879 logger.debug(f"🔍 _invalidate_field_in_matching_states: state_scope={state.scope_id}, target_base_type={target_base_type.__name__}, field_name={field_name}") 

880 logger.debug(f"🔍 state.parameter_structure.owner_paths has {len(state.parameter_structure.owner_paths)} entries") 

881 

882 for dotted_path in state.inheritance_field_paths( 

883 target_base_type, 

884 field_name, 

885 ): 

886 if _debug: 

887 logger.debug(f"🔄 INVALIDATING FIELD: {dotted_path} (in parameters)") 

888 state.invalidate_field(dotted_path) 

889 invalidated_paths.add(dotted_path) 

890 

891 if invalidated_paths: 

892 if invalidate_saved: 

893 # Without recorded sources the saved snapshot is resolved again in full. 

894 state._resolved_from.pop(True, None) 

895 # Resolve now, so callbacks fire only for values that really changed (no 

896 # flash when Reset is clicked on a field that is already reset). 

897 changed_paths = state._ensure_live_resolved(notify_flash=False) 

898 state._sync_materialized_state(changed_value_fields=changed_paths) 

899 

900 # ========== REGISTRY-LEVEL TIME-TRAVEL (DAG Model) ========== 

901 

902 # Snapshots: Dict of all snapshots by ID (the DAG - snapshots are NEVER deleted) 

903 # Each Snapshot contains id, timestamp, label, triggering_scope, parent_id, all_states 

904 # The parent_id forms the DAG edges 

905 _snapshots: Dict[str, Snapshot] = {} 

906 

907 # Current head: None = at branch head (live), snapshot_id = time-traveling to that snapshot 

908 _current_head: Optional[str] = None 

909 

910 _history_enabled: bool = True 

911 _max_history_size: int = 1000 # Max snapshots in DAG (increased since we don't truncate branches) 

912 _in_time_travel: bool = False # Flag for PFM to know to refresh widget values 

913 

914 # Limbo: ObjectStates temporarily removed during time-travel 

915 # When traveling to a snapshot, ObjectStates not in that snapshot are moved here. 

916 # When traveling forward or to head, they're restored from here. 

917 _time_travel_limbo: Dict[str, 'ObjectState'] = {} 

918 

919 # Graveyard: ObjectStates that were unregistered (deleted) but may be needed for time travel 

920 # Unlike limbo (cleared on diverge), graveyard persists for the session lifetime. 

921 # This allows time travel to snapshots that reference deleted objects. 

922 _graveyard: Dict[str, 'ObjectState'] = {} 

923 

924 # Timelines (branches): Named branches that point to a head snapshot. 

925 # MAIN_BRANCH is created automatically on the first snapshot. 

926 # head_id always points to a valid key in _snapshots (guaranteed by construction) 

927 MAIN_BRANCH = "main" 

928 _timelines: Dict[str, Timeline] = {} 

929 _current_timeline: str = MAIN_BRANCH 

930 

931 @classmethod 

932 @contextmanager 

933 def atomic(cls, label: str, scope_id: Optional[str] = None) -> Generator[None, None, None]: 

934 """Context manager for atomic operations that should be a single undo step. 

935 

936 All ObjectState changes within this context are coalesced into a single 

937 snapshot when the context exits. Nested atomic blocks are supported - 

938 only the outermost block records the snapshot. 

939 

940 Example: 

941 with ObjectStateRegistry.atomic("add step"): 

942 # Register step ObjectState 

943 ObjectStateRegistry.register(step_state) 

944 # Update pipeline's step_scope_ids 

945 pipeline_state.update_parameter("step_scope_ids", new_ids) 

946 # Register function ObjectState 

947 ObjectStateRegistry.register(func_state) 

948 # Single snapshot recorded here with label "add step" 

949 

950 Args: 

951 label: Human-readable label for the coalesced snapshot 

952 scope_id: Optional semantic owner of the mutation. When omitted, 

953 the first scoped snapshot request inside the atomic block becomes 

954 the owner. 

955 """ 

956 block, token = cls._enter_atomic_block(label, scope_id) 

957 try: 

958 yield 

959 finally: 

960 if cls._leave_atomic_block(block, token) and cls._atomic_should_record(block): 

961 cls.record_snapshot(block.label, block.triggering_scope) 

962 

963 @classmethod 

964 @contextmanager 

965 def atomic_success( 

966 cls, 

967 label: str, 

968 scope_id: Optional[str] = None, 

969 ) -> Generator[None, None, None]: 

970 """Record one coalesced snapshot only when the atomic body succeeds. 

971 

972 This shares the same coalescing state as ``atomic`` so nested ordinary 

973 atomic blocks defer into the outer success-only operation. If the body 

974 raises, the outermost success-only operation unwinds without recording 

975 a partial snapshot. 

976 """ 

977 outermost = cls.current_atomic_block() is None 

978 snapshots_before = set(cls._snapshots) if outermost else None 

979 timelines_before = copy.deepcopy(cls._timelines) if outermost else None 

980 current_head_before = cls._current_head if outermost else None 

981 current_timeline_before = cls._current_timeline if outermost else None 

982 in_time_travel_before = cls._in_time_travel if outermost else None 

983 snapshot_dirty_scopes_before = ( 

984 set(cls._snapshot_dirty_scopes) if outermost else None 

985 ) 

986 

987 block, token = cls._enter_atomic_block(label, scope_id) 

988 succeeded = False 

989 try: 

990 yield 

991 succeeded = True 

992 finally: 

993 if cls._leave_atomic_block(block, token): 

994 if succeeded: 

995 if cls._atomic_should_record(block): 

996 cls.record_snapshot(block.label, block.triggering_scope) 

997 elif snapshots_before is not None and timelines_before is not None: 

998 for snapshot_id in set(cls._snapshots) - snapshots_before: 

999 del cls._snapshots[snapshot_id] 

1000 cls._timelines = timelines_before 

1001 cls._current_head = current_head_before 

1002 cls._current_timeline = current_timeline_before or cls.MAIN_BRANCH 

1003 cls._in_time_travel = bool(in_time_travel_before) 

1004 if snapshot_dirty_scopes_before is not None: 

1005 cls._snapshot_dirty_scopes = snapshot_dirty_scopes_before 

1006 

1007 @classmethod 

1008 def current_atomic_block(cls) -> AtomicBlock | None: 

1009 """Return the block the running context is inside, or None outside any block.""" 

1010 block = _atomic_block.get() 

1011 if block is None or block.depth == 0: 

1012 return None 

1013 return block 

1014 

1015 @classmethod 

1016 def _enter_atomic_block( 

1017 cls, label: str, scope_id: str | None 

1018 ) -> tuple[AtomicBlock, Token[AtomicBlock | None] | None]: 

1019 """Join the running context's block, opening one when it is outside any.""" 

1020 block = cls.current_atomic_block() 

1021 if block is not None: 

1022 block.depth += 1 

1023 return block, None 

1024 block = AtomicBlock( 

1025 label=label, 

1026 triggering_scope=scope_id, 

1027 entry_signature=cls._registry_mutation_signature(), 

1028 ) 

1029 return block, _atomic_block.set(block) 

1030 

1031 @classmethod 

1032 def _leave_atomic_block( 

1033 cls, block: AtomicBlock, token: Token[AtomicBlock | None] | None 

1034 ) -> bool: 

1035 """Leave ``block``; return whether that closed it, so its step is now due. 

1036 

1037 A block dropped by ``clear()`` while open is not due: its states are gone. 

1038 """ 

1039 block.depth -= 1 

1040 still_current = _atomic_block.get() is block 

1041 if token is not None: 

1042 _atomic_block.reset(token) 

1043 return block.depth == 0 and still_current 

1044 

1045 @classmethod 

1046 def _registry_mutation_signature( 

1047 cls, 

1048 ) -> RegistryMutationSignature: 

1049 """Return the active registry state used to suppress no-op atomics.""" 

1050 

1051 entries: list[Tuple[str, int, Dict[str, Any], Dict[str, Any], StateConstructionBinding]] = ( 

1052 [] 

1053 ) 

1054 for key, state in sorted(cls._states.items()): 

1055 parameters, saved_parameters = state.copy_parameter_pair_preserving_callable_identity( 

1056 state.parameters, 

1057 state._saved_parameters, 

1058 ) 

1059 entries.append( 

1060 (key, id(state), parameters, saved_parameters, state.construction_binding()) 

1061 ) 

1062 return tuple(entries) 

1063 

1064 @classmethod 

1065 def _atomic_should_record(cls, block: AtomicBlock) -> bool: 

1066 """Return whether the closed block produced a user-visible state change.""" 

1067 

1068 if block.snapshot_requested: 

1069 return True 

1070 return cls._registry_mutation_signature() != block.entry_signature 

1071 

1072 @classmethod 

1073 def ensure_baseline_snapshot(cls, label: str = "init") -> None: 

1074 """Create the initial baseline snapshot when history is empty.""" 

1075 if not cls._history_enabled: 

1076 return 

1077 if cls._in_time_travel: 

1078 return 

1079 if cls._snapshots: 

1080 return 

1081 import time 

1082 

1083 cls._record_snapshot_internal(label, time.time(), None, prepare_resolved=False) 

1084 

1085 @classmethod 

1086 def get_branch_history(cls, branch_name: Optional[str] = None) -> List[Snapshot]: 

1087 """Get ordered history for a branch by walking parent_id chain. 

1088 

1089 Args: 

1090 branch_name: Branch to get history for. If None, uses current branch. 

1091 

1092 Returns: 

1093 List of snapshots from oldest to newest (root first, head last). 

1094 Index 0 = oldest (root), index -1 = newest (head). 

1095 Empty list if branch has no snapshots yet. 

1096 """ 

1097 if branch_name is None: 

1098 branch_name = cls._current_timeline 

1099 

1100 if branch_name not in cls._timelines: 

1101 return [] 

1102 

1103 head_id = cls._timelines[branch_name].head_id 

1104 history = [] 

1105 current_id: Optional[str] = head_id 

1106 

1107 while current_id is not None: 

1108 snapshot = cls._snapshots[current_id] # KeyError if bug 

1109 history.append(snapshot) 

1110 current_id = snapshot.parent_id 

1111 

1112 history.reverse() # [oldest, ..., newest] - natural timeline order 

1113 return history 

1114 

1115 @classmethod 

1116 def get_current_snapshot_index(cls) -> int: 

1117 """Get current position as index into branch history. 

1118 

1119 Returns: 

1120 -1 if at head (live), else index into get_branch_history() list. 

1121 Index 0 = oldest, len-1 = head. 

1122 """ 

1123 if cls._current_head is None: 

1124 return -1 

1125 

1126 history = cls.get_branch_history() 

1127 for i, snapshot in enumerate(history): 

1128 if snapshot.id == cls._current_head: 

1129 return i 

1130 

1131 logger.error(f"⏱️ BRANCH: current_head {cls._current_head} not in branch history") 

1132 return -1 

1133 

1134 @classmethod 

1135 def record_snapshot(cls, label: str = "", scope_id: Optional[str] = None) -> None: 

1136 """Record current state of ALL ObjectStates to history. 

1137 

1138 Called automatically after significant state changes. 

1139 Each snapshot captures the full system state at a point in time. 

1140 

1141 Callers that mutate ObjectState values must create the initial baseline 

1142 before applying the mutation. This method snapshots the current state 

1143 as-is. 

1144 

1145 Args: 

1146 label: Human-readable label (e.g., "edit group_by", "save") 

1147 scope_id: Optional scope that triggered the snapshot (for labeling) 

1148 """ 

1149 if not cls._history_enabled: 

1150 return 

1151 

1152 # CRITICAL: Don't record snapshots during time-travel 

1153 if cls._in_time_travel: 

1154 return 

1155 

1156 # ATOMIC OPERATIONS: Defer snapshot until atomic block exits 

1157 # The atomic() context manager will call record_snapshot() when it completes 

1158 block = cls.current_atomic_block() 

1159 if block is not None: 

1160 block.snapshot_requested = True 

1161 if scope_id is not None and block.triggering_scope is None: 

1162 block.triggering_scope = scope_id 

1163 logger.debug(f"⏱️ ATOMIC: Deferring snapshot '{label}' (depth={block.depth})") 

1164 return 

1165 

1166 import time 

1167 

1168 full_label = f"{label} [{scope_id}]" if scope_id else label 

1169 cls._record_snapshot_internal(full_label, time.time(), scope_id) 

1170 

1171 @classmethod 

1172 def _record_snapshot_internal( 

1173 cls, 

1174 label: str, 

1175 timestamp: float, 

1176 triggering_scope: Optional[str], 

1177 *, 

1178 prepare_resolved: bool = True, 

1179 ) -> None: 

1180 """Internal method to record a snapshot without baseline logic. 

1181 

1182 DAG Model: 

1183 - Snapshots are added to _snapshots dict (never deleted) 

1184 - If _current_head is not None (time-traveling), we're diverging 

1185 - On diverge: create auto-branch for old future, new snapshot parents from _current_head 

1186 - Branch head_id is updated to point to new snapshot 

1187 """ 

1188 import uuid 

1189 

1190 if prepare_resolved: 

1191 if cls.has_deferred_invalidations(): 

1192 cls.flush_deferred_invalidations() 

1193 cls._prepare_states_for_snapshot() 

1194 

1195 # Determine parent_id for new snapshot 

1196 if cls._current_head is not None: 

1197 # We're in the past - diverging from _current_head 

1198 parent_id = cls._current_head 

1199 

1200 # Auto-branch: preserve old future before we diverge 

1201 # The old branch head becomes an auto-saved branch 

1202 if cls._current_timeline in cls._timelines: 

1203 old_head_id = cls._timelines[cls._current_timeline].head_id 

1204 if old_head_id != cls._current_head: 

1205 # There IS an old future to preserve 

1206 branch_name = f"auto-{old_head_id[:8]}" 

1207 if branch_name not in cls._timelines: 

1208 cls._timelines[branch_name] = Timeline( 

1209 name=branch_name, 

1210 head_id=old_head_id, 

1211 base_id=cls._current_head, 

1212 description=f"Auto-saved from diverge at {cls._current_head[:8]}", 

1213 ) 

1214 old_snapshot = cls._snapshots[old_head_id] 

1215 logger.info(f"⏱️ AUTO-BRANCH: Created '{branch_name}' (was {old_snapshot.label})") 

1216 

1217 # Move limbo contents to graveyard before clearing - enables time travel 

1218 # to any snapshot that references these states (even on other branches) 

1219 cls._graveyard.update(cls._time_travel_limbo) 

1220 cls._time_travel_limbo.clear() 

1221 else: 

1222 # At branch head - parent is current branch's head (if exists) 

1223 if cls._current_timeline in cls._timelines: 

1224 parent_id = cls._timelines[cls._current_timeline].head_id 

1225 else: 

1226 parent_id = None 

1227 

1228 parent_snapshot = cls._snapshots.get(parent_id) if parent_id is not None else None 

1229 

1230 # Capture registered ObjectStates as StateSnapshot dataclasses. 

1231 # Unchanged scopes reuse the parent snapshot's immutable StateSnapshot; 

1232 # dirty/new scopes are copied. This keeps snapshots semantically 

1233 # complete without revalidating and comparing every open step/config on 

1234 # every edit. 

1235 current_state_keys = set(cls._states) 

1236 if parent_snapshot is None: 

1237 all_states: Dict[str, StateSnapshot] = {} 

1238 snapshot_candidate_keys = current_state_keys 

1239 else: 

1240 all_states = { 

1241 key: state_snapshot 

1242 for key, state_snapshot in parent_snapshot.all_states.items() 

1243 if key in current_state_keys 

1244 } 

1245 snapshot_candidate_keys = ( 

1246 cls._snapshot_dirty_scopes & current_state_keys 

1247 ) | (current_state_keys - set(parent_snapshot.all_states)) 

1248 

1249 for state in cls._states.values(): 

1250 state.validate_metadata_contracts("snapshot") 

1251 capture_values = { 

1252 key: StateSnapshot( 

1253 saved_resolved=state._saved_resolved, 

1254 live_resolved=state._live_resolved, 

1255 parameters=state.parameters, 

1256 saved_parameters=state._saved_parameters, 

1257 provenance=state._live_provenance, 

1258 construction=state.construction_binding(), 

1259 meta=state.copy_metadata_for_snapshot(), 

1260 ) 

1261 for key, state in cls._states.items() 

1262 } 

1263 # A changed scope also invalidates immutable snapshots that share a 

1264 # copied object with it. Independent clean scopes retain structural 

1265 # sharing; connected scopes are copied together with one graph memo. 

1266 alias_scopes: Dict[int, Set[str]] = {} 

1267 for key, values in capture_values.items(): 

1268 memo: Dict[int, Any] = {} 

1269 copy.deepcopy(values, memo) 

1270 for identity in memo: 

1271 if identity not in (id(memo), id(values), id(values.construction)): 

1272 alias_scopes.setdefault(identity, set()).add(key) 

1273 pending = list(snapshot_candidate_keys) 

1274 while pending: 

1275 key = pending.pop() 

1276 for scopes in alias_scopes.values(): 

1277 if key in scopes: 

1278 additional = scopes - snapshot_candidate_keys 

1279 snapshot_candidate_keys.update(additional) 

1280 pending.extend(additional) 

1281 captured = copy.deepcopy({key: capture_values[key] for key in snapshot_candidate_keys}) 

1282 

1283 with TimeTravelProfiler.phase( 

1284 "objectstate.snapshot_capture_states", 

1285 states=len(cls._states), 

1286 has_parent=parent_snapshot is not None, 

1287 candidates=len(snapshot_candidate_keys), 

1288 ): 

1289 for key in snapshot_candidate_keys: 

1290 state = cls._states[key] 

1291 state.validate_metadata_contracts("snapshot") 

1292 all_states[key] = captured[key] 

1293 cls._snapshot_dirty_scopes.clear() 

1294 

1295 # Create new snapshot 

1296 snapshot = Snapshot( 

1297 id=str(uuid.uuid4()), 

1298 timestamp=timestamp, 

1299 label=label, 

1300 triggering_scope=triggering_scope, 

1301 parent_id=parent_id, 

1302 all_states=all_states, 

1303 ) 

1304 

1305 # Add to DAG 

1306 cls._snapshots[snapshot.id] = snapshot 

1307 

1308 # Update or create current branch to point to new snapshot 

1309 if cls._current_timeline not in cls._timelines: 

1310 cls._timelines[cls._current_timeline] = Timeline( 

1311 name=cls._current_timeline, 

1312 head_id=snapshot.id, 

1313 base_id=snapshot.id, 

1314 description="Main timeline" if cls._current_timeline == cls.MAIN_BRANCH else "", 

1315 ) 

1316 logger.info(f"⏱️ BRANCH: Created '{cls._current_timeline}' timeline") 

1317 else: 

1318 cls._timelines[cls._current_timeline].head_id = snapshot.id 

1319 

1320 # Return to head (live state) - we're no longer time-traveling 

1321 cls._current_head = None 

1322 

1323 # Enforce max DAG size (prune oldest snapshots not referenced by any branch) 

1324 if len(cls._snapshots) > cls._max_history_size: 

1325 cls._prune_unreachable_snapshots() 

1326 

1327 logger.debug(f"⏱️ SNAPSHOT: Recorded '{label}' (id={snapshot.id[:8]})") 

1328 cls._fire_history_changed_callbacks() 

1329 

1330 @staticmethod 

1331 def _copy_snapshot_mapping_with_parent_sharing( 

1332 current: Dict[str, Any], 

1333 parent: Optional[Dict[str, Any]], 

1334 ) -> Dict[str, Any]: 

1335 """Copy snapshot values while reusing unchanged parent snapshot entries.""" 

1336 if parent is None: 

1337 return copy.deepcopy(current) 

1338 

1339 snapshot: Dict[str, Any] = {} 

1340 for key, value in current.items(): 

1341 if key in parent and parent[key] == value: 

1342 snapshot[key] = parent[key] 

1343 else: 

1344 snapshot[key] = copy.deepcopy(value) 

1345 return snapshot 

1346 

1347 @classmethod 

1348 def _prepare_states_for_snapshot(cls) -> None: 

1349 """Synchronize resolved caches before immutable snapshot capture.""" 

1350 

1351 for _key, state in cls._states_by_dependency_order(): 

1352 state.prepare_for_snapshot_capture() 

1353 

1354 @classmethod 

1355 def _states_by_dependency_order(cls) -> tuple[tuple[str, 'ObjectState'], ...]: 

1356 """Return states ordered so ancestors resolve before descendants.""" 

1357 

1358 return tuple( 

1359 sorted( 

1360 cls._states.items(), 

1361 key=lambda item: (item[0].depth, item[0]), 

1362 ) 

1363 ) 

1364 

1365 @classmethod 

1366 def _prune_unreachable_snapshots(cls) -> None: 

1367 """Remove snapshots not reachable from any branch head. 

1368 

1369 Walks from each branch head to find all reachable snapshots, 

1370 then removes any that aren't reachable. 

1371 """ 

1372 reachable: Set[str] = set() 

1373 

1374 for timeline in cls._timelines.values(): 

1375 current_id: Optional[str] = timeline.head_id 

1376 while current_id is not None and current_id not in reachable: 

1377 reachable.add(current_id) 

1378 snapshot = cls._snapshots.get(current_id) 

1379 current_id = snapshot.parent_id if snapshot else None 

1380 

1381 # Remove unreachable 

1382 unreachable = set(cls._snapshots.keys()) - reachable 

1383 for snapshot_id in unreachable: 

1384 del cls._snapshots[snapshot_id] 

1385 

1386 if unreachable: 

1387 logger.debug(f"⏱️ PRUNE: Removed {len(unreachable)} unreachable snapshots") 

1388 

1389 @classmethod 

1390 def _build_time_travel_transaction(cls, snapshot_id: str) -> TimeTravelTransaction | None: 

1391 if snapshot_id not in cls._snapshots: 

1392 logger.error(f"⏱️ TIME_TRAVEL: Snapshot {snapshot_id} not found") 

1393 return None 

1394 

1395 snapshot = cls._snapshots[snapshot_id] 

1396 return TimeTravelTransaction( 

1397 snapshot_id=snapshot_id, 

1398 snapshot=snapshot, 

1399 source_snapshot_id=cls._current_snapshot_id(), 

1400 snapshot_scopes=set(snapshot.all_states.keys()), 

1401 current_scopes=set(cls._states.keys()), 

1402 ) 

1403 

1404 @classmethod 

1405 def _current_snapshot_id(cls) -> Optional[str]: 

1406 """Return the snapshot id that currently backs the live registry state.""" 

1407 

1408 if cls._current_head is not None: 

1409 return cls._current_head 

1410 timeline = cls._timelines.get(cls._current_timeline) 

1411 if timeline is None: 

1412 return None 

1413 return timeline.head_id 

1414 

1415 @classmethod 

1416 def _navigation_triggering_scope( 

1417 cls, 

1418 transaction: TimeTravelTransaction, 

1419 ) -> Optional[str]: 

1420 """Return the semantic mutation owner for the time-travel transition. 

1421 

1422 A snapshot's ``triggering_scope`` describes the operation that produced 

1423 that snapshot from its parent. When moving backward, the operation being 

1424 undone is the source snapshot, not the target snapshot. When moving 

1425 forward, the operation being redone is the target snapshot. 

1426 """ 

1427 

1428 source_snapshot = ( 

1429 cls._snapshots.get(transaction.source_snapshot_id) 

1430 if transaction.source_snapshot_id is not None 

1431 else None 

1432 ) 

1433 target_snapshot = transaction.snapshot 

1434 

1435 if source_snapshot is not None: 

1436 if source_snapshot.parent_id == target_snapshot.id: 

1437 return source_snapshot.triggering_scope 

1438 if target_snapshot.parent_id == source_snapshot.id: 

1439 return target_snapshot.triggering_scope 

1440 

1441 return target_snapshot.triggering_scope or ( 

1442 source_snapshot.triggering_scope 

1443 if source_snapshot is not None 

1444 else None 

1445 ) 

1446 

1447 @classmethod 

1448 def _reconcile_time_travel_scopes( 

1449 cls, 

1450 transaction: TimeTravelTransaction, 

1451 prepared: Dict[str, PreparedStateRestore], 

1452 ) -> TimeTravelScopeReconciliation: 

1453 moved_to_limbo: Set[str] = set() 

1454 restored_scopes: Set[str] = set() 

1455 

1456 for scope_key in transaction.current_scopes - transaction.snapshot_scopes: 

1457 state = cls._states.pop(scope_key) 

1458 cls._time_travel_limbo[scope_key] = state 

1459 moved_to_limbo.add(scope_key) 

1460 logger.debug(f"⏱️ TIME_TRAVEL: Moved to limbo: {scope_key}") 

1461 

1462 for scope_key in transaction.snapshot_scopes - transaction.current_scopes: 

1463 state = prepared[scope_key].state 

1464 cls._time_travel_limbo.pop(scope_key, None) 

1465 cls._graveyard.pop(scope_key, None) 

1466 cls._states[scope_key] = state 

1467 restored_scopes.add(scope_key) 

1468 

1469 return TimeTravelScopeReconciliation( 

1470 moved_to_limbo=moved_to_limbo, 

1471 restored_scopes=restored_scopes, 

1472 ) 

1473 

1474 @classmethod 

1475 def _prepare_time_travel_states(cls, snapshot: Snapshot) -> Dict[str, PreparedStateRestore]: 

1476 """Validate and construct the entire scope graph before publishing it.""" 

1477 

1478 values = copy.deepcopy(snapshot.all_states) 

1479 prepared: Dict[str, PreparedStateRestore] = {} 

1480 pending = dict(values) 

1481 while pending: 

1482 progressed = False 

1483 for scope, state_snapshot in list(pending.items()): 

1484 binding = state_snapshot.construction 

1485 if not isinstance(binding, StateConstructionBinding): 

1486 raise ValueError(f"Missing typed construction binding at {scope!r}.") 

1487 parent_scope = binding.parent_scope 

1488 if parent_scope is not None and parent_scope not in values: 

1489 raise ValueError(f"Missing historical parent {parent_scope!r} for {scope!r}.") 

1490 if parent_scope is not None and parent_scope not in prepared: 

1491 continue 

1492 state = ( 

1493 cls._states.get(scope) 

1494 or cls._time_travel_limbo.get(scope) 

1495 or cls._graveyard.get(scope) 

1496 ) 

1497 parent = prepared[parent_scope].schema if parent_scope is not None else None 

1498 schema = binding.prepare(scope, state, parent) 

1499 schema.metadata = ObjectStateMetadataStore.from_snapshot( 

1500 scope_id=scope, metadata=state_snapshot.meta 

1501 ) 

1502 schema.validate_metadata_contracts("time_travel") 

1503 state_snapshot = cls._reconcile_snapshot_schema( 

1504 schema, 

1505 state_snapshot, 

1506 ) 

1507 cls._apply_time_travel_snapshot_state(schema, state_snapshot, materialize=False) 

1508 binding.validate_scope_membership(schema, values.keys()) 

1509 schema._raw_dirty = schema.parameters != schema._saved_parameters 

1510 schema._dirty_fields = schema._compute_dirty_fields() 

1511 schema._signature_diff_fields = schema._compute_signature_diff_fields() 

1512 prepared[scope] = PreparedStateRestore( 

1513 snapshot=state_snapshot, 

1514 state=state if state is not None else schema, 

1515 schema=schema, 

1516 diff=cls._time_travel_state_diff( 

1517 state if state is not None else schema, state_snapshot 

1518 ), 

1519 construction_changed=( 

1520 state is not None and state.construction_binding() != binding 

1521 ), 

1522 ) 

1523 del pending[scope] 

1524 progressed = True 

1525 if not progressed: 

1526 raise ValueError(f"Cyclic historical ObjectState parents: {sorted(pending)!r}.") 

1527 return prepared 

1528 

1529 @staticmethod 

1530 def _reconcile_snapshot_schema( 

1531 schema: "ObjectState", 

1532 snapshot: StateSnapshot, 

1533 ) -> StateSnapshot: 

1534 """Project historical values onto the declaration's current field schema. 

1535 

1536 Construction owns the valid parameter surface. Historical snapshots own 

1537 values only for fields still declared on that surface. This gives newly 

1538 declared fields their construction-derived defaults and prevents removed 

1539 fields from surviving as detached historical parameters. 

1540 """ 

1541 

1542 def reconcile(current: dict, historical: dict) -> dict: 

1543 return { 

1544 key: historical[key] if key in historical else value 

1545 for key, value in current.items() 

1546 } 

1547 

1548 return replace( 

1549 snapshot, 

1550 parameters=reconcile(schema.parameters, snapshot.parameters), 

1551 saved_parameters=reconcile( 

1552 schema._saved_parameters, 

1553 snapshot.saved_parameters, 

1554 ), 

1555 saved_resolved=reconcile( 

1556 schema._saved_resolved, 

1557 snapshot.saved_resolved, 

1558 ), 

1559 live_resolved=reconcile( 

1560 schema._live_resolved, 

1561 snapshot.live_resolved, 

1562 ), 

1563 provenance=reconcile( 

1564 schema._live_provenance, 

1565 snapshot.provenance, 

1566 ), 

1567 ) 

1568 

1569 @classmethod 

1570 def _restore_time_travel_states( 

1571 cls, 

1572 transaction: TimeTravelTransaction, 

1573 reconciliation: TimeTravelScopeReconciliation, 

1574 prepared: Dict[str, PreparedStateRestore], 

1575 ) -> TimeTravelChangeSet: 

1576 scope_changes: Dict[str, TimeTravelScopeChange] = {} 

1577 

1578 for scope_key, restoration in prepared.items(): 

1579 state = restoration.state 

1580 state_snap = restoration.snapshot 

1581 binding = state_snap.construction 

1582 parent = cls._states[binding.parent_scope] if binding.parent_scope is not None else None 

1583 state.apply_prepared_construction(binding, restoration.schema, parent) 

1584 

1585 with TimeTravelProfiler.phase("objectstate.restore_scope", scope=scope_key): 

1586 scope_changes[scope_key] = cls._restore_time_travel_state( 

1587 scope_key=scope_key, 

1588 state=state, 

1589 state_snap=state_snap, 

1590 was_restored_from_limbo=scope_key in reconciliation.restored_scopes, 

1591 construction_changed=restoration.construction_changed, 

1592 diff=restoration.diff, 

1593 ) 

1594 

1595 return TimeTravelChangeSet( 

1596 triggering_scope=cls._navigation_triggering_scope(transaction), 

1597 scope_changes=scope_changes, 

1598 ) 

1599 

1600 @classmethod 

1601 def _restore_time_travel_state( 

1602 cls, 

1603 *, 

1604 scope_key: str, 

1605 state: "ObjectState", 

1606 state_snap: StateSnapshot, 

1607 was_restored_from_limbo: bool, 

1608 diff: TimeTravelStateDiff, 

1609 construction_changed: bool = False, 

1610 ) -> TimeTravelScopeChange: 

1611 with TimeTravelProfiler.phase( 

1612 "objectstate.scope_navigation_meta", 

1613 scope=scope_key, 

1614 changed_paths=len(diff.changed_paths), 

1615 ): 

1616 cls._apply_time_travel_navigation_state( 

1617 scope_key=scope_key, 

1618 state=state, 

1619 changed_values=cls._time_travel_navigation_changed_values( 

1620 concrete_changed_values=diff.changed_param_values, 

1621 notification_changed_values=diff.notification_changed_values, 

1622 ), 

1623 ) 

1624 cls._log_time_travel_param_changes( 

1625 scope_key=scope_key, 

1626 changed_param_values=diff.changed_param_values, 

1627 was_restored_from_limbo=was_restored_from_limbo, 

1628 ) 

1629 cls._apply_time_travel_meta_state( 

1630 scope_key=scope_key, 

1631 state=state, 

1632 meta_changed_values=diff.meta_changed_values, 

1633 ) 

1634 

1635 with TimeTravelProfiler.phase( 

1636 "objectstate.scope_apply_snapshot", 

1637 scope=scope_key, 

1638 changed_paths=len(diff.changed_paths), 

1639 ): 

1640 cls._apply_time_travel_snapshot_state( 

1641 state, state_snap, 

1642 materialize=(diff.requires_snapshot_apply or was_restored_from_limbo or construction_changed), 

1643 ) 

1644 is_concrete_dirty = state.parameters != state._saved_parameters 

1645 if is_concrete_dirty: 

1646 cls._log_time_travel_concrete_dirty(scope_key, state) 

1647 

1648 return TimeTravelScopeChange( 

1649 changed_paths=diff.changed_paths, 

1650 changed_param_keys=set(diff.changed_param_values.keys()), 

1651 meta_changed_keys=set(diff.meta_changed_values.keys()), 

1652 is_concrete_dirty=is_concrete_dirty, 

1653 construction_changed=construction_changed, 

1654 ) 

1655 

1656 @classmethod 

1657 def _time_travel_state_diff( 

1658 cls, 

1659 state: 'ObjectState', 

1660 state_snap: StateSnapshot, 

1661 ) -> TimeTravelStateDiff: 

1662 current_params = state.parameters.copy() if state.parameters else {} 

1663 current_live = state._live_resolved.copy() if state._live_resolved else {} 

1664 

1665 live_changed_paths = cls._time_travel_changed_paths( 

1666 current_live=current_live, 

1667 target_live=state_snap.live_resolved, 

1668 ) 

1669 changed_param_values = cls._time_travel_changed_param_values( 

1670 current_params=current_params, 

1671 target_params=state_snap.parameters, 

1672 ) 

1673 notification_changed_values = cls._time_travel_changed_live_values( 

1674 current_live=current_live, 

1675 target_live=state_snap.live_resolved, 

1676 changed_paths=live_changed_paths, 

1677 ) 

1678 notification_changed_values.update(changed_param_values) 

1679 changed_paths = state.notification_fields_for_value_changes( 

1680 notification_changed_values 

1681 ) 

1682 

1683 return TimeTravelStateDiff( 

1684 changed_paths=changed_paths, 

1685 changed_param_values=changed_param_values, 

1686 notification_changed_values=notification_changed_values, 

1687 meta_changed_values=cls._time_travel_changed_meta_values( 

1688 current_meta=state.metadata, 

1689 target_meta=state_snap.meta, 

1690 ), 

1691 saved_parameters_changed=state._saved_parameters 

1692 != state_snap.saved_parameters, 

1693 saved_resolved_changed=state._saved_resolved 

1694 != state_snap.saved_resolved, 

1695 provenance_changed=state._live_provenance != state_snap.provenance, 

1696 ) 

1697 

1698 @staticmethod 

1699 def _time_travel_changed_paths( 

1700 *, 

1701 current_live: Dict, 

1702 target_live: Dict, 

1703 ) -> Set[str]: 

1704 return { 

1705 key 

1706 for key in set(target_live.keys()) | set(current_live.keys()) 

1707 if target_live.get(key) != current_live.get(key) 

1708 } 

1709 

1710 @staticmethod 

1711 def _time_travel_changed_param_values( 

1712 *, 

1713 current_params: Dict, 

1714 target_params: Dict, 

1715 ) -> Dict[str, Tuple[Any, Any]]: 

1716 changed: Dict[str, Tuple[Any, Any]] = {} 

1717 for param_key in set(target_params.keys()) | set(current_params.keys()): 

1718 before = current_params.get(param_key) 

1719 after = target_params.get(param_key) 

1720 if is_dataclass(before) or is_dataclass(after): 

1721 continue 

1722 if before != after: 

1723 changed[param_key] = (before, after) 

1724 return changed 

1725 

1726 @staticmethod 

1727 def _time_travel_changed_live_values( 

1728 *, 

1729 current_live: Dict, 

1730 target_live: Dict, 

1731 changed_paths: Set[str], 

1732 ) -> Dict[str, Tuple[Any, Any]]: 

1733 return { 

1734 path: ( 

1735 current_live.get(path), 

1736 target_live.get(path), 

1737 ) 

1738 for path in changed_paths 

1739 } 

1740 

1741 @classmethod 

1742 def _time_travel_navigation_changed_values( 

1743 cls, 

1744 *, 

1745 concrete_changed_values: Dict[str, Tuple[Any, Any]], 

1746 notification_changed_values: Dict[str, Tuple[Any, Any]], 

1747 ) -> Dict[str, Tuple[Any, Any]]: 

1748 """Return concrete changed paths plus their structural descendants for navigation.""" 

1749 

1750 if not concrete_changed_values: 

1751 return {} 

1752 from objectstate.field_access import DottedFieldPath 

1753 

1754 concrete_paths = tuple( 

1755 DottedFieldPath(path) 

1756 for path in concrete_changed_values 

1757 ) 

1758 return { 

1759 path: values 

1760 for path, values in notification_changed_values.items() 

1761 if any( 

1762 concrete_path.contains_path(path) 

1763 for concrete_path in concrete_paths 

1764 ) 

1765 } 

1766 

1767 @classmethod 

1768 def _apply_time_travel_navigation_state( 

1769 cls, 

1770 *, 

1771 scope_key: str, 

1772 state: 'ObjectState', 

1773 changed_values: Dict[str, Tuple[Any, Any]], 

1774 ) -> None: 

1775 if not changed_values: 

1776 state._last_changed_field = None 

1777 state._last_changed_concrete_paths = set() 

1778 state._last_changed_paths = set() 

1779 state._last_changed_values = {} 

1780 logger.debug(f"⏱️ LAST_CHANGED_FIELD: {scope_key} field=None (no value changes)") 

1781 return 

1782 

1783 state._set_last_changed_values(changed_values) 

1784 logger.debug( 

1785 "⏱️ LAST_CHANGED_FIELD: %s field=%s total_changes=%d", 

1786 scope_key, 

1787 state._last_changed_field, 

1788 len(changed_values), 

1789 ) 

1790 

1791 @staticmethod 

1792 def _log_time_travel_param_changes( 

1793 *, 

1794 scope_key: str, 

1795 changed_param_values: Dict[str, Tuple[Any, Any]], 

1796 was_restored_from_limbo: bool, 

1797 ) -> None: 

1798 if was_restored_from_limbo: 

1799 return 

1800 for param_key, (before, after) in changed_param_values.items(): 

1801 logger.debug( 

1802 "⏱️ PARAM_CHANGE: %s param=%s before=%r after=%r", 

1803 scope_key, 

1804 param_key, 

1805 before, 

1806 after, 

1807 ) 

1808 

1809 @staticmethod 

1810 def _time_travel_changed_meta_values( 

1811 *, 

1812 current_meta: Dict, 

1813 target_meta: Dict, 

1814 ) -> Dict[str, Tuple[Any, Any]]: 

1815 changed: Dict[str, Tuple[Any, Any]] = {} 

1816 for meta_key in set(current_meta.keys()) | set(target_meta.keys()): 

1817 before = current_meta.get(meta_key) 

1818 after = target_meta.get(meta_key) 

1819 if before != after: 

1820 changed[meta_key] = (before, after) 

1821 return changed 

1822 

1823 @staticmethod 

1824 def _apply_time_travel_meta_state( 

1825 *, 

1826 scope_key: str, 

1827 state: 'ObjectState', 

1828 meta_changed_values: Dict[str, Tuple[Any, Any]], 

1829 ) -> None: 

1830 state._last_changed_meta_keys = set(meta_changed_values.keys()) 

1831 state._last_changed_meta_values = meta_changed_values 

1832 if meta_changed_values: 

1833 logger.debug("⏱️ META_CHANGE: %s keys=%s", scope_key, sorted(meta_changed_values)) 

1834 

1835 @staticmethod 

1836 def _apply_time_travel_snapshot_state( 

1837 state: "ObjectState", 

1838 state_snap: StateSnapshot, 

1839 *, 

1840 materialize: bool = True, 

1841 ) -> None: 

1842 # The complete historical graph was copied once during preparation. 

1843 state._saved_resolved = state_snap.saved_resolved 

1844 state._live_resolved = state_snap.live_resolved 

1845 state._live_provenance = state_snap.provenance 

1846 state.parameters = state_snap.parameters 

1847 state._index_parameter_paths() 

1848 state._saved_parameters = state_snap.saved_parameters 

1849 state._cached_object = None 

1850 state._cached_object_applied = False 

1851 state.metadata = ObjectStateMetadataStore.from_snapshot( 

1852 scope_id=state.scope_id, 

1853 metadata=state_snap.meta, 

1854 ) 

1855 if materialize: 

1856 state._sync_materialized_state(emit_notifications=False) 

1857 

1858 @staticmethod 

1859 def _log_time_travel_concrete_dirty(scope_key: str, state: "ObjectState") -> None: 

1860 for param_key in set(state.parameters.keys()) | set(state._saved_parameters.keys()): 

1861 parameter_value = state.parameters.get(param_key) 

1862 saved_value = state._saved_parameters.get(param_key) 

1863 if parameter_value != saved_value: 

1864 logger.debug( 

1865 "⏱️ CONCRETE_DIRTY: %s param=%s params=%r saved_params=%r", 

1866 scope_key, 

1867 param_key, 

1868 parameter_value, 

1869 saved_value, 

1870 ) 

1871 

1872 @classmethod 

1873 def _notify_time_travel_state_callbacks(cls, change_set: TimeTravelChangeSet) -> None: 

1874 for scope_key, change in change_set.scope_changes.items(): 

1875 state = cls._states.get(scope_key) 

1876 if state is None: 

1877 continue 

1878 if change.changed_paths: 

1879 with TimeTravelProfiler.phase( 

1880 "objectstate.notify_resolved", 

1881 scope=scope_key, 

1882 changed_paths=len(change.changed_paths), 

1883 ): 

1884 state._notify_resolved_changed( 

1885 change.changed_paths, 

1886 context="time_travel", 

1887 ) 

1888 

1889 @classmethod 

1890 def _notify_time_travel_materialized_callbacks( 

1891 cls, 

1892 change_set: TimeTravelChangeSet, 

1893 ) -> None: 

1894 for scope_key, change in change_set.scope_changes.items(): 

1895 state = cls._states.get(scope_key) 

1896 if state is None: 

1897 continue 

1898 if not ( 

1899 change.changed_paths 

1900 or change.changed_param_keys 

1901 or change.meta_changed_keys 

1902 or change.construction_changed 

1903 ): 

1904 continue 

1905 with TimeTravelProfiler.phase( 

1906 "objectstate.notify_state_callback", 

1907 scope=scope_key, 

1908 changed_paths=len(change.changed_paths), 

1909 changed_params=len(change.changed_param_keys), 

1910 meta_changes=len(change.meta_changed_keys), 

1911 ): 

1912 state._notify_state_changed( 

1913 change.changed_paths | change.changed_param_keys 

1914 ) 

1915 

1916 @classmethod 

1917 def _fire_time_travel_complete_callbacks( 

1918 cls, 

1919 change_set: TimeTravelChangeSet, 

1920 ) -> None: 

1921 if not cls._on_time_travel_complete_callbacks: 

1922 return 

1923 

1924 states_with_changes = change_set.states_needing_navigation(cls._states) 

1925 logger.debug( 

1926 "⏱️ TIME_TRAVEL: Firing %d callback(s) with %d changed state(s)", 

1927 len(cls._on_time_travel_complete_callbacks), 

1928 len(states_with_changes), 

1929 ) 

1930 for callback in cls._on_time_travel_complete_callbacks: 

1931 with TimeTravelProfiler.phase( 

1932 "objectstate.complete_callback", 

1933 callback=f"{type(callback).__module__}.{type(callback).__qualname__}", 

1934 changed_states=len(states_with_changes), 

1935 ): 

1936 callback(states_with_changes, change_set.triggering_scope) 

1937 

1938 @classmethod 

1939 def time_travel_to_snapshot(cls, snapshot_id: str) -> bool: 

1940 """Travel ALL ObjectStates to a specific snapshot by ID. 

1941 

1942 Full time-travel: ObjectStates are registered/unregistered to match the snapshot. 

1943 ObjectStates not in the snapshot are moved to limbo. ObjectStates in snapshot 

1944 but not in registry are restored from limbo. 

1945 

1946 Args: 

1947 snapshot_id: UUID of snapshot to travel to 

1948 

1949 Returns: 

1950 True if travel succeeded. 

1951 """ 

1952 transaction = cls._build_time_travel_transaction(snapshot_id) 

1953 if transaction is None: 

1954 return False 

1955 

1956 prepared = cls._prepare_time_travel_states(transaction.snapshot) 

1957 states_before = dict(cls._states) 

1958 limbo_before = dict(cls._time_travel_limbo) 

1959 graveyard_before = dict(cls._graveyard) 

1960 head_before = cls._current_head 

1961 dirty_before = set(cls._snapshot_dirty_scopes) 

1962 checkpoints = { 

1963 scope: ObjectStateTransactionCheckpoint.capture( 

1964 restoration.state, preserve_value_identity=True 

1965 ) 

1966 for scope, restoration in prepared.items() 

1967 } 

1968 cls._current_head = snapshot_id 

1969 cls._in_time_travel = True 

1970 try: 

1971 with TimeTravelProfiler.phase( 

1972 "objectstate.reconcile_scopes", 

1973 snapshot=snapshot_id[:8], 

1974 ): 

1975 reconciliation = cls._reconcile_time_travel_scopes(transaction, prepared) 

1976 with TimeTravelProfiler.phase( 

1977 "objectstate.restore_states", 

1978 snapshot=snapshot_id[:8], 

1979 states=len(transaction.snapshot.all_states), 

1980 ): 

1981 change_set = cls._restore_time_travel_states( 

1982 transaction, 

1983 reconciliation, 

1984 prepared, 

1985 ) 

1986 except Exception: 

1987 try: 

1988 for scope, restoration in reversed(list(prepared.items())): 

1989 checkpoints[scope].restore(restoration.state, emit_notifications=False) 

1990 finally: 

1991 cls._states = states_before 

1992 cls._time_travel_limbo = limbo_before 

1993 cls._graveyard = graveyard_before 

1994 cls._current_head = head_before 

1995 cls._snapshot_dirty_scopes = dirty_before 

1996 raise 

1997 finally: 

1998 cls._in_time_travel = False 

1999 

2000 cls._in_time_travel = True 

2001 try: 

2002 # Lifecycle subscribers only see a complete, rebound scope graph. 

2003 for scope in reconciliation.moved_to_limbo: 

2004 cls._fire_unregister_callbacks(scope, cls._time_travel_limbo[scope]) 

2005 for scope in reconciliation.restored_scopes: 

2006 cls._fire_register_callbacks(scope, cls._states[scope]) 

2007 with TimeTravelProfiler.phase( 

2008 "objectstate.notify_resolved_all", 

2009 snapshot=snapshot_id[:8], 

2010 ): 

2011 cls._notify_time_travel_state_callbacks(change_set) 

2012 with TimeTravelProfiler.phase( 

2013 "objectstate.notify_state_all", 

2014 snapshot=snapshot_id[:8], 

2015 ): 

2016 cls._notify_time_travel_materialized_callbacks(change_set) 

2017 with TimeTravelProfiler.phase( 

2018 "objectstate.complete_callbacks_all", 

2019 snapshot=snapshot_id[:8], 

2020 ): 

2021 cls._fire_time_travel_complete_callbacks(change_set) 

2022 finally: 

2023 cls._in_time_travel = False 

2024 

2025 with TimeTravelProfiler.phase("objectstate.history_callbacks"): 

2026 cls._fire_history_changed_callbacks() 

2027 logger.info( 

2028 "⏱️ TIME_TRAVEL: Traveled to %s (%s)", 

2029 transaction.snapshot.label, 

2030 snapshot_id[:8], 

2031 ) 

2032 return True 

2033 

2034 @classmethod 

2035 def time_travel_to(cls, index: int) -> bool: 

2036 """Travel to a snapshot by index in current branch history. 

2037 

2038 Convenience method for UI sliders. Uses get_branch_history() to map 

2039 index to snapshot_id. 

2040 

2041 Args: 

2042 index: Index into branch history (0 = oldest/root, -1 = newest/head) 

2043 

2044 Returns: 

2045 True if travel succeeded. 

2046 """ 

2047 history = cls.get_branch_history() 

2048 if not history: 

2049 logger.warning("⏱️ TIME_TRAVEL: No history") 

2050 return False 

2051 

2052 # Normalize negative index 

2053 if index < 0: 

2054 index = len(history) + index 

2055 

2056 if index < 0 or index >= len(history): 

2057 logger.warning(f"⏱️ TIME_TRAVEL: Index {index} out of range [0, {len(history) - 1}]") 

2058 return False 

2059 

2060 # Index len-1 = head (newest), returning to head means exit time-travel 

2061 if index == len(history) - 1: 

2062 return cls.time_travel_to_head() 

2063 

2064 snapshot = history[index] 

2065 return cls.time_travel_to_snapshot(snapshot.id) 

2066 

2067 @classmethod 

2068 def time_travel_back(cls) -> bool: 

2069 """Travel one step back in history (toward older/lower index).""" 

2070 history = cls.get_branch_history() 

2071 if not history: 

2072 return False 

2073 

2074 current_index = cls.get_current_snapshot_index() 

2075 if current_index == -1: 

2076 # At head (len-1) - go one step back 

2077 if len(history) < 2: 

2078 return False 

2079 return cls.time_travel_to_snapshot(history[-2].id) 

2080 

2081 # Already time-traveling - go one step older (lower index) 

2082 if current_index <= 0: 

2083 return False # Already at oldest 

2084 return cls.time_travel_to_snapshot(history[current_index - 1].id) 

2085 

2086 @classmethod 

2087 def time_travel_forward(cls) -> bool: 

2088 """Travel one step forward in history (toward newer/higher index).""" 

2089 if cls._current_head is None: 

2090 return False # Already at head 

2091 

2092 history = cls.get_branch_history() 

2093 current_index = cls.get_current_snapshot_index() 

2094 

2095 # Go one step toward head (higher index) 

2096 next_index = current_index + 1 

2097 if next_index >= len(history) - 1: 

2098 # At or past head - return to head 

2099 return cls.time_travel_to_head() 

2100 

2101 return cls.time_travel_to_snapshot(history[next_index].id) 

2102 

2103 @classmethod 

2104 def time_travel_to_head(cls) -> bool: 

2105 """Return to the latest state (exit time-travel mode). 

2106 

2107 Restores state from current branch's head snapshot. 

2108 """ 

2109 if cls._current_head is None: 

2110 return True # Already at head 

2111 

2112 if cls._current_timeline not in cls._timelines: 

2113 logger.warning("⏱️ TIME_TRAVEL: No current timeline") 

2114 return False 

2115 

2116 head_id = cls._timelines[cls._current_timeline].head_id 

2117 result = cls.time_travel_to_snapshot(head_id) 

2118 cls._current_head = None # Mark as at head (not time-traveling) 

2119 return result 

2120 

2121 @classmethod 

2122 def get_history_info(cls, filter_fn: Optional[Callable[[str], bool]] = None) -> List[Dict[str, Any]]: 

2123 """Get human-readable history for UI display. 

2124 

2125 Returns history for current branch, oldest first (index 0 = oldest, -1 = head). 

2126 

2127 Args: 

2128 filter_fn: Optional predicate function that takes a scope_id and returns True if the scope 

2129 should be included in visible state counts. If None, no filtering is applied. 

2130 Example: lambda scope_id: scope_id not in {"", "__plates__"} 

2131 """ 

2132 import datetime 

2133 history = cls.get_branch_history() 

2134 current_index = cls.get_current_snapshot_index() 

2135 head_index = len(history) - 1 

2136 

2137 result = [] 

2138 for i, snapshot in enumerate(history): 

2139 # Filter visible states based on caller-provided predicate 

2140 if filter_fn is not None: 

2141 visible_states = [scope_id for scope_id in snapshot.all_states.keys() 

2142 if filter_fn(scope_id)] 

2143 else: 

2144 visible_states = list(snapshot.all_states.keys()) 

2145 

2146 if not visible_states: 

2147 # Skip this snapshot - no visible states 

2148 continue 

2149 

2150 is_head = (i == head_index) 

2151 is_current = (current_index == -1 and is_head) or (i == current_index) 

2152 result.append({ 

2153 'index': i, 

2154 'id': snapshot.id, 

2155 'timestamp': datetime.datetime.fromtimestamp(snapshot.timestamp).strftime('%H:%M:%S.%f')[:-3], 

2156 'label': snapshot.label or f"Snapshot #{i}", 

2157 'is_current': is_current, 

2158 'is_head': is_head, 

2159 'num_states': len(visible_states), 

2160 'parent_id': snapshot.parent_id, 

2161 }) 

2162 return result 

2163 

2164 @classmethod 

2165 def get_history_length(cls) -> int: 

2166 """Get number of snapshots in current branch history.""" 

2167 return len(cls.get_branch_history()) 

2168 

2169 @classmethod 

2170 def is_time_traveling(cls) -> bool: 

2171 """Check if currently viewing historical state (not at head).""" 

2172 return cls._current_head is not None 

2173 

2174 @classmethod 

2175 def is_restoring_snapshot(cls) -> bool: 

2176 """Whether a snapshot restore is in progress, so views refresh restored values.""" 

2177 return cls._in_time_travel 

2178 

2179 # ========== BRANCH OPERATIONS ========== 

2180 

2181 @classmethod 

2182 def create_branch(cls, name: str, description: str = "") -> Timeline: 

2183 """Create a new branch at current position. 

2184 

2185 Args: 

2186 name: Branch name (must be unique) 

2187 description: Optional description 

2188 

2189 Returns: 

2190 The created Timeline 

2191 """ 

2192 # Branch from current position 

2193 if cls._current_head is not None: 

2194 # Time-traveling - branch from current position 

2195 head_id = cls._current_head 

2196 elif cls._current_timeline in cls._timelines: 

2197 # At head of current branch 

2198 head_id = cls._timelines[cls._current_timeline].head_id 

2199 else: 

2200 logger.error("⏱️ BRANCH: No snapshots to branch from") 

2201 raise ValueError("No snapshots to branch from") 

2202 

2203 timeline = Timeline( 

2204 name=name, 

2205 head_id=head_id, 

2206 base_id=head_id, 

2207 description=description, 

2208 ) 

2209 cls._timelines[name] = timeline 

2210 logger.info(f"⏱️ BRANCH: Created branch '{name}' at {head_id[:8]}") 

2211 return timeline 

2212 

2213 @classmethod 

2214 def switch_branch(cls, name: str) -> bool: 

2215 """Switch to a different branch. 

2216 

2217 Args: 

2218 name: Branch name 

2219 

2220 Returns: 

2221 True if switch succeeded 

2222 """ 

2223 timeline = cls._timelines[name] # KeyError if branch doesn't exist 

2224 

2225 # If we're back in time on current branch, preserve the old future as auto-branch 

2226 # before switching (same logic as diverge in record_snapshot) 

2227 if cls._current_head is not None and cls._current_timeline in cls._timelines: 

2228 old_head_id = cls._timelines[cls._current_timeline].head_id 

2229 if old_head_id != cls._current_head: 

2230 # There IS an old future to preserve 

2231 branch_name = f"auto-{old_head_id[:8]}" 

2232 if branch_name not in cls._timelines: 

2233 cls._timelines[branch_name] = Timeline( 

2234 name=branch_name, 

2235 head_id=old_head_id, 

2236 base_id=cls._current_head, 

2237 description=f"Auto-saved before switch from {cls._current_timeline} at {cls._current_head[:8]}", 

2238 ) 

2239 old_snapshot = cls._snapshots[old_head_id] 

2240 logger.info(f"⏱️ AUTO-BRANCH: Created '{branch_name}' before branch switch (was {old_snapshot.label})") 

2241 

2242 # Switch to branch and travel to its head 

2243 cls._current_timeline = name 

2244 result = cls.time_travel_to_snapshot(timeline.head_id) 

2245 cls._current_head = None # At head of new branch 

2246 cls._fire_history_changed_callbacks() 

2247 logger.info(f"⏱️ BRANCH: Switched to '{name}'") 

2248 return result 

2249 

2250 @classmethod 

2251 def delete_branch(cls, name: str) -> None: 

2252 """Delete a branch. 

2253 

2254 Args: 

2255 name: Branch name to delete 

2256 

2257 Note: Snapshots are not deleted - they may be reachable from other branches. 

2258 Unreachable snapshots are pruned automatically when DAG exceeds max size. 

2259 """ 

2260 del cls._timelines[name] # KeyError if branch doesn't exist 

2261 logger.info(f"⏱️ BRANCH: Deleted branch '{name}'") 

2262 cls._fire_history_changed_callbacks() 

2263 

2264 @classmethod 

2265 def list_branches(cls) -> List[Dict[str, Any]]: 

2266 """List all branches. 

2267 

2268 Returns: 

2269 List of dicts with branch info 

2270 """ 

2271 return [ 

2272 { 

2273 'name': tl.name, 

2274 'head_id': tl.head_id, 

2275 'base_id': tl.base_id, 

2276 'description': tl.description, 

2277 'is_current': tl.name == cls._current_timeline, 

2278 } 

2279 for tl in cls._timelines.values() 

2280 ] 

2281 

2282 @classmethod 

2283 def get_current_branch(cls) -> str: 

2284 """Get current branch name.""" 

2285 return cls._current_timeline 

2286 

2287 # ========== PERSISTENCE ========== 

2288 

2289 @classmethod 

2290 def export_history_to_dict(cls) -> Dict[str, Any]: 

2291 """Export the complete typed history document payload. 

2292 

2293 Returns: 

2294 Dict with 'snapshots', 'timelines', 'current_head', 'current_timeline'. 

2295 """ 

2296 return { 

2297 'format_version': 2, 

2298 'snapshots': {sid: snap.to_dict() for sid, snap in cls._snapshots.items()}, 

2299 'timelines': [tl.to_dict() for tl in cls._timelines.values()], 

2300 'current_head': cls._current_head, 

2301 'current_timeline': cls._current_timeline, 

2302 } 

2303 

2304 @classmethod 

2305 def import_history_from_dict(cls, data: dict[str, Any]) -> None: 

2306 """Import a complete typed history document payload. 

2307 

2308 Version 2 includes exact construction declarations. Older documents 

2309 cannot prove callable ownership and are rejected without mutation. 

2310 

2311 Args: 

2312 data: Dict with 'snapshots', 'timelines', 'current_head', 'current_timeline'. 

2313 """ 

2314 document = copy.deepcopy(data) 

2315 if document.get("format_version") != 2: 

2316 raise ValueError( 

2317 "Unsupported ObjectState history version; construction ownership is required." 

2318 ) 

2319 snapshots = { 

2320 sid: Snapshot.from_dict(payload) for sid, payload in document["snapshots"].items() 

2321 } 

2322 timelines = { 

2323 timeline.name: timeline for timeline in map(Timeline.from_dict, document["timelines"]) 

2324 } 

2325 for sid, snapshot in snapshots.items(): 

2326 if sid != snapshot.id or ( 

2327 snapshot.parent_id is not None and snapshot.parent_id not in snapshots 

2328 ): 

2329 raise ValueError(f"Invalid ObjectState snapshot ancestry at {sid!r}.") 

2330 for sid, snapshot in snapshots.items(): 

2331 ancestry = set() 

2332 ancestor = sid 

2333 while ancestor is not None: 

2334 if ancestor in ancestry: 

2335 raise ValueError(f"Cyclic ObjectState snapshot ancestry at {sid!r}.") 

2336 ancestry.add(ancestor) 

2337 ancestor = snapshots[ancestor].parent_id 

2338 cls._prepare_time_travel_states(snapshot) 

2339 for timeline in timelines.values(): 

2340 if timeline.head_id not in snapshots or timeline.base_id not in snapshots: 

2341 raise ValueError(f"Invalid ObjectState timeline {timeline.name!r}.") 

2342 if timelines and document["current_timeline"] not in timelines: 

2343 raise ValueError("Missing current ObjectState timeline.") 

2344 if document["current_head"] is not None and document["current_head"] not in snapshots: 

2345 raise ValueError("Missing current ObjectState snapshot.") 

2346 cls._snapshots = snapshots 

2347 cls._timelines = timelines 

2348 cls._current_timeline = document["current_timeline"] 

2349 cls._current_head = document["current_head"] 

2350 

2351 @classmethod 

2352 def save_history_to_file(cls, filepath: str) -> None: 

2353 """Save history as a type-preserving ObjectState document. 

2354 

2355 The document is intended for trusted local persistence. Dill preserves 

2356 arbitrary Python values and graph identity without a parallel type-tag 

2357 registry. 

2358 

2359 Args: 

2360 filepath: Path to the binary ObjectState history document. 

2361 """ 

2362 import dill 

2363 

2364 with open(filepath, "wb") as history_file: 

2365 dill.dump(cls.export_history_to_dict(), history_file) 

2366 logger.info(f"⏱️ Saved {len(cls._snapshots)} snapshots to {filepath}") 

2367 

2368 @classmethod 

2369 def load_history_from_file(cls, filepath: str) -> None: 

2370 """Load and materialize a trusted ObjectState history document. 

2371 

2372 Args: 

2373 filepath: Path to the binary ObjectState history document. 

2374 """ 

2375 import dill 

2376 

2377 previous_history = ( 

2378 cls._snapshots, 

2379 cls._timelines, 

2380 cls._current_timeline, 

2381 cls._current_head, 

2382 ) 

2383 try: 

2384 with open(filepath, "rb") as history_file: 

2385 cls.import_history_from_dict(dill.load(history_file)) 

2386 restored_at_head = cls._current_head is None 

2387 snapshot_id = cls._current_snapshot_id() 

2388 if snapshot_id is not None: 

2389 if not cls.time_travel_to_snapshot(snapshot_id): 

2390 raise RuntimeError( 

2391 "Loaded ObjectState history could not restore its current snapshot." 

2392 ) 

2393 if restored_at_head: 

2394 cls._current_head = None 

2395 except Exception: 

2396 cls._snapshots, cls._timelines, cls._current_timeline, cls._current_head = ( 

2397 previous_history 

2398 ) 

2399 raise 

2400 logger.info(f"⏱️ Loaded {len(cls._snapshots)} snapshots from {filepath}")