Coverage for src/objectstate/object_state.py: 86%

1167 statements  

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

1""" 

2ObjectState: Extracted MODEL from ParameterFormManager. 

3 

4This class holds configuration state independently of UI widgets. 

5Lifecycle: Created when object added to pipeline, persists until removed. 

6PFM attaches to ObjectState when editor opens, detaches when closed. 

7 

8FieldProxy: Type-safe proxy for accessing ObjectState fields via dotted attribute syntax. 

9""" 

10 

11from __future__ import annotations 

12 

13import copy 

14import logging 

15from collections.abc import Callable, Sequence 

16from dataclasses import fields as dataclass_fields 

17from dataclasses import is_dataclass 

18from types import FunctionType 

19from typing import Any, Optional 

20 

21from objectstate.field_access import DataclassFieldAccess, DottedFieldPath 

22from objectstate.construction_binding import ( 

23 DelegatedStateBinding, 

24 DirectStateBinding, 

25 StateConstructionBinding, 

26) 

27from objectstate.object_state_metadata import ( 

28 ObjectStateMetadataContract, 

29 ObjectStateMetadataContractRegistry, 

30 ObjectStateMetadataStore, 

31) 

32from objectstate.object_state_registry import ObjectStateRegistry 

33from objectstate.scope_key import ScopeKey 

34from objectstate.parameter_owner import ParameterOwner 

35from objectstate.parameter_structure import ParameterStructure 

36from objectstate.resolution_sources import ResolutionSources 

37from objectstate.lazy_factory import is_global_config_type 

38from objectstate.subfield_semantics import ( 

39 MISSING, 

40 ObjectStateSubfieldSemanticIndex, 

41 build_subfield_semantic_index, 

42 changed_structural_leaf_paths, 

43) 

44from objectstate.time_travel_profile import TimeTravelProfiler 

45from objectstate.value_semantics import semantic_values_equal 

46 

47logger = logging.getLogger(__name__) 

48 

49class FieldProxy: 

50 """Type-safe proxy for accessing ObjectState fields via dotted attribute syntax. 

51 

52 Provides IDE autocomplete while using flat internal storage: 

53 - External API: state.fields.partition_filter_config.partition_filter (type-safe) 

54 - Internal: state.parameters['partition_filter_config.partition_filter'] (flat dict) 

55 """ 

56 

57 def __init__(self, state: 'ObjectState', path: str, field_type: type): 

58 """Initialize FieldProxy. 

59 

60 Args: 

61 state: The ObjectState this proxy accesses 

62 path: Current dotted path (empty for root) 

63 field_type: Type of the object at this path 

64 """ 

65 object.__setattr__(self, '_state', state) 

66 object.__setattr__(self, '_path', path) 

67 object.__setattr__(self, '_type', field_type) 

68 

69 def __getattr__(self, name: str) -> Any: 

70 """Get field value or nested FieldProxy. 

71 

72 Args: 

73 name: Field name to access 

74 

75 Returns: 

76 FieldProxy for nested dataclass fields, or resolved value for leaf fields 

77 """ 

78 new_path = f'{self._path}.{name}' if self._path else name 

79 

80 # Get field info from the type 

81 if not is_dataclass(self._type): 

82 type_name = getattr(self._type, '__name__', str(self._type)) 

83 raise AttributeError(f"{type_name} is not a dataclass") 

84 

85 field_info = None 

86 for f in dataclass_fields(self._type): 

87 if f.name == name: 

88 field_info = f 

89 break 

90 

91 if field_info is None: 

92 type_name = getattr(self._type, '__name__', str(self._type)) 

93 raise AttributeError(f"{type_name} has no field '{name}'") 

94 

95 # Check if field is a nested dataclass 

96 field_type = field_info.type 

97 

98 # Handle Optional[DataclassType] 

99 from typing import Union, get_args, get_origin 

100 origin = get_origin(field_type) 

101 if origin is Union: 

102 args = get_args(field_type) 

103 if len(args) == 2 and type(None) in args: 

104 inner_type = next(arg for arg in args if arg is not type(None)) 

105 if is_dataclass(inner_type) and isinstance(inner_type, type): 

106 return FieldProxy(self._state, new_path, inner_type) 

107 

108 # Handle direct dataclass type 

109 if isinstance(field_type, type) and is_dataclass(field_type): 

110 return FieldProxy(self._state, new_path, field_type) 

111 

112 # Leaf field - get resolved value 

113 return self._state.get_resolved_value(new_path) 

114 

115 def __setattr__(self, name: str, value: Any) -> None: 

116 """Prevent attribute setting - use state.update_parameter() instead.""" 

117 _ = (name, value) # Suppress unused warnings 

118 raise AttributeError("FieldProxy is read-only. Use state.update_parameter(path, value) to set values.") 

119 

120 

121class ObjectState: 

122 """ 

123 UI-independent state for editing and resolving one backing object. 

124 

125 Lifecycle: 

126 - Created at the backing object's lifecycle boundary, before any editor. 

127 - Persists independently of windows that attach to it. 

128 - Unregistered when the backing object leaves its owning scope. 

129 

130 State ownership: 

131 - ``parameters`` is the flat mutable working copy. Nested dataclass fields 

132 use dotted paths and do not create recursive ObjectState instances. 

133 - ``_saved_parameters`` and ``_saved_resolved`` are the explicit saved 

134 baseline; ``_live_resolved`` and ``_live_provenance`` describe the 

135 current resolved view. 

136 - ``_parent_state`` links independently scoped states for context and 

137 notification. It is not structural ownership of a nested dataclass. 

138 - ``scope_id`` is the registry identity. 

139 

140 Dirty fields and signature differences are materialized from the raw and 

141 resolved snapshots. Structural leaf identities are read-only projections 

142 of the owning flat field, not additional writable state. 

143 """ 

144 

145 def __init__( 

146 self, 

147 object_instance: Any, 

148 scope_id: str | None = None, 

149 parent_state: Optional['ObjectState'] = None, 

150 exclude_params: list[str] | None = None, 

151 initial_values: dict[str, Any] | None = None, 

152 ): 

153 """ 

154 Initialize ObjectState with minimal attributes. 

155 

156 Args: 

157 object_instance: The object being edited (dataclass, callable, etc.) 

158 If the object declares __objectstate_delegate__, parameters 

159 are extracted from that attribute instead (delegation pattern). 

160 scope_id: Scope identifier for filtering (e.g., "/path::step_0") 

161 parent_state: Parent independently scoped ObjectState used for 

162 context and parent notification. 

163 exclude_params: Parameters to exclude from extraction. 

164 initial_values: Initial values to override extracted defaults (e.g., saved kwargs) 

165 """ 

166 # === Core State (3 attributes) === 

167 self.object_instance = object_instance 

168 # Use passed scope_id if provided, otherwise inherit from parent 

169 # Editors may pass explicit scope_id values for nested parameter scopes. 

170 # Nested dataclass configs may omit scope_id and inherit from parent 

171 self.scope_id = scope_id if scope_id is not None else (parent_state.scope_id if parent_state else None) 

172 

173 # === Delegation Support === 

174 # Check if object declares a delegate for parameter extraction. 

175 # This allows storing a lifecycle object (e.g., orchestrator) while 

176 # extracting editable parameters from a nested config (e.g., pipeline_config). 

177 delegate_attr = getattr(type(object_instance), '__objectstate_delegate__', None) 

178 if delegate_attr: 

179 self._extraction_target = getattr(object_instance, delegate_attr) 

180 self._delegate_attr = delegate_attr 

181 logger.debug(f"ObjectState delegation: extracting from '{delegate_attr}' attribute") 

182 else: 

183 self._extraction_target = object_instance 

184 self._delegate_attr = None 

185 

186 # Canonical declarations/topology, supplied by the extraction below. 

187 self.parameter_structure: ParameterStructure 

188 self._cached_object: Any | None = None # Cached result of to_object() 

189 self._cached_object_applied: bool = False # True if cached delegate was applied to object_instance 

190 

191 # UI / integration metadata (never participates in dirty detection). 

192 # Namespaced extension keys are contract-checked so time-travel cannot 

193 # silently snapshot or restore extension state it does not understand. 

194 self.metadata: ObjectStateMetadataStore = ObjectStateMetadataStore(self.scope_id) 

195 

196 # Time-travel navigation helpers (set by ObjectStateRegistry time travel) 

197 # Maps param_name -> (before, after) for the last time-travel transition. 

198 self._last_changed_values: dict[str, tuple[Any, Any]] = {} 

199 

200 # Maps metadata key -> (before, after) for the last time-travel transition. 

201 self._last_changed_meta_keys: set[str] = set() 

202 self._last_changed_meta_values: dict[str, tuple[Any, Any]] = {} 

203 

204 # Extract parameters using FLAT extraction (dotted paths) 

205 # This replaces the old UnifiedParameterAnalyzer + _create_nested_states() approach 

206 self.parameters: dict[str, Any] = {} 

207 extraction_target = self._extraction_target 

208 

209 # Flatten parameter extraction - walk nested dataclasses recursively. 

210 # Uses _extraction_target (delegate) instead of object_instance for 

211 # delegation support. The structure and its immutable path index are 

212 # replaced atomically once for this extraction. 

213 self._replace_parameter_structure( 

214 extraction_target, 

215 exclude_params=exclude_params, 

216 initial_values=initial_values, 

217 ) 

218 

219 # === Structure (1 attribute) === 

220 self._parent_state: ObjectState | None = parent_state 

221 # NOTE: nested_states DELETED - flat storage eliminates nested ObjectState instances 

222 

223 # === Cache (3 attributes) === 

224 self._live_resolved: dict[str, Any] | None = None # None = needs full compute 

225 self._invalid_fields: set[str] = set() # Fields needing partial recompute 

226 # Maps dotted_path → (source_scope_id, source_type) for inherited fields 

227 # source_type may differ from local container_type due to MRO inheritance 

228 self._live_provenance: dict[str, tuple[str | None, type | None]] = {} 

229 # What each resolved snapshot was computed from, keyed by ``use_saved``. A 

230 # snapshot is current only while ``_resolution_sources()`` still returns the 

231 # same objects; a snapshot without an entry is resolved again in full. 

232 self._resolved_from: dict[bool, ResolutionSources] = {} 

233 

234 # === Saved baseline (2 attributes) === 

235 self._saved_resolved: dict[str, Any] = {} 

236 self._saved_parameters: dict[str, Any] = {} # Immutable snapshot for diff on restore 

237 

238 # === Materialized diffs (3 attributes) === 

239 self._raw_dirty = False 

240 self._dirty_fields: set[str] = set() 

241 self._signature_diff_fields: set[str] = set() 

242 

243 # === Change tracking for navigation (2 attributes) === 

244 # Track which field most recently changed VALUE (not just dirty status) 

245 # Used for time-travel navigation to scroll to what changed in a transition 

246 self._last_changed_field: str | None = None 

247 self._last_changed_concrete_paths: set[str] = set() 

248 self._last_changed_paths: set[str] = set() 

249 

250 # === Flags (kept for batch operations) === 

251 self._in_reset = False 

252 self._block_cross_window_updates = False 

253 self._forwarding_to_parent = False 

254 self._parent_field_name: str | None = None 

255 

256 # === State Change Callbacks === 

257 # Callbacks notified when materialized state changes (dirty/signature diffs) 

258 self._on_state_changed_callbacks: list[Callable[[set[str]], None]] = [] 

259 

260 # === Resolved Change Callbacks === 

261 # Callbacks notified when resolved values actually change (for UI flashing) 

262 self._on_resolved_changed_callbacks: list[Callable[[set[str]], None]] = [] 

263 

264 # === Time-Travel Callbacks === 

265 # Callbacks notified when time-travel restores parameters (for widget refresh) 

266 self._on_time_travel_callbacks: list[Callable[[], None]] = [] 

267 

268 # === Time-Travel === 

269 # Instance-level time-travel removed - use ObjectStateRegistry class-level DAG instead 

270 

271 # Initialize baselines (nothing to tell anyone during init) 

272 self._live_resolved = self._compute_resolved_snapshot() 

273 

274 # CRITICAL: Initialize _saved_parameters BEFORE _compute_resolved_snapshot(use_saved=True) 

275 # because that method reads from _saved_parameters to get raw values. 

276 self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) 

277 

278 # CRITICAL: Compute saved_resolved using SAVED ancestor context, not LIVE. 

279 # This ensures saved baseline represents "what would this object's values be 

280 # if all ancestors were at their saved state", NOT "what are values right now 

281 # with ancestor's unsaved edits baked in". 

282 # 

283 # If we used copy.deepcopy(_live_resolved), we'd bake in ancestor's unsaved 

284 # edits, causing inverted dirty state when ancestor is saved/reset. 

285 self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) 

286 

287 # DEBUG: Log live vs saved at registration 

288 logger.debug(f"🔵 INIT_RESOLVED: scope={self.scope_id!r} obj_type={type(self.object_instance).__name__}") 

289 for k in sorted(self._live_resolved.keys()): 

290 live_val = self._live_resolved.get(k) 

291 saved_val = self._saved_resolved.get(k) 

292 if live_val != saved_val: 

293 logger.debug(f"🔵 INIT_DIFF: {k!r} live={live_val!r} saved={saved_val!r}") 

294 

295 # Materialize initial diff sets (no notification during init) 

296 # Should be empty for new objects since saved = live 

297 self._dirty_fields = self._compute_dirty_fields() 

298 self._signature_diff_fields = self._compute_signature_diff_fields() 

299 

300 # NOTE: Don't record "init" snapshot here - each ObjectState would create a separate 

301 # snapshot missing other ObjectStates created later. Instead, the first edit will 

302 # record the baseline state automatically (see record_snapshot logic). 

303 

304 def set_extension_metadata( 

305 self, 

306 contract: ObjectStateMetadataContract, 

307 value: Any, 

308 ) -> None: 

309 """Write extension metadata through a registered time-travel contract.""" 

310 

311 ObjectStateMetadataContractRegistry.register(contract) 

312 self.metadata[contract.key] = value 

313 

314 def validate_metadata_contracts(self, operation: str) -> None: 

315 """Raise if ObjectState metadata contains unregistered extension keys.""" 

316 

317 ObjectStateMetadataContractRegistry.validate_mapping( 

318 scope_id=self.scope_id, 

319 metadata=self.metadata, 

320 operation=operation, 

321 ) 

322 

323 def copy_metadata_for_snapshot(self) -> dict[str, Any]: 

324 """Return validated metadata payload for StateSnapshot.""" 

325 

326 return self.metadata.copy_for_snapshot() 

327 

328 @property 

329 def context_obj(self) -> Any | None: 

330 """Derive context_obj from parent_state (no separate attribute needed).""" 

331 return self._parent_state.object_instance if self._parent_state else None 

332 

333 def _check_and_sync_delegate(self) -> bool: 

334 """Check if delegate attribute has changed and sync extraction target if needed. 

335 

336 This implements auto-detection of delegate changes (Option 3 from architectural discussion). 

337 When object_instance's delegate attribute is replaced with a new instance (e.g., after 

338 code that rebuilds the config), this method detects the change and 

339 automatically re-extracts parameters from the new delegate. 

340 

341 Returns: 

342 True if delegate was detected as changed and re-extraction occurred, False otherwise. 

343 """ 

344 if self._delegate_attr is None: 

345 # Not using delegation - nothing to check 

346 return False 

347 

348 try: 

349 current_delegate = object.__getattribute__(self.object_instance, self._delegate_attr) 

350 except AttributeError: 

351 # Delegate attribute no longer exists - this is unexpected but handle gracefully 

352 logger.warning( 

353 f"Delegate attribute '{self._delegate_attr}' no longer exists on " 

354 f"{type(self.object_instance).__name__}. Keeping current extraction target." 

355 ) 

356 return False 

357 

358 # Use identity check (is) not equality (==) to detect if it's a new instance 

359 if current_delegate is self._extraction_target: 

360 # Delegate hasn't changed - no sync needed 

361 return False 

362 

363 # Delegate has changed to a new instance - sync extraction target and re-extract 

364 logger.debug( 

365 f"Auto-detected delegate change for ObjectState(scope={self.scope_id!r}): " 

366 f"'{self._delegate_attr}' attribute was replaced with new instance. Re-extracting parameters." 

367 ) 

368 

369 self.update_object_instance(current_delegate) 

370 return True 

371 

372 @property 

373 def has_delegate(self) -> bool: 

374 """Return whether this state extracts parameters through an object delegate.""" 

375 

376 return self._delegate_attr is not None 

377 

378 @classmethod 

379 def _copy_parameters_for_saved_baseline( 

380 cls, parameters: dict[str, Any] 

381 ) -> dict[str, Any]: 

382 """Snapshot parameter values without cloning callable identities. 

383 

384 Callables can be semantic identities in object declarations. A plain 

385 deepcopy can rebuild callable wrapper instances through ``__reduce__``, 

386 making live and saved function specs unequal immediately after load. 

387 """ 

388 memo: dict[int, Any] = {} 

389 cls._seed_callable_identity_memo(parameters, memo, set()) 

390 return copy.deepcopy(parameters, memo) 

391 

392 @classmethod 

393 def copy_parameter_pair_preserving_callable_identity( 

394 cls, 

395 parameters: dict[str, Any], 

396 saved_parameters: dict[str, Any], 

397 ) -> tuple[dict[str, Any], dict[str, Any]]: 

398 """Copy live/saved raw parameters as one identity-preserving pair. 

399 

400 Time-travel snapshots must preserve callable identity relationships across 

401 `parameters` and `_saved_parameters`. Copying the two dicts independently 

402 can rebuild callable wrappers twice and make a clean state appear dirty. 

403 """ 

404 

405 memo: dict[int, Any] = {} 

406 seen: set[int] = set() 

407 cls._seed_callable_identity_memo(parameters, memo, seen) 

408 cls._seed_callable_identity_memo(saved_parameters, memo, seen) 

409 return copy.deepcopy(parameters, memo), copy.deepcopy(saved_parameters, memo) 

410 

411 @classmethod 

412 def _seed_callable_identity_memo( 

413 cls, 

414 value: Any, 

415 memo: dict[int, Any], 

416 seen: set[int], 

417 ) -> None: 

418 value_id = id(value) 

419 if value_id in seen: 

420 return 

421 seen.add(value_id) 

422 

423 if callable(value): 

424 memo[value_id] = value 

425 return 

426 

427 if isinstance(value, dict): 

428 for key, item in value.items(): 

429 cls._seed_callable_identity_memo(key, memo, seen) 

430 cls._seed_callable_identity_memo(item, memo, seen) 

431 return 

432 

433 if isinstance(value, (list, tuple, set, frozenset)): 

434 for item in value: 

435 cls._seed_callable_identity_memo(item, memo, seen) 

436 return 

437 

438 if is_dataclass(value) and not isinstance(value, type): 

439 for field in dataclass_fields(value): 

440 cls._seed_callable_identity_memo( 

441 object.__getattribute__(value, field.name), 

442 memo, 

443 seen, 

444 ) 

445 

446 @property 

447 def saved_object(self) -> Any: 

448 """Get the saved baseline object with the correct type. 

449 

450 For delegation: returns _extraction_target (the delegate/config) 

451 For non-delegation: returns object_instance 

452 

453 This is the object that should be used for context resolution when 

454 use_saved=True. It represents the "saved" state of the editable object. 

455 """ 

456 # Auto-detect delegate changes before returning extraction target 

457 self._check_and_sync_delegate() 

458 return self._extraction_target 

459 

460 @property 

461 def fields(self) -> FieldProxy: 

462 """Type-safe field access via FieldProxy. 

463 

464 Returns: 

465 FieldProxy for accessing fields with IDE autocomplete: 

466 state.fields.partition_filter_config.partition_filter → resolved value 

467 """ 

468 return FieldProxy(self, '', type(self.object_instance)) 

469 

470 def to_resolved_object(self) -> Any: 

471 """Reconstruct this state as an object with live resolved values.""" 

472 self._check_and_sync_delegate() 

473 self._ensure_live_resolved() 

474 assert self._live_resolved is not None 

475 return self._reconstruct_from_resolved( 

476 "", self._live_resolved, self._extraction_target, self.parameter_structure 

477 ) 

478 

479 def to_saved_resolved_object(self) -> Any: 

480 """Reconstruct this state as an object with saved resolved values.""" 

481 self._check_and_sync_delegate() 

482 self._ensure_saved_resolved() 

483 return self._reconstruct_from_resolved( 

484 "", self._saved_resolved, self._extraction_target, self.parameter_structure 

485 ) 

486 

487 @classmethod 

488 def resolve_saved_object( 

489 cls, 

490 object_instance: Any, 

491 *, 

492 scope_id: str | None = None, 

493 ancestor_objects_with_scopes: Sequence[tuple[str, Any]] = (), 

494 ) -> tuple[Any, dict[str, tuple[str | None, type | None]]]: 

495 """Resolve a submitted saved declaration without creating an editing state. 

496 

497 Ancestors are explicit saved declarations ordered least to most specific. 

498 The saved global context, dual-axis inheritance, raw field extraction and 

499 nominal reconstruction are the same operations used by editing states. 

500 Returned provenance describes this same saved epoch, not live UI edits. 

501 Mutable resolved values are detached together, retaining shared aliases 

502 and callable identities. No state is registered or editor initialized. 

503 """ 

504 delegate_attr = getattr(type(object_instance), "__objectstate_delegate__", None) 

505 target = ( 

506 getattr(object_instance, delegate_attr) if delegate_attr else object_instance 

507 ) 

508 structure = ParameterStructure.for_target(target, None) 

509 parameters: dict[str, Any] = {} 

510 cls._extract_all_parameters_flat(target, parameters, structure) 

511 saved_parameters = cls._copy_parameters_for_saved_baseline(parameters) 

512 snapshot, provenance = cls._resolve_parameter_snapshot( 

513 saved_parameters, 

514 saved_parameters, 

515 structure, 

516 target, 

517 scope_id, 

518 ancestor_objects_with_scopes, 

519 use_saved=True, 

520 ) 

521 # A compilation capture owns inherited mutable values too. The editor's 

522 # saved-object API intentionally keeps its existing ancestor lifetime. 

523 snapshot = cls._copy_parameters_for_saved_baseline(snapshot) 

524 concrete = cls._reconstruct_from_resolved("", snapshot, target, structure) 

525 return concrete, provenance 

526 

527 @property 

528 def parameter_descriptions(self) -> dict[str, str | None]: 

529 """Get parameter descriptions for all parameters. 

530 

531 Returns: 

532 Dictionary mapping dotted parameter paths to their descriptions (value may be None). 

533 E.g., {'partition_filter_config.partition_filter': 'Filter partitions by...'} 

534 """ 

535 return self.parameter_structure.descriptions 

536 

537 def type_for_path(self, field_path: str) -> ParameterOwner: 

538 """Return the ObjectState-recorded type for a dotted field path. 

539 

540 Leaf paths return the dataclass/function owner that declares the 

541 field. Nested dataclass paths return the nested dataclass type itself. 

542 """ 

543 if field_path == "": 

544 return type(self._extraction_target) 

545 try: 

546 return self.parameter_structure.owner_paths[field_path] 

547 except KeyError as exc: 

548 raise KeyError( 

549 f"ObjectState path is not registered: {field_path!r}" 

550 ) from exc 

551 

552 def direct_parameter_paths( 

553 self, 

554 field_path: str = "", 

555 ) -> tuple[DottedFieldPath, ...]: 

556 """Return immutable direct-child paths for one flat parameter scope. 

557 

558 The path topology is rebuilt when ObjectState extracts a replacement 

559 object. Values remain authoritative in ``parameters`` and are therefore 

560 never cached in this projection. 

561 """ 

562 

563 return self.parameter_structure.direct_paths.get(DottedFieldPath(field_path), ()) 

564 

565 def has_parameter_descendants(self, field_path: str) -> bool: 

566 """Return whether an authoritative flat parameter owns child paths.""" 

567 

568 return bool(self.direct_parameter_paths(field_path)) 

569 

570 # === Resolved Change Subscription === 

571 

572 def on_resolved_changed(self, callback: Callable[[set[str]], None]) -> None: 

573 """Subscribe to resolved value change notifications. 

574 

575 The callback is called when resolved values actually change (not just when 

576 cache is invalidated). This enables UI components to flash/highlight 

577 specific fields when their resolved values change. 

578 

579 Args: 

580 callback: Function that takes a Set[str] of changed dotted paths. 

581 E.g., {'processing_config.group_by', 

582 'partition_filter_config.partition_filter'} 

583 """ 

584 if callback not in self._on_resolved_changed_callbacks: 

585 self._on_resolved_changed_callbacks.append(callback) 

586 

587 def off_resolved_changed(self, callback: Callable[[set[str]], None]) -> None: 

588 """Unsubscribe from resolved value change notifications.""" 

589 if callback in self._on_resolved_changed_callbacks: 

590 self._on_resolved_changed_callbacks.remove(callback) 

591 

592 def on_state_changed(self, callback: Callable[[set[str]], None]) -> None: 

593 """Subscribe to materialized state change notifications (dirty/signature diffs).""" 

594 if callback not in self._on_state_changed_callbacks: 

595 self._on_state_changed_callbacks.append(callback) 

596 

597 def off_state_changed(self, callback: Callable[[set[str]], None]) -> None: 

598 """Unsubscribe from materialized state change notifications.""" 

599 if callback in self._on_state_changed_callbacks: 

600 self._on_state_changed_callbacks.remove(callback) 

601 

602 def _notify_state_changed(self, changed_paths: set[str]) -> None: 

603 """Fire state change callbacks (best-effort).""" 

604 for callback in list(self._on_state_changed_callbacks): 

605 try: 

606 callback(changed_paths) 

607 except Exception as e: 

608 logger.warning(f"Error in state_changed callback: {e}") 

609 

610 def _notify_resolved_changed( 

611 self, 

612 changed_paths: set[str], 

613 *, 

614 context: str, 

615 ) -> None: 

616 """Publish resolved-value changes to registry and local subscribers.""" 

617 if not changed_paths: 

618 return 

619 

620 with TimeTravelProfiler.phase( 

621 "objectstate.notify_resolved_emit", 

622 scope=self.scope_id, 

623 context=context, 

624 changed_paths=len(changed_paths), 

625 callbacks=len(self._on_resolved_changed_callbacks), 

626 ): 

627 ObjectStateRegistry.notify_resolved_changed(self.scope_id, changed_paths) 

628 if not self._on_resolved_changed_callbacks: 

629 return 

630 

631 logger.debug( 

632 "🔔 CALLBACK_LEAK_DEBUG: Notifying %d callbacks for scope=%s, " 

633 "context=%s, changed_paths=%s", 

634 len(self._on_resolved_changed_callbacks), 

635 self.scope_id, 

636 context, 

637 changed_paths, 

638 ) 

639 for i, callback in enumerate(list(self._on_resolved_changed_callbacks)): 

640 try: 

641 with TimeTravelProfiler.phase( 

642 "objectstate.notify_resolved_callback", 

643 scope=self.scope_id, 

644 context=context, 

645 callback_index=i, 

646 callback_count=len(self._on_resolved_changed_callbacks), 

647 changed_paths=len(changed_paths), 

648 ): 

649 callback(changed_paths) 

650 except RuntimeError as e: 

651 logger.warning( 

652 "🔴 CALLBACK_LEAK_DEBUG: Dead callback #%d detected! " 

653 "scope=%s, context=%s, error: %s", 

654 i, 

655 self.scope_id, 

656 context, 

657 e, 

658 ) 

659 except Exception as e: 

660 logger.warning( 

661 "Error in resolved_changed callback #%d during %s: %s", 

662 i, 

663 context, 

664 e, 

665 ) 

666 

667 def forward_to_parent_state(self, field_path: str | None = None) -> None: 

668 """Forward child state changes to parent state. 

669 

670 Notifies the parent state that a field has conceptually changed, causing 

671 the parent's on_resolved_changed callbacks to fire (e.g., for UI flash). 

672 

673 Args: 

674 field_path: Dotted path of the field that changed (e.g., 'config.value'). 

675 If None, uses _parent_field_name if set, otherwise defaults to scope suffix. 

676 

677 Example: 

678 # In child ObjectState callback 

679 def on_child_changed(changed_paths): 

680 child_state.forward_to_parent_state('config.value') 

681 

682 Raises: 

683 RuntimeError: If called on a state without a parent state. 

684 """ 

685 if self._parent_state is None: 

686 raise RuntimeError( 

687 f"Cannot forward to parent: state '{self.scope_id}' has no parent state" 

688 ) 

689 

690 # Reentrancy guard (pattern from ObjectState architectural fixes) 

691 if self._forwarding_to_parent: 

692 logger.debug(f"[ObjectState] Reentrancy guard blocked for {self.scope_id}") 

693 return 

694 

695 self._forwarding_to_parent = True 

696 try: 

697 # Determine which parent field changed 

698 # Priority: explicit field_path arg > _parent_field_name > auto-detect 

699 parent_field = field_path or self._parent_field_name 

700 if parent_field is None: 

701 # Auto-detect from a numeric scope suffix. 

702 scope = ScopeKey(self.scope_id) 

703 if scope.depth >= 2: 

704 last = scope.last 

705 import re 

706 m = re.match(r'^(.+?)_\d+$', last) 

707 parent_field = m.group(1) if m else last 

708 

709 if parent_field: 

710 self._parent_state._notify_resolved_changed( 

711 {parent_field}, 

712 context="forward_to_parent_state", 

713 ) 

714 

715 logger.debug( 

716 "[ObjectState] Forwarded %s to parent, field=%r", 

717 self.scope_id, 

718 parent_field, 

719 ) 

720 

721 finally: 

722 self._forwarding_to_parent = False 

723 

724 def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]: 

725 """Bring both resolved snapshots up to date; return the changed live paths. 

726 

727 Two things make a field out of date: it was invalidated on this state 

728 (``_invalid_fields``), or the snapshot was computed from other sources than 

729 the state has now (``_fields_with_other_sources``): an ancestor registered, 

730 unregistered or replaced, an ancestor's value edited, saved or restored, the 

731 global config replaced. Both are found here, on every resolved read, so no 

732 caller has to announce a change and a state that is not registered follows 

733 its ancestors like a registered one. Only the fields that can be affected are 

734 resolved again; when nothing changed the cost is the comparison of the 

735 sources. 

736 

737 Args: 

738 notify_flash: True (reads): when anything was resolved again, dirty and 

739 signature fields are updated and subscribers are told here. False: 

740 the caller does that with the returned paths 

741 (``_sync_materialized_state``). 

742 """ 

743 if self._live_resolved is None: 

744 # First access - populate cache; there is no earlier value to compare with. 

745 self._live_resolved = self._compute_resolved_snapshot() 

746 self._invalid_fields.clear() 

747 return set() 

748 

749 sources, saved_sources = self._resolution_sources() 

750 saved_resolved_again = self._resolve_saved_again_if_sources_changed(saved_sources) 

751 if saved_resolved_again: 

752 sources, _ = self._resolution_sources() 

753 

754 stale = self._fields_with_other_sources(sources, use_saved=False) 

755 if stale is None: 

756 # Other states are registered above this one: every inherited field may 

757 # have another source. Pending field invalidations are covered. 

758 previous = self._live_resolved 

759 self._live_resolved = self._compute_resolved_snapshot() 

760 self._invalid_fields.clear() 

761 changed_values = { 

762 path: (previous.get(path), value) 

763 for path, value in self._live_resolved.items() 

764 if previous.get(path) != value 

765 } 

766 if changed_values: 

767 self._set_last_changed_values(changed_values) 

768 changed_paths = self.notification_fields_for_value_changes(changed_values) 

769 live_resolved_again = True 

770 else: 

771 self._invalid_fields.update(stale) 

772 live_resolved_again = bool(self._invalid_fields) 

773 if live_resolved_again: 

774 changed_paths = self._recompute_invalid_fields(sources) 

775 self._invalid_fields.clear() 

776 else: 

777 changed_paths = set() 

778 self._resolved_from[False] = sources 

779 

780 if notify_flash and (live_resolved_again or saved_resolved_again): 

781 self._sync_materialized_state(changed_value_fields=changed_paths) 

782 

783 return changed_paths 

784 

785 def _resolution_sources( 

786 self, *, sync_delegates: bool = False 

787 ) -> tuple[ResolutionSources, ResolutionSources]: 

788 """What this state's fields are resolved from today, live then saved (so the 

789 pair is indexed by ``use_saved``): the global config and the objects of the 

790 other states registered above. 

791 

792 This state's own object is left out (its own parameters are not an outside 

793 source), and so are global config objects of ancestors, for which 

794 ``build_context_stack`` enters the global config itself. 

795 """ 

796 from objectstate.config import get_base_config_type 

797 from objectstate.global_config import get_live_global_config, get_saved_global_config 

798 

799 live: list[tuple[str, Any]] = [] 

800 saved: list[tuple[str, Any]] = [] 

801 for scope, state in ObjectStateRegistry.registered_ancestors(self.scope_id): 

802 if state is self: 

803 continue 

804 saved_object = state.object_for_resolution(use_saved=True) 

805 if is_global_config_type(type(saved_object)): 

806 continue 

807 saved.append((scope, saved_object)) 

808 live.append( 

809 (scope, state.object_for_resolution(sync_delegate=sync_delegates)) 

810 ) 

811 global_config_type = get_base_config_type() 

812 return ( 

813 ResolutionSources(get_live_global_config(global_config_type), tuple(live)), 

814 ResolutionSources(get_saved_global_config(global_config_type), tuple(saved)), 

815 ) 

816 

817 def object_for_resolution(self, use_saved: bool = False, sync_delegate: bool = False) -> Any: 

818 """The object a state below this one enters as a context layer. 

819 

820 For the state of a global config these two objects are the live and the saved 

821 global config (``objectstate.global_config``). 

822 

823 Saved: the object as last saved, without delegate sync or reconstruction. 

824 Live: rebuilt from the current parameters when they changed since the last 

825 call; ``sync_delegate`` decides whether a replaced delegate is taken over 

826 first, which must not happen while a snapshot is being recomputed. 

827 """ 

828 if use_saved: 

829 return self._extraction_target 

830 if self._cached_object is not None and not sync_delegate: 

831 return self._cached_object 

832 return self.to_object(update_delegate=False, sync_delegate=sync_delegate) 

833 

834 def _fields_with_other_sources( 

835 self, sources: ResolutionSources, *, use_saved: bool 

836 ) -> set[str] | None: 

837 """Fields of one snapshot that ``sources`` may resolve differently. 

838 

839 These are the fields this state leaves unset whose config type inherits a 

840 ``(config type, field)`` that an ancestor or the global config stores 

841 differently than when the snapshot was computed. A field this state sets 

842 itself has no outside source. ``None`` means every field. 

843 """ 

844 resolved_from = self._resolved_from.get(use_saved) 

845 if sources.same_objects_as(resolved_from): 

846 return set() 

847 differing = sources.fields_that_differ(resolved_from) 

848 if differing is None: 

849 return None 

850 own_values = self._saved_parameters if use_saved else self.parameters 

851 return { 

852 path 

853 for config_type, field_name in differing 

854 for path in self.inheritance_field_paths(config_type, field_name) 

855 if own_values.get(path) is None 

856 } 

857 

858 def _resolve_saved_again_if_sources_changed(self, sources: ResolutionSources) -> bool: 

859 """Resolve the saved snapshot again when its sources changed; say whether it was. 

860 

861 The saved sources change when an ancestor is saved, restored or replaced and 

862 when the saved global config is replaced, not while values are edited, so the 

863 snapshot is resolved in full. 

864 """ 

865 stale = self._fields_with_other_sources(sources, use_saved=True) 

866 if stale is not None and not stale: 

867 self._resolved_from[True] = sources 

868 return False 

869 self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) 

870 return True 

871 

872 def resolve_again_if_sources_changed(self) -> None: 

873 """Resolve again now, and tell subscribers, if this state's sources changed. 

874 

875 Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it 

876 when registration changes so subscribers are told at once. 

877 """ 

878 if self._live_resolved is not None: 

879 self._ensure_live_resolved() 

880 

881 def _ensure_saved_resolved(self) -> None: 

882 """Make ``_saved_resolved`` hold every field, resolved from today's sources.""" 

883 if self._live_resolved is not None: 

884 self._ensure_live_resolved() 

885 elif not self._saved_resolved: 

886 self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) 

887 

888 # DELETED: _create_nested_states() - No longer needed with flat storage 

889 # Nested ObjectStates are no longer created - flat storage handles all parameters 

890 

891 @staticmethod 

892 def _analyze_parameters( 

893 obj: Any, 

894 exclude_params: list[str] | None = None, 

895 ) -> dict[str, Any]: 

896 """Analyze object parameters through the canonical introspection owner. 

897 

898 Returns dict mapping param_name -> info object with .param_type, .default_value, and .description attributes. 

899 """ 

900 # NOTE: python_introspect is a required dependency; fail loud if missing. 

901 # UnifiedParameterAnalyzer is responsible for correctness guarantees: 

902 # - defaults come from type/signature (never from instance) 

903 # - current instance values are accessed in a lazy-safe way (if needed) 

904 from python_introspect import UnifiedParameterAnalyzer 

905 

906 return UnifiedParameterAnalyzer.analyze(obj, exclude_params=exclude_params) 

907 

908 @staticmethod 

909 def _get_nested_dataclass_type(param_type: Any) -> type | None: 

910 """Get the nested dataclass type if param_type is a nested dataclass. 

911 

912 Args: 

913 param_type: The parameter type to check 

914 

915 Returns: 

916 The dataclass type if nested, None otherwise 

917 """ 

918 from typing import Union, get_args, get_origin 

919 

920 # Check Optional[dataclass] 

921 origin = get_origin(param_type) 

922 if origin is Union: 

923 args = get_args(param_type) 

924 if len(args) == 2 and type(None) in args: 

925 inner_type = next(arg for arg in args if arg is not type(None)) 

926 if is_dataclass(inner_type): 

927 return inner_type 

928 

929 # Check direct dataclass (but not the type itself) 

930 if is_dataclass(param_type) and not isinstance(param_type, type): 

931 # param_type is an instance, not a type - shouldn't happen but handle it 

932 return type(param_type) 

933 

934 if is_dataclass(param_type): 

935 return param_type 

936 

937 return None 

938 

939 def reset_all_parameters(self) -> None: 

940 """Reset all parameters to defaults.""" 

941 self._in_reset = True 

942 try: 

943 for param_name in list(self.parameters.keys()): 

944 self.reset_parameter(param_name) 

945 finally: 

946 self._in_reset = False 

947 

948 def _dataclass_parameter_updates(self, prefix: str, value: Any) -> dict[str, Any]: 

949 """Flatten a dataclass container update into registered child parameters.""" 

950 if value is None or not is_dataclass(type(value)): 

951 return {} 

952 

953 prefix_dot = f"{prefix}." 

954 if not any( 

955 path.startswith(prefix_dot) 

956 for path in self.parameters 

957 if path != prefix 

958 ): 

959 return {} 

960 

961 updates: dict[str, Any] = {} 

962 for field in dataclass_fields(value): 

963 field_path = f"{prefix_dot}{field.name}" 

964 field_value = DataclassFieldAccess.raw_value(value, field.name) 

965 if field_path in self.parameters: 

966 updates[field_path] = field_value 

967 updates.update(self._dataclass_parameter_updates(field_path, field_value)) 

968 return updates 

969 

970 def _dataclass_ancestor_parameter_updates( 

971 self, 

972 param_name: str, 

973 parameters: dict[str, Any], 

974 ) -> dict[str, Any]: 

975 """Rebuild dataclass container parameters that own a changed child path.""" 

976 

977 updates: dict[str, Any] = {} 

978 for path in DottedFieldPath(param_name).parent.lineage: 

979 prefix = path.value 

980 if prefix not in parameters: 

981 continue 

982 prefix_type = self.parameter_structure.owner_paths.get(prefix) 

983 if not (isinstance(prefix_type, type) and is_dataclass(prefix_type)): 

984 continue 

985 

986 rebuilt_value = self._reconstruct_from_parameter_snapshot( 

987 prefix, 

988 parameters, 

989 ) 

990 if parameters.get(prefix) != rebuilt_value: 

991 updates[prefix] = rebuilt_value 

992 parameters[prefix] = rebuilt_value 

993 return updates 

994 

995 

996 def inheritance_field_paths( 

997 self, 

998 target_type: ParameterOwner, 

999 field_name: str, 

1000 ) -> tuple[str, ...]: 

1001 """Return flat field paths whose container type can inherit target_type.""" 

1002 

1003 from objectstate.lazy_factory import get_base_type_for_lazy 

1004 

1005 if isinstance(target_type, type): 

1006 target_base_type = get_base_type_for_lazy(target_type) or target_type 

1007 else: 

1008 target_base_type = target_type 

1009 

1010 paths: list[str] = [] 

1011 for dotted_path, container_type in self.parameter_structure.owner_paths.items(): 

1012 if dotted_path not in self.parameters: 

1013 continue 

1014 if not (dotted_path.endswith(f".{field_name}") or dotted_path == field_name): 

1015 continue 

1016 

1017 if isinstance(container_type, type): 

1018 container_base_type = ( 

1019 get_base_type_for_lazy(container_type) or container_type 

1020 ) 

1021 else: 

1022 container_base_type = container_type 

1023 

1024 type_matches = container_base_type == target_base_type 

1025 if ( 

1026 not type_matches 

1027 and isinstance(container_base_type, type) 

1028 and isinstance(target_base_type, type) 

1029 ): 

1030 type_matches = any( 

1031 (get_base_type_for_lazy(mro_class) or mro_class) 

1032 == target_base_type 

1033 for mro_class in container_base_type.__mro__ 

1034 ) 

1035 

1036 if type_matches: 

1037 paths.append(dotted_path) 

1038 

1039 return tuple(paths) 

1040 

1041 def update_parameter(self, param_name: str, value: Any) -> set[str]: 

1042 """Update parameter value in state. 

1043 

1044 Enforces invariants: 

1045 1. State mutation → scope+type+field aware cache invalidation 

1046 2. State mutation → global token increment (for live context cache) 

1047 

1048 PERFORMANCE: Three-tier filtering for minimal invalidation: 

1049 - SCOPE: Only descendants of this scope (they inherit from us) 

1050 - TYPE: Only states with this type in their tree 

1051 - FIELD: Only the specific field that changed 

1052 

1053 Args: 

1054 param_name: Name of parameter to update 

1055 value: New value 

1056 

1057 Returns: 

1058 Resolved field paths whose visible values changed. 

1059 """ 

1060 # Auto-detect delegate changes before parameter access 

1061 self._check_and_sync_delegate() 

1062 

1063 if param_name not in self.parameters: 

1064 logger.warning( 

1065 f"⚠️ update_parameter({param_name!r}) called on ObjectState(scope={self.scope_id!r}) " 

1066 f"but parameter does not exist. Available: {list(self.parameters.keys())[:5]}..." 

1067 ) 

1068 return set() 

1069 

1070 child_updates = self._dataclass_parameter_updates(param_name, value) 

1071 pending_parameters = dict(self.parameters) 

1072 pending_parameters[param_name] = value 

1073 for child_path, child_value in child_updates.items(): 

1074 pending_parameters[child_path] = child_value 

1075 ancestor_updates = self._dataclass_ancestor_parameter_updates( 

1076 param_name, 

1077 pending_parameters, 

1078 ) 

1079 

1080 # EARLY EXIT: No change, no invalidation, no flash 

1081 current_value = self.parameters[param_name] 

1082 changed_child_params = { 

1083 child_path 

1084 for child_path, child_value in child_updates.items() 

1085 if self.parameters.get(child_path) != child_value 

1086 } 

1087 changed_ancestor_params = { 

1088 ancestor_path 

1089 for ancestor_path, ancestor_value in ancestor_updates.items() 

1090 if self.parameters.get(ancestor_path) != ancestor_value 

1091 } 

1092 changed_storage_params = set(changed_child_params) | changed_ancestor_params 

1093 if current_value != value: 

1094 changed_storage_params.add(param_name) 

1095 if not changed_storage_params: 

1096 return set() 

1097 

1098 changed_semantic_params = set(changed_child_params) 

1099 if current_value != value and not changed_child_params: 

1100 changed_semantic_params.add(param_name) 

1101 

1102 changed_param_values = self._changed_parameter_values( 

1103 changed_semantic_params, 

1104 pending_parameters, 

1105 ) 

1106 # Establish the pre-mutation resolved authority even for a state that 

1107 # has not yet been displayed. Raw edits can preserve the resolved value. 

1108 changed_before_the_edit = self._ensure_live_resolved(notify_flash=False) 

1109 ObjectStateRegistry.ensure_baseline_snapshot() 

1110 

1111 # Update state directly (no type conversion - that's VIEW responsibility) 

1112 self.parameters[param_name] = value 

1113 for child_path, child_value in child_updates.items(): 

1114 self.parameters[child_path] = child_value 

1115 for ancestor_path, ancestor_value in ancestor_updates.items(): 

1116 self.parameters[ancestor_path] = ancestor_value 

1117 

1118 # SELF-INVALIDATION: Mark changed fields and same-state inherited siblings. 

1119 local_invalidations = set(changed_semantic_params) 

1120 for changed_param in changed_semantic_params: 

1121 container_type = self.parameter_structure.owner_paths.get(changed_param, type(self.object_instance)) 

1122 leaf_field_name = DottedFieldPath(changed_param).field_name 

1123 local_invalidations.update( 

1124 self.inheritance_field_paths(container_type, leaf_field_name) 

1125 ) 

1126 self._invalid_fields.update(local_invalidations) 

1127 self._cached_object = None # Invalidate cached reconstructed object 

1128 self._cached_object_applied = False 

1129 

1130 # Materialize the origin once after its live context is authoritative, 

1131 # then cascade only to descendants. 

1132 changed_paths = self._ensure_live_resolved(notify_flash=False) | changed_before_the_edit 

1133 for changed_param in changed_semantic_params: 

1134 # SCOPE + TYPE + FIELD AWARE INVALIDATION: 

1135 # Get the CONTAINER type for this field (e.g., PartitionFilterConfig for 

1136 # 'partition_filter_config.partition_filter') 

1137 # This is critical for sibling inheritance: when 

1138 # PartitionFilterConfig.partition_filter changes, we need to invalidate 

1139 # PathPlanningConfig.partition_filter (which inherits from PartitionFilterConfig) 

1140 container_type = self.parameter_structure.owner_paths.get(changed_param, type(self.object_instance)) 

1141 

1142 # Extract leaf field name for invalidation matching 

1143 leaf_field_name = DottedFieldPath(changed_param).field_name 

1144 

1145 

1146 ObjectStateRegistry.invalidate_by_type_and_scope( 

1147 scope_id=self.scope_id, 

1148 changed_type=container_type, 

1149 field_name=leaf_field_name, 

1150 include_origin=False, 

1151 ) 

1152 

1153 # Increment global token for LiveContextService.collect() cache invalidation 

1154 ObjectStateRegistry.increment_token(notify=False) 

1155 

1156 # Raw edits own history/navigation; the resolved delta owns value flashes. 

1157 self._set_last_changed_values(changed_param_values) 

1158 # Sync materialized state (single point for dirty/sig_diff update + notification) 

1159 self._sync_materialized_state( 

1160 changed_value_fields=changed_paths 

1161 ) 

1162 

1163 # Record snapshot for time-travel (registry-level for coherent system history) 

1164 # Leaf edits record directly. Dataclass-container edits record when they 

1165 # materialize changed flat child fields; those child paths are the UI and 

1166 # time-travel navigation authority for inline editors. 

1167 # A field is a container if there are other params that start with "param_name." 

1168 param_path = DottedFieldPath(param_name) 

1169 is_container = any( 

1170 p != param_name and param_path.contains_path(p) 

1171 for p in self.parameters.keys() 

1172 ) 

1173 snapshot_field = ( 

1174 self._select_changed_field(changed_semantic_params) 

1175 if is_container and changed_semantic_params 

1176 else param_name 

1177 ) 

1178 if snapshot_field: 

1179 ObjectStateRegistry.record_snapshot(f"edit {snapshot_field}", self.scope_id) 

1180 

1181 return changed_paths 

1182 

1183 def get_resolved_value(self, param_name: str) -> Any: 

1184 """Get resolved value for a field from the bulk snapshot. 

1185 

1186 Args: 

1187 param_name: Field name to resolve (can be dotted path like 

1188 'path_planning_config.partition_filter') 

1189 

1190 Returns: 

1191 Resolved value from _live_resolved snapshot. 

1192 For dataclass container fields, returns a reconstructed dataclass instance 

1193 with all sub-fields populated from live resolved values. 

1194 """ 

1195 # Auto-detect delegate changes before resolving values 

1196 self._check_and_sync_delegate() 

1197 self._ensure_live_resolved() 

1198 assert self._live_resolved is not None # Guaranteed by _ensure_live_resolved 

1199 

1200 # Check if this is a container/dataclass field (has subfields in _live_resolved) 

1201 prefix = f"{param_name}." 

1202 has_subfields = any(key.startswith(prefix) for key in self._live_resolved.keys()) 

1203 

1204 if has_subfields: 

1205 # This is a container field - reconstruct's dataclass from live resolved values 

1206 field_type = self.parameter_structure.owner_paths.get(param_name) 

1207 if field_type is not None and is_dataclass(field_type): 

1208 return self._reconstruct_from_resolved( 

1209 param_name, self._live_resolved, self._extraction_target, self.parameter_structure 

1210 ) 

1211 

1212 result = self._live_resolved.get(param_name) 

1213 

1214 

1215 return result 

1216 

1217 def get_saved_resolved_value(self, param_name: str) -> Any: 

1218 """Get saved resolved value for a field from the saved snapshot. 

1219 

1220 Unlike get_resolved_value() which returns live values (including unsaved edits), 

1221 this returns the saved baseline with inheritance applied. This is useful for 

1222 compilation and other operations that should only consider saved state. 

1223 

1224 For container fields (dataclasses), this reconstructs the entire nested 

1225 dataclass with all sub-fields populated from saved resolved values. 

1226 

1227 Args: 

1228 param_name: Field name to resolve (can be dotted path like 

1229 'path_planning_config.partition_filter') 

1230 

1231 Returns: 

1232 Saved resolved value from _saved_resolved snapshot. 

1233 For dataclass fields, returns a reconstructed dataclass instance. 

1234 """ 

1235 # Auto-detect delegate changes before resolving values 

1236 self._check_and_sync_delegate() 

1237 

1238 self._ensure_saved_resolved() 

1239 

1240 # Check if this is a container/dataclass field (has subfields in _saved_resolved) 

1241 prefix = f"{param_name}." 

1242 has_subfields = any(key.startswith(prefix) for key in self._saved_resolved.keys()) 

1243 

1244 if has_subfields: 

1245 # This is a container field - reconstruct's dataclass from saved resolved values 

1246 field_type = self.parameter_structure.owner_paths.get(param_name) 

1247 if field_type is not None and is_dataclass(field_type): 

1248 return self._reconstruct_from_resolved( 

1249 param_name, self._saved_resolved, self._extraction_target, self.parameter_structure 

1250 ) 

1251 

1252 # Return the simple value (or None if not found) 

1253 return self._saved_resolved.get(param_name) 

1254 

1255 def subfield_semantics( 

1256 self, 

1257 owner_field_path: DottedFieldPath, 

1258 ) -> ObjectStateSubfieldSemanticIndex: 

1259 """Project dirty/default/inherited semantics for structural leaves. 

1260 

1261 The owner field remains the only writable ObjectState field. Returned 

1262 leaves are visual/projection identities under that owner, such as 

1263 ``source_filters[0].match_type`` for a tuple of dataclass rows. 

1264 """ 

1265 

1266 self._check_and_sync_delegate() 

1267 self._ensure_live_resolved() 

1268 assert self._live_resolved is not None 

1269 self._ensure_saved_resolved() 

1270 

1271 path = owner_field_path 

1272 raw_value = self.parameters.get(path.value, MISSING) 

1273 resolved_value = self.get_resolved_value(path.value) 

1274 saved_resolved_value = self.get_saved_resolved_value(path.value) 

1275 declaration = self.parameter_structure.declarations.get(path.value) 

1276 signature_default_value = ( 

1277 declaration.default_value if declaration is not None else MISSING 

1278 ) 

1279 

1280 return build_subfield_semantic_index( 

1281 owner_field_path=path, 

1282 raw_value=raw_value, 

1283 resolved_value=resolved_value, 

1284 saved_resolved_value=saved_resolved_value, 

1285 signature_default_value=signature_default_value, 

1286 owner_dirty=path.contains_any(self.dirty_fields), 

1287 owner_signature_diff=path.contains_any(self.signature_diff_fields), 

1288 ) 

1289 

1290 

1291 @classmethod 

1292 def _reconstruct_from_resolved( 

1293 cls, 

1294 prefix: str, 

1295 resolved_snapshot: dict[str, Any], 

1296 target: Any, 

1297 structure: ParameterStructure, 

1298 ) -> Any: 

1299 """Reconstruct the nominal object and nested dataclasses from a snapshot. 

1300 

1301 Unified method for both live and saved resolved values. The only difference 

1302 is which snapshot dict is passed in (_live_resolved or _saved_resolved). 

1303 

1304 Args: 

1305 prefix: Current path prefix (e.g., 'analysis_consolidation_config') 

1306 resolved_snapshot: The snapshot dict to reconstruct from (_live_resolved or _saved_resolved) 

1307 

1308 Returns: 

1309 Reconstructed dataclass instance with resolved values 

1310 """ 

1311 from objectstate.lazy_factory import get_base_type_for_lazy 

1312 

1313 # Determine the type to reconstruct 

1314 if not prefix: 

1315 obj_type = type(target) 

1316 else: 

1317 obj_type = structure.owner_paths.get(prefix) 

1318 if obj_type is None: 

1319 raise ValueError(f"No type mapping for prefix: {prefix}") 

1320 

1321 # Normalize to base type for lazy dataclasses 

1322 obj_type = get_base_type_for_lazy(obj_type) or obj_type 

1323 

1324 prefix_dot = f'{prefix}.' if prefix else '' 

1325 

1326 # Collect direct fields and nested prefixes from resolved snapshot 

1327 direct_fields = {} 

1328 nested_prefixes = set() 

1329 

1330 for path, value in resolved_snapshot.items(): 

1331 if not path.startswith(prefix_dot): 

1332 continue 

1333 

1334 remainder = path[len(prefix_dot):] 

1335 

1336 if '.' in remainder: 

1337 # This is a nested field - collect the first component 

1338 first_component = remainder.split('.')[0] 

1339 nested_prefixes.add(first_component) 

1340 else: 

1341 # Direct field of this object 

1342 direct_fields[remainder] = value 

1343 

1344 # Reconstruct nested dataclasses first 

1345 for nested_name in nested_prefixes: 

1346 nested_path = f'{prefix_dot}{nested_name}' 

1347 nested_obj = cls._reconstruct_from_resolved( 

1348 nested_path, resolved_snapshot, target, structure 

1349 ) 

1350 direct_fields[nested_name] = nested_obj 

1351 

1352 # Instantiate the dataclass with all resolved fields 

1353 result = obj_type(**direct_fields) 

1354 

1355 return result 

1356 

1357 def get_provenance(self, param_name: str) -> tuple[str | None, type] | None: 

1358 """Get the source scope_id and type for an inherited field value. 

1359 

1360 For fields where the local value is None (inherited), returns the scope_id 

1361 of the ancestor that provided the value AND the type that has it. 

1362 Used for click-to-source navigation in the UI. 

1363 

1364 The source_type may differ from the local container type due to MRO inheritance. 

1365 For example, PartitionFilterConfig.partition_filter might inherit from PathPlanningConfig. 

1366 

1367 NOTE: Returns provenance even when the resolved value is None (signature default). 

1368 A "concrete None" just means the class default is None and nothing overrode it. 

1369 

1370 Args: 

1371 param_name: Field name (can be dotted path like 'path_planning_config.partition_filter') 

1372 

1373 Returns: 

1374 (source_scope_id, source_type): The scope and type that provided the value, 

1375 or None if the value is set on this field itself (not inherited). 

1376 

1377 When another config of this same object provides the value, the scope 

1378 is this state's own ``scope_id``. For a state without a scope id that 

1379 is ``None``, so ``(None, source_type)`` means "this object, through 

1380 ``source_type``". 

1381 """ 

1382 self._ensure_live_resolved() 

1383 result = self._live_provenance.get(param_name) 

1384 if result is None: 

1385 return None # Field is set on this object, not inherited 

1386 scope_id, source_type = result 

1387 if source_type is None: 

1388 return None # No layer and no declared default supplied the field 

1389 if scope_id is None and not self._holds_config_type_without_scope(source_type): 

1390 return None # No layer held the field; the value is a declared default 

1391 return (scope_id, source_type) 

1392 

1393 def _holds_config_type_without_scope(self, source_type: type) -> bool: 

1394 """True when this state has no scope id and its own object holds ``source_type``. 

1395 

1396 A layer without a scope id supplies values under source scope ``None``. The 

1397 only such layer in this state's context stack is its own object, because the 

1398 global config and every ancestor are entered under a scope id. 

1399 """ 

1400 return self.scope_id is None and self.find_path_for_type(source_type) is not None 

1401 

1402 def find_path_for_type(self, container_type: type) -> str | None: 

1403 """Find the path prefix for a container type in this ObjectState. 

1404 

1405 With flat storage, nested configs are identified by their path prefix. 

1406 Given a container type (e.g., PathPlanningConfig), returns the path prefix 

1407 (e.g., 'path_planning_config'). 

1408 

1409 Handles type normalization: LazyPathPlanningConfig matches PathPlanningConfig. 

1410 

1411 Args: 

1412 container_type: The type to find the path for 

1413 

1414 Returns: 

1415 Path prefix for the type, or None if not found. 

1416 Returns "" (empty string) if type is the root object type. 

1417 """ 

1418 from objectstate.lazy_factory import get_base_type_for_lazy 

1419 

1420 # Normalize the container_type for comparison 

1421 container_base = get_base_type_for_lazy(container_type) or container_type 

1422 

1423 # Check if container_type matches the root object type 

1424 root_type = type(self.object_instance) 

1425 root_base = get_base_type_for_lazy(root_type) or root_type 

1426 if container_base == root_base: 

1427 return "" # Root type has no prefix 

1428 

1429 # Look for paths where the TYPE matches (normalized comparison) 

1430 # The path for a nested config is the one WITHOUT a dot suffix that has the type 

1431 for path, typ in self.parameter_structure.owner_paths.items(): 

1432 typ_base = get_base_type_for_lazy(typ) or typ 

1433 if not isinstance(typ_base, type): 

1434 continue 

1435 if typ_base == container_base and '.' not in path: 

1436 return path 

1437 

1438 return None 

1439 

1440 def project_ui_visible_field_path(self, container_type: type, field_name: str) -> str | None: 

1441 """Project a container type and field name to the visible UI field path. 

1442 

1443 Hidden configuration classes can own inherited values even when the form 

1444 renders a visible subclass. ObjectState owns the path/type index, so this 

1445 method is the authority for remapping hidden source types to a visible 

1446 field path. 

1447 """ 

1448 from objectstate.lazy_factory import get_base_type_for_lazy 

1449 from objectstate.ui_visibility import UIVisibilityRegistry 

1450 

1451 container_base = get_base_type_for_lazy(container_type) or container_type 

1452 if UIVisibilityRegistry.is_hidden(container_base): 

1453 return self._find_visible_subclass_field_path(container_base, field_name) 

1454 

1455 path_prefix = self.find_path_for_type(container_type) 

1456 if path_prefix is None: 

1457 return None 

1458 

1459 field_path = f"{path_prefix}.{field_name}" if path_prefix else field_name 

1460 if field_path not in self.parameter_structure.owner_paths and field_path not in self.parameters: 

1461 return None 

1462 return field_path 

1463 

1464 def _find_visible_subclass_field_path(self, hidden_type: type, field_name: str) -> str | None: 

1465 """Find a rendered field path for a hidden config type via a visible subclass.""" 

1466 from objectstate.lazy_factory import get_base_type_for_lazy 

1467 from objectstate.ui_visibility import UIVisibilityRegistry 

1468 

1469 hidden_base = get_base_type_for_lazy(hidden_type) or hidden_type 

1470 for path, typ in self.parameter_structure.owner_paths.items(): 

1471 if "." in path: 

1472 continue 

1473 

1474 typ_base = get_base_type_for_lazy(typ) or typ 

1475 if not isinstance(typ_base, type): 

1476 continue 

1477 if UIVisibilityRegistry.is_hidden(typ_base): 

1478 continue 

1479 if hidden_base not in typ_base.__mro__: 

1480 continue 

1481 

1482 field_path = f"{path}.{field_name}" 

1483 if field_path in self.parameter_structure.owner_paths or field_path in self.parameters: 

1484 return field_path 

1485 

1486 return None 

1487 

1488 def resolve_for_type(self, container_type: type, field_name: str) -> Any: 

1489 """Resolve a field value given the container type and field name. 

1490 

1491 Convenience method for callers who have a config object but don't know 

1492 its path in the flat storage. Finds the path prefix for the container type 

1493 and constructs the full dotted path. 

1494 

1495 Args: 

1496 container_type: Type of the containing config (e.g., PathPlanningConfig) 

1497 field_name: Field name within that config (e.g., 'partition_filter') 

1498 

1499 Returns: 

1500 Resolved value, or None if not found 

1501 """ 

1502 path_prefix = self.find_path_for_type(container_type) 

1503 if path_prefix is None: 

1504 # Type not found - try the field_name directly (top-level field) 

1505 return self.get_resolved_value(field_name) 

1506 

1507 full_path = f'{path_prefix}.{field_name}' 

1508 return self.get_resolved_value(full_path) 

1509 

1510 def invalidate_cache(self) -> None: 

1511 """Invalidate resolved cache - forces full recompute on next access.""" 

1512 self._live_resolved = None 

1513 self._live_provenance = {} # Provenance must be recomputed with resolved values 

1514 self._cached_object = None # Also invalidate cached object 

1515 

1516 def invalidate_self_and_nested(self) -> None: 

1517 """Invalidate this state's cache. 

1518 

1519 With flat storage, no nested states to invalidate. 

1520 """ 

1521 self._live_resolved = None 

1522 self._live_provenance = {} # Provenance must be recomputed with resolved values 

1523 self._invalid_fields.clear() # Full invalidation, not field-level 

1524 self._cached_object = None 

1525 

1526 def invalidate_field(self, field_name: str) -> None: 

1527 """Mark a specific field as needing recomputation. 

1528 

1529 PERFORMANCE: Field-level invalidation - only the changed field 

1530 needs recomputation, not all 20+ fields in the config. 

1531 """ 

1532 if field_name in self.parameters: 

1533 self._invalid_fields.add(field_name) 

1534 

1535 @staticmethod 

1536 def _select_changed_field(changed_paths: set[str]) -> str | None: 

1537 """Choose a stable representative changed path for navigation.""" 

1538 if not changed_paths: 

1539 return None 

1540 return sorted(changed_paths, key=lambda path: (-path.count("."), path))[0] 

1541 

1542 def _set_last_changed_values( 

1543 self, 

1544 changed_values: dict[str, tuple[Any, Any]], 

1545 ) -> None: 

1546 """Store the latest value changes for UI/time-travel navigation.""" 

1547 if not changed_values: 

1548 return 

1549 self._last_changed_concrete_paths = set(changed_values) 

1550 self._last_changed_paths = self.notification_fields_for_value_changes( 

1551 changed_values 

1552 ) 

1553 self._last_changed_values = copy.deepcopy(changed_values) 

1554 self._last_changed_field = self._select_changed_field( 

1555 self._last_changed_paths 

1556 ) 

1557 

1558 def _changed_parameter_values( 

1559 self, 

1560 changed_params: set[str], 

1561 pending_parameters: dict[str, Any], 

1562 ) -> dict[str, tuple[Any, Any]]: 

1563 """Return old/new values for changed flat parameter paths.""" 

1564 

1565 changed_values: dict[str, tuple[Any, Any]] = {} 

1566 for param_path in pending_parameters: 

1567 if param_path not in changed_params: 

1568 continue 

1569 old_value = self.parameters.get(param_path) 

1570 new_value = pending_parameters.get(param_path) 

1571 if old_value != new_value: 

1572 changed_values[param_path] = (old_value, new_value) 

1573 return changed_values 

1574 

1575 def notification_fields_for_value_changes( 

1576 self, 

1577 changed_values: dict[str, tuple[Any, Any]], 

1578 ) -> set[str]: 

1579 """Return ObjectState display paths affected by concrete value changes.""" 

1580 

1581 fields: set[str] = set() 

1582 for param_path, (old_value, new_value) in changed_values.items(): 

1583 if self.parameter_structure.is_container_parameter(param_path, new_value, self.parameters): 

1584 continue 

1585 fields.add(param_path) 

1586 fields.update( 

1587 changed_structural_leaf_paths( 

1588 owner_field_path=DottedFieldPath(param_path), 

1589 old_value=old_value, 

1590 new_value=new_value, 

1591 ) 

1592 ) 

1593 return fields 

1594 

1595 @staticmethod 

1596 def _most_specific_notification_fields(fields: set[str]) -> set[str]: 

1597 """Drop container notifications when a concrete descendant path is present.""" 

1598 

1599 if len(fields) < 2: 

1600 return set(fields) 

1601 

1602 paths = tuple(DottedFieldPath(field) for field in fields) 

1603 result: set[str] = set() 

1604 for field_path in paths: 

1605 if any( 

1606 field_path.value != candidate.value 

1607 and field_path.contains_path(candidate) 

1608 for candidate in paths 

1609 ): 

1610 continue 

1611 result.add(field_path.value) 

1612 return result 

1613 

1614 def construction_binding(self) -> StateConstructionBinding: 

1615 """Capture construction from this state, not a parallel factory registry.""" 

1616 

1617 arguments = dict( 

1618 state_type=type(self), 

1619 target=self._extraction_target, 

1620 parent_scope=( 

1621 self._parent_state.scope_id or "" if self._parent_state is not None else None 

1622 ), 

1623 exclusions=tuple(self.parameter_structure.exclusions), 

1624 parent_field=self._parent_field_name, 

1625 ) 

1626 if self._delegate_attr is not None: 

1627 return DelegatedStateBinding( 

1628 **arguments, 

1629 lifecycle_type=type(self.object_instance), 

1630 delegate_attribute=self._delegate_attr, 

1631 ) 

1632 return DirectStateBinding(**arguments) 

1633 

1634 def apply_prepared_construction( 

1635 self, 

1636 binding: StateConstructionBinding, 

1637 prepared: ObjectState, 

1638 parent: ObjectState | None, 

1639 ) -> None: 

1640 """Attach a prevalidated owner/schema without replacing state callbacks.""" 

1641 

1642 binding.attach(self) 

1643 self._parent_state = parent 

1644 self._parent_field_name = binding.parent_field 

1645 self.parameter_structure = prepared.parameter_structure.clone() 

1646 self._cached_object = None 

1647 self._cached_object_applied = False 

1648 self._invalid_fields.clear() 

1649 

1650 def update_object_instance(self, new_instance: Any) -> None: 

1651 """Replace object_instance with a new instance and re-extract parameters. 

1652 

1653 This is used when the object being edited is replaced externally (e.g., from 

1654 code mode execution). The ObjectState is updated to point to the new instance 

1655 and parameters are re-extracted to match the new object's state. 

1656 

1657 For delegation cases, this updates _extraction_target. For non-delegation cases, 

1658 it updates object_instance directly. 

1659 

1660 Args: 

1661 new_instance: The new object instance to extract parameters from 

1662 """ 

1663 self._ensure_live_resolved(notify_flash=False) 

1664 previous_parameters = dict(self.parameters) 

1665 previous_saved_parameters = dict(self._saved_parameters) 

1666 ObjectStateRegistry.mark_snapshot_dirty_scope(self.scope_id) 

1667 old_live_resolved = copy.deepcopy(self._live_resolved or {}) 

1668 

1669 if self._delegate_attr is not None: 

1670 # Delegation case: verify the new_instance matches the delegate type 

1671 if type(new_instance) != type(self._extraction_target): 

1672 logger.warning( 

1673 f"Type mismatch in update_object_instance for delegated ObjectState: " 

1674 f"expected {type(self._extraction_target).__name__}, got {type(new_instance).__name__}" 

1675 ) 

1676 self._extraction_target = new_instance 

1677 setattr(self.object_instance, self._delegate_attr, new_instance) 

1678 else: 

1679 # Non-delegation case: update object_instance directly 

1680 self.object_instance = new_instance 

1681 self._extraction_target = new_instance 

1682 

1683 # Re-extract parameters from new instance 

1684 self._replace_parameter_structure( 

1685 new_instance, 

1686 exclude_params=self.parameter_structure.exclusions 

1687 ) 

1688 

1689 # Update saved parameters to match 

1690 self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) 

1691 

1692 if not semantic_values_equal( 

1693 previous_parameters, 

1694 self.parameters, 

1695 ) or not semantic_values_equal( 

1696 previous_saved_parameters, 

1697 self._saved_parameters, 

1698 ): 

1699 ObjectStateRegistry.mark_snapshot_dirty_scope(self.scope_id) 

1700 

1701 self._cached_object = None 

1702 self._cached_object_applied = False 

1703 self._invalid_fields.clear() 

1704 

1705 self._live_resolved = self._compute_resolved_snapshot(use_saved=False) 

1706 self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) 

1707 

1708 missing = object() 

1709 changed_paths = { 

1710 path 

1711 for path in set(old_live_resolved) | set(self._live_resolved) 

1712 if old_live_resolved.get(path, missing) 

1713 != self._live_resolved.get(path, missing) 

1714 } 

1715 changed_values = { 

1716 path: ( 

1717 old_live_resolved.get(path), 

1718 self._live_resolved.get(path), 

1719 ) 

1720 for path in changed_paths 

1721 } 

1722 

1723 self._set_last_changed_values(changed_values) 

1724 

1725 self._notify_resolved_changed( 

1726 self.notification_fields_for_value_changes(changed_values), 

1727 context="update_object_instance", 

1728 ) 

1729 

1730 self._sync_materialized_state() 

1731 

1732 for path in changed_paths: 

1733 container_type = self.parameter_structure.owner_paths.get(path, type(self.object_instance)) 

1734 leaf_field_name = DottedFieldPath(path).field_name 

1735 ObjectStateRegistry.invalidate_by_type_and_scope( 

1736 scope_id=self.scope_id, 

1737 changed_type=container_type, 

1738 field_name=leaf_field_name, 

1739 invalidate_saved=True, 

1740 ) 

1741 

1742 self._set_last_changed_values(changed_values) 

1743 

1744 if changed_paths: 

1745 ObjectStateRegistry.increment_token(notify=True) 

1746 

1747 logger.debug( 

1748 f"Updated ObjectState(scope={self.scope_id!r}) to new instance of type {type(new_instance).__name__}" 

1749 ) 

1750 

1751 def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: 

1752 """Recompute only the invalid fields of the live snapshot, from ``sources``. 

1753 

1754 PERFORMANCE: For explicitly set values, use parameters directly. 

1755 Only build context stack for inherited (None) values. 

1756 

1757 Returns: 

1758 Set of paths whose resolved values actually changed (for UI notification). 

1759 """ 

1760 from objectstate.context_manager import build_context_stack 

1761 

1762 changed_paths: set[str] = set() 

1763 changed_values: dict[str, tuple[Any, Any]] = {} 

1764 

1765 # _live_resolved must exist when this is called (from _ensure_live_resolved) 

1766 if self._live_resolved is None: 

1767 return changed_paths 

1768 

1769 # Separate explicit vs inherited fields, skipping container entries 

1770 explicit_fields = [] 

1771 inherited_fields = [] 

1772 for name in self._invalid_fields: 

1773 if name not in self.parameters: 

1774 continue 

1775 # Safety check: skip any container entries that might have leaked in 

1776 # (containers should NOT be in parameters — only leaf fields are tracked) 

1777 raw_value = self.parameters[name] 

1778 if self.parameter_structure.is_container_parameter(name, raw_value, self.parameters): 

1779 continue 

1780 if raw_value is not None: 

1781 explicit_fields.append(name) 

1782 else: 

1783 inherited_fields.append(name) 

1784 

1785 # Explicit values: use parameters directly (no resolution needed) 

1786 for name in explicit_fields: 

1787 old_val = self._live_resolved.get(name) 

1788 explicit_val = self.parameters[name] 

1789 if old_val != explicit_val: 

1790 changed_paths.add(name) 

1791 changed_values[name] = (old_val, explicit_val) 

1792 logger.debug( 

1793 f"RECOMPUTE EXPLICIT CHANGED [{self.scope_id}] {name}: " 

1794 f"old={old_val!r} -> new={explicit_val!r}" 

1795 ) 

1796 self._live_resolved[name] = explicit_val 

1797 # Clear provenance for explicit values - they're no longer inherited 

1798 if name in self._live_provenance: 

1799 del self._live_provenance[name] 

1800 

1801 # Inherited values: need context stack for lazy resolution + provenance 

1802 if inherited_fields: 

1803 from objectstate.dual_axis_resolver import resolve_with_provenance 

1804 from objectstate.lazy_factory import has_lazy_resolution 

1805 

1806 # ARCHITECTURAL FIX: Reconstruct current object from parameters for real-time MRO. 

1807 # Using object_instance (original saved state) gives stale values, breaking inheritance. 

1808 # Calling to_object() with sync_delegate=False reconstructs from CURRENT parameters 

1809 # without triggering delegate sync that would invalidate our cache mid-computation. 

1810 current_obj = self.to_object(update_delegate=False, sync_delegate=False) 

1811 

1812 with build_context_stack( 

1813 object_instance=current_obj, 

1814 ancestor_objects_with_scopes=sources.ancestors, 

1815 current_scope_id=self.scope_id, 

1816 ): 

1817 # For each inherited field, resolve using dual-axis resolution with provenance 

1818 for dotted_path in inherited_fields: 

1819 container_type = self.parameter_structure.owner_paths.get(dotted_path) 

1820 if container_type is None: 

1821 logger.debug(f"⚠️ _recompute: {dotted_path} has no container_type in _path_to_type") 

1822 continue 

1823 # Non-lazy fields with None stay as None rather than 

1824 # participating in contextual inheritance. 

1825 if not is_dataclass(container_type) or not has_lazy_resolution(container_type): 

1826 # Non-lazy field: just use raw value (None) 

1827 logger.debug(f"⚠️ _recompute: {dotted_path} has non-lazy container_type={container_type.__name__}, using raw value=None") 

1828 old_val = self._live_resolved.get(dotted_path) 

1829 raw_val = self.parameters.get(dotted_path) 

1830 if old_val != raw_val: 

1831 changed_paths.add(dotted_path) 

1832 changed_values[dotted_path] = (old_val, raw_val) 

1833 self._live_resolved[dotted_path] = raw_val 

1834 continue 

1835 field_name = DottedFieldPath(dotted_path).field_name 

1836 

1837 # Use resolve_with_provenance for SINGLE walk that gets both value AND source 

1838 value, source_scope_id, source_type = resolve_with_provenance(container_type, field_name) 

1839 

1840 old_val = self._live_resolved.get(dotted_path) 

1841 if old_val != value: 

1842 changed_paths.add(dotted_path) 

1843 changed_values[dotted_path] = (old_val, value) 

1844 logger.debug( 

1845 f"RECOMPUTE INHERITED CHANGED [{self.scope_id}] {dotted_path}: " 

1846 f"old={old_val!r} -> new={value!r}" 

1847 ) 

1848 else: 

1849 logger.debug( 

1850 f"RECOMPUTE INHERITED UNCHANGED [{self.scope_id}] {dotted_path}: " 

1851 f"old={old_val!r} == new={value!r}" 

1852 ) 

1853 self._live_resolved[dotted_path] = value 

1854 

1855 # Update provenance for this field 

1856 self._live_provenance[dotted_path] = (source_scope_id, source_type) 

1857 

1858 # Store for navigation only when recomputation observed concrete deltas. 

1859 # First-populate paths legitimately have no resolved delta; preserving the 

1860 # raw update_parameter() paths keeps time-travel/flash navigation precise. 

1861 if changed_values: 

1862 self._set_last_changed_values(changed_values) 

1863 return self.notification_fields_for_value_changes(changed_values) 

1864 

1865 @property 

1866 def last_changed_field(self) -> str | None: 

1867 """Field that most recently changed value (not just dirty status). 

1868 

1869 This tracks any value change regardless of saved/unsaved state, 

1870 useful for time-travel navigation to show what changed in a transition. 

1871 """ 

1872 return self._last_changed_field 

1873 

1874 def reset_parameter(self, param_name: str) -> None: 

1875 """Reset parameter to signature default (None for lazy dataclasses). 

1876 

1877 Delegates to update_parameter() to ensure consistent invalidation behavior. 

1878 """ 

1879 if param_name not in self.parameters: 

1880 return 

1881 

1882 # Use signature defaults (CLASS defaults), not instance values 

1883 # This ensures reset goes back to None for lazy fields, not saved concrete values 

1884 declaration = self.parameter_structure.declarations.get(param_name) 

1885 default_value = declaration.default_value if declaration is not None else None 

1886 self.update_parameter(param_name, default_value) 

1887 

1888 def get_current_values(self) -> dict[str, Any]: 

1889 """ 

1890 Get current parameter values from state. 

1891 

1892 With flat storage, this returns the flat dict with dotted paths. 

1893 Callers needing nested structure should use to_object() instead. 

1894 

1895 For ObjectState, this reads directly from self.parameters. 

1896 PFM overrides this to also read from widgets. 

1897 """ 

1898 # Auto-detect delegate changes before accessing parameters 

1899 self._check_and_sync_delegate() 

1900 return dict(self.parameters) 

1901 

1902 def reconstruct_top_level_parameters(self) -> dict[str, Any]: 

1903 """Return top-level parameters with nested dataclass values rebuilt. 

1904 

1905 ObjectState stores nested fields in a flat dotted-path mapping. Function 

1906 pattern kwargs need the top-level call signature values, not the flat UI 

1907 storage shape. This method exposes that boundary without requiring UI 

1908 widgets or service code to reach into ObjectState private helpers. 

1909 """ 

1910 self._check_and_sync_delegate() 

1911 

1912 values: dict[str, Any] = {} 

1913 root_type = type(self._extraction_target) 

1914 top_level_params = { 

1915 key 

1916 for key in self.parameters 

1917 if "." not in key 

1918 } 

1919 

1920 for param_name in top_level_params: 

1921 param_type = self.parameter_structure.owner_paths.get(param_name) 

1922 nested_dataclass = ( 

1923 param_type is not None 

1924 and is_dataclass(param_type) 

1925 and param_type != root_type 

1926 ) 

1927 if nested_dataclass: 

1928 values[param_name] = self._reconstruct_from_prefix(param_name) 

1929 else: 

1930 values[param_name] = self.parameters[param_name] 

1931 

1932 return values 

1933 

1934 # ==================== MATERIALIZED DIFFS ==================== 

1935 

1936 @property 

1937 def dirty_fields(self) -> set[str]: 

1938 """Fields whose live and saved resolved values differ, among today's sources.""" 

1939 self._ensure_live_resolved() 

1940 return self._dirty_fields 

1941 

1942 @property 

1943 def signature_diff_fields(self) -> set[str]: 

1944 """Fields where raw != signature_default.""" 

1945 return self._signature_diff_fields 

1946 

1947 @property 

1948 def is_raw_dirty(self) -> bool: 

1949 """Check if raw parameters differ from saved parameters (not resolved values).""" 

1950 return self.parameters != self._saved_parameters 

1951 

1952 def _update_raw_dirty(self) -> bool: 

1953 """Recompute raw dirty state, return True if it changed.""" 

1954 new_raw_dirty = self.is_raw_dirty 

1955 if new_raw_dirty != self._raw_dirty: 

1956 self._raw_dirty = new_raw_dirty 

1957 return True 

1958 return False 

1959 

1960 def _compute_dirty_fields(self) -> set[str]: 

1961 """Compute dirty set from live vs saved caches.""" 

1962 if self._live_resolved is None: 

1963 return set() 

1964 dirty = set() 

1965 

1966 def _normalize_callable_value(value: Any) -> Any: 

1967 if value is None: 

1968 return None 

1969 if callable(value): 

1970 return value 

1971 if isinstance(value, list): 

1972 normalized = [] 

1973 for item in value: 

1974 if callable(item): 

1975 normalized.append(item) 

1976 continue 

1977 if isinstance(item, tuple) and len(item) == 2 and callable(item[0]) and isinstance(item[1], dict): 

1978 pruned_kwargs = {k: v for k, v in item[1].items() if v is not None} 

1979 normalized.append(item[0] if not pruned_kwargs else (item[0], pruned_kwargs)) 

1980 if len(normalized) == 1 and callable(normalized[0]): 

1981 return normalized[0] 

1982 return normalized 

1983 return value 

1984 for k in (self._live_resolved.keys() | self._saved_resolved.keys()): 

1985 live_val = _normalize_callable_value(self._live_resolved.get(k)) 

1986 saved_val = _normalize_callable_value(self._saved_resolved.get(k)) 

1987 

1988 if live_val != saved_val: 

1989 dirty.add(k) 

1990 logger.debug(f"🔴 DIRTY_FIELD: scope={self.scope_id!r} field={k!r} live={live_val!r} saved={saved_val!r}") 

1991 if dirty: 

1992 logger.debug(f"🔴 DIRTY_SUMMARY: scope={self.scope_id!r} dirty_fields={dirty}") 

1993 return dirty 

1994 

1995 def _compute_signature_diff_fields(self) -> set[str]: 

1996 """Compute signature-diff set from parameters vs defaults. 

1997 

1998 Any field that differs from its signature default is included. 

1999 Defaults come from the same declarations used for reset, including 

2000 declared nested containers. 

2001 """ 

2002 result = set() 

2003 for k, v in self.parameters.items(): 

2004 if k in self.parameter_structure.declarations: 

2005 sig_default = self.parameter_structure.declarations[k].default_value 

2006 is_diff = v != sig_default 

2007 if is_diff: 

2008 result.add(k) 

2009 return result 

2010 

2011 def _update_dirty_fields(self) -> set[str]: 

2012 """Recompute _dirty_fields, return set of fields that changed dirty status. 

2013 

2014 Returns fields that either became dirty OR became clean. 

2015 Empty set means no change. 

2016 """ 

2017 new_dirty = self._compute_dirty_fields() 

2018 if new_dirty != self._dirty_fields: 

2019 # Symmetric difference: fields that changed dirty status in either direction 

2020 changed_fields = new_dirty ^ self._dirty_fields 

2021 self._dirty_fields = new_dirty 

2022 return changed_fields 

2023 return set() 

2024 

2025 def _update_signature_diff_fields(self) -> set[str]: 

2026 """Recompute signature differences and retain the changed field paths.""" 

2027 new_sig_diff = self._compute_signature_diff_fields() 

2028 changed_fields = new_sig_diff ^ self._signature_diff_fields 

2029 self._signature_diff_fields = new_sig_diff 

2030 return changed_fields 

2031 

2032 def _sync_materialized_state( 

2033 self, 

2034 *, 

2035 changed_value_fields: set[str] | None = None, 

2036 emit_notifications: bool = True, 

2037 ) -> bool: 

2038 """Single point where materialized diffs are recomputed and notified. 

2039 

2040 Call this after ANY mutation that could affect: 

2041 - _live_resolved (affects dirty_fields) 

2042 - _saved_resolved (affects dirty_fields) 

2043 - parameters (affects signature_diff_fields) 

2044 

2045 Correctness guarantee: All mutation paths call this ONE method. 

2046 

2047 Resolved-value subscribers receive only value changes. Materialized 

2048 state subscribers also receive dirty/signature transitions, including 

2049 saving or changing explicitness without changing the resolved value. 

2050 """ 

2051 raw_dirty_changed = self._update_raw_dirty() 

2052 dirty_status_changed_fields = self._update_dirty_fields() 

2053 signature_changed_fields = self._update_signature_diff_fields() 

2054 value_notification_fields = self._most_specific_notification_fields( 

2055 changed_value_fields or set() 

2056 ) 

2057 notification_fields = self._most_specific_notification_fields( 

2058 dirty_status_changed_fields 

2059 | signature_changed_fields 

2060 | value_notification_fields 

2061 ) 

2062 

2063 materialized_changed = raw_dirty_changed or bool(notification_fields) 

2064 if materialized_changed: 

2065 ObjectStateRegistry.mark_snapshot_dirty_scope(self.scope_id) 

2066 

2067 if emit_notifications: 

2068 self._notify_resolved_changed( 

2069 value_notification_fields, 

2070 context="_sync_materialized_state", 

2071 ) 

2072 

2073 if materialized_changed: 

2074 self._notify_state_changed(notification_fields) 

2075 

2076 return materialized_changed 

2077 

2078 def prepare_for_snapshot_capture(self) -> None: 

2079 """Synchronize only pending resolved work before immutable capture.""" 

2080 if self._live_resolved is not None and not self._invalid_fields: 

2081 return 

2082 

2083 changed_paths = self._ensure_live_resolved(notify_flash=False) 

2084 self._sync_materialized_state( 

2085 changed_value_fields=changed_paths, 

2086 emit_notifications=False, 

2087 ) 

2088 

2089 # ==================== SAVED STATE / DIRTY TRACKING ==================== 

2090 

2091 def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: 

2092 """Resolve editor fields using the selected live or saved ancestor epoch.""" 

2093 if not isinstance(self.parameters, dict): 

2094 raise TypeError("ObjectState.parameters must remain a canonical flat dictionary.") 

2095 if not isinstance(self._saved_parameters, dict): 

2096 raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.") 

2097 sources = self._resolution_sources(sync_delegates=True)[use_saved] 

2098 self._resolved_from[use_saved] = sources 

2099 current_obj = ( 

2100 self.saved_object if use_saved else self.to_object(update_delegate=False) 

2101 ) 

2102 parameters = self._saved_parameters if use_saved else self.parameters 

2103 snapshot, provenance = self._resolve_parameter_snapshot( 

2104 self.parameters, 

2105 parameters, 

2106 self.parameter_structure, 

2107 current_obj, 

2108 self.scope_id, 

2109 sources.ancestors, 

2110 use_saved=use_saved, 

2111 ) 

2112 if not use_saved: 

2113 self._live_provenance = provenance 

2114 return snapshot 

2115 

2116 @classmethod 

2117 def _resolve_parameter_snapshot( 

2118 cls, 

2119 parameters: dict[str, Any], 

2120 values: dict[str, Any], 

2121 structure: ParameterStructure, 

2122 current_obj: Any, 

2123 scope_id: str | None, 

2124 ancestor_objects_with_scopes: Sequence[tuple[str, Any]], 

2125 *, 

2126 use_saved: bool, 

2127 ) -> tuple[dict[str, Any], dict[str, tuple[str | None, type | None]]]: 

2128 """Resolve raw fields and their provenance in one scoped semantic epoch.""" 

2129 from objectstate.context_manager import build_context_stack 

2130 from objectstate.dual_axis_resolver import resolve_with_provenance 

2131 from objectstate.lazy_factory import has_lazy_resolution 

2132 

2133 snapshot: dict[str, Any] = {} 

2134 provenance: dict[str, tuple[str | None, type | None]] = {} 

2135 

2136 # UNIFIED: Resolve ALL fields in single context stack 

2137 # For each path, check if it has a lazy dataclass container type 

2138 with build_context_stack( 

2139 object_instance=current_obj, 

2140 ancestor_objects_with_scopes=ancestor_objects_with_scopes, 

2141 current_scope_id=scope_id, 

2142 use_live=not use_saved, 

2143 ): 

2144 for dotted_path in parameters.keys(): 

2145 raw_value = values.get(dotted_path) 

2146 container_type = structure.owner_paths.get(dotted_path) 

2147 

2148 # Check if this path is a CONTAINER entry (value is a nested dataclass) 

2149 # vs a LEAF field (value is primitive, even if container_type is a dataclass) 

2150 if structure.is_container_parameter(dotted_path, raw_value, parameters): 

2151 # Container-level entry - SKIP from snapshot 

2152 # Containers are kept in parameters for UI rendering but excluded from 

2153 # dirty comparison since we compare leaf fields instead 

2154 pass 

2155 elif ( 

2156 container_type is not None 

2157 and is_dataclass(container_type) 

2158 and has_lazy_resolution(container_type) 

2159 ): 

2160 # Leaf field inside a LAZY dataclass - resolve value AND provenance in ONE walk 

2161 # CRITICAL: Only resolve for lazy dataclasses! Non-lazy dataclasses with None 

2162 # defaults should keep None as-is, not trigger inheritance resolution. 

2163 # This handles both: 

2164 # - Nested fields (processing_config.group_by) 

2165 # - Top-level fields on root (num_workers on PipelineConfig) 

2166 field_name = DottedFieldPath(dotted_path).field_name 

2167 

2168 if raw_value is None: 

2169 # Field needs resolution - use combined resolve + provenance walk 

2170 resolved_val, source_scope, source_type = resolve_with_provenance(container_type, field_name) 

2171 snapshot[dotted_path] = resolved_val 

2172 

2173 # Keep provenance in the same epoch as the resolved value. 

2174 provenance[dotted_path] = (source_scope, source_type) 

2175 else: 

2176 # Field has concrete local value - no resolution needed 

2177 resolved_val = raw_value 

2178 snapshot[dotted_path] = resolved_val 

2179 

2180 logger.debug( 

2181 f"SNAPSHOT [{scope_id}] {dotted_path}: " 

2182 f"raw={raw_value!r} -> resolved={resolved_val!r} (type={type(resolved_val).__name__})" 

2183 ) 

2184 else: 

2185 # Non-lazy field (regular dataclass, class instance, callable) - use raw value directly 

2186 # None stays as None, no inheritance resolution 

2187 snapshot[dotted_path] = raw_value 

2188 

2189 return snapshot, provenance 

2190 

2191 def mark_saved(self) -> None: 

2192 """Mark current state as saved baseline. 

2193 

2194 UNIFIED: Works for any object_instance type. For the state of a global 

2195 config, the saved object also becomes the saved global config. 

2196 

2197 CRITICAL: Invalidates descendant caches for any parameters that changed. 

2198 This ensures that when saving, other windows that inherited from the 

2199 old saved values get their caches invalidated so they pick up new values. 

2200 This mirrors what restore_saved() does but in the opposite direction. 

2201 

2202 Invalidation is based on comparing the OLD object_instance (about to be replaced) 

2203 with the NEW self.parameters (live values used for reconstruction). 

2204 """ 

2205 logger.debug(f"🐛 mark_saved: ENTER for scope={self.scope_id!r}, obj_type={type(self.object_instance).__name__}, _saved_parameters is None={self._saved_parameters is None}") 

2206 # Ensure live cache is populated for accurate dirty computation post-save 

2207 self._ensure_live_resolved(notify_flash=False) 

2208 

2209 # CRITICAL: Extract old values from object_instance BEFORE rebuilding it 

2210 # These are the values that descendants might be inheriting from 

2211 old_instance_values = {} 

2212 if not isinstance(self.object_instance, type): 

2213 # Extract raw attribute values from the old object_instance 

2214 for param_name in self.parameters.keys(): 

2215 # Skip container entries (nested dataclass instances) 

2216 if param_name in self.parameters: 

2217 raw_value = self.parameters.get(param_name) 

2218 if self.parameter_structure.is_container_parameter(param_name, raw_value, self.parameters): 

2219 continue 

2220 

2221 # Get the old value by navigating dotted path on the extraction target 

2222 # For delegation, parameters are on the delegate, not object_instance 

2223 try: 

2224 # Navigate through nested attributes for dotted paths 

2225 obj = self._extraction_target 

2226 parts = param_name.split('.') 

2227 for part in parts: 

2228 obj = object.__getattribute__(obj, part) 

2229 old_instance_values[param_name] = obj 

2230 except AttributeError: 

2231 # Field doesn't exist on extraction target, skip it 

2232 pass 

2233 

2234 # Find parameters that differ between old object_instance and new live parameters 

2235 # These are the fields that changed and need descendant invalidation 

2236 changed_params = [] 

2237 for param_name in self.parameters.keys(): 

2238 # Skip container entries 

2239 raw_value = self.parameters.get(param_name) 

2240 if self.parameter_structure.is_container_parameter(param_name, raw_value, self.parameters): 

2241 continue 

2242 

2243 old_value = old_instance_values.get(param_name) 

2244 new_value = self.parameters.get(param_name) 

2245 if old_value != new_value: 

2246 changed_params.append(param_name) 

2247 

2248 # CRITICAL: Rebuild extraction target BEFORE invalidating descendants 

2249 # Descendants will recompute using parent's extraction target, so it must have new values! 

2250 if not isinstance(self.object_instance, type): 

2251 if self._delegate_attr is not None: 

2252 # DELEGATION: to_object() returns the delegate and updates it on object_instance 

2253 # as a side effect. Keep object_instance unchanged (it's the lifecycle object). 

2254 # _extraction_target is updated to point to the new delegate. 

2255 self._extraction_target = self.to_object(update_delegate=True) 

2256 else: 

2257 # NON-DELEGATION: to_object() returns the reconstructed object_instance 

2258 self.object_instance = self.to_object(update_delegate=True) 

2259 self._extraction_target = self.object_instance # Keep in sync 

2260 

2261 # Update saved parameters (after object_instance update, before invalidation) 

2262 self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) 

2263 

2264 # NOW invalidate descendant caches AFTER object_instance is updated 

2265 # This ensures descendants see the NEW object_instance when they recompute 

2266 # CRITICAL: Also invalidate saved_resolved cache so descendants recompute their saved baseline 

2267 logger.debug(f"🔧 mark_saved: Starting invalidation for changed_params={changed_params}, scope={self.scope_id!r}") 

2268 for param_name in changed_params: 

2269 container_type = self.parameter_structure.owner_paths.get(param_name, type(self.object_instance)) 

2270 leaf_field_name = DottedFieldPath(param_name).field_name 

2271 logger.debug(f"🔧 mark_saved: Invalidating param={param_name}, container_type={container_type.__name__ if hasattr(container_type, '__name__') else type(container_type).__name__}, leaf_field={leaf_field_name}") 

2272 

2273 ObjectStateRegistry.invalidate_by_type_and_scope( 

2274 scope_id=self.scope_id, 

2275 changed_type=container_type, 

2276 field_name=leaf_field_name, 

2277 invalidate_saved=True # Invalidate saved baseline for descendants 

2278 ) 

2279 

2280 # Compute new saved resolved using SAVED ancestor baselines (use_saved=True) 

2281 # This ensures saved baseline is computed relative to other saved baselines 

2282 logger.debug(f"🔧 mark_saved: Computing new saved_resolved for scope={self.scope_id!r}") 

2283 new_saved_resolved = self._compute_resolved_snapshot(use_saved=True) 

2284 logger.debug(f"🔧 mark_saved: New saved_resolved computed, keys={list(new_saved_resolved.keys())[:5]}...") 

2285 

2286 # Update saved resolved baseline 

2287 self._saved_resolved = new_saved_resolved 

2288 

2289 # Invalidate cached object so next to_object() call rebuilds 

2290 self._cached_object = None 

2291 

2292 # Sync materialized state (single point for dirty/sig_diff update + notification) 

2293 self._sync_materialized_state() 

2294 

2295 # Record snapshot for time-travel (registry-level) - ONLY if there were actual changes 

2296 # This prevents no-op snapshots (e.g., saving a window where only sibling state changed) 

2297 if changed_params: 

2298 ObjectStateRegistry.record_snapshot("save", self.scope_id) 

2299 

2300 # CRITICAL FIX: Propagate saved baseline update to ALL descendant states 

2301 # When an ancestor's saved baseline changes, all descendants must recompute 

2302 # their _saved_resolved to reflect the new ancestor saved values. This ensures that 

2303 # when GlobalPipelineConfig is saved, plates/steps clear their dirty markers (*). 

2304 logger.debug(f"🔧 mark_saved: Propagating saved baseline to descendants for scope={self.scope_id!r}") 

2305 logger.debug(f"🔧 mark_saved: Total states in registry: {len(ObjectStateRegistry._states)}") 

2306 

2307 # Collect descendant scopes first to avoid modifying registry during iteration. 

2308 changed_scope = ObjectStateRegistry._normalize_scope_id(self.scope_id) 

2309 descendant_scopes = [ 

2310 s.scope_id for s in ObjectStateRegistry._states.values() 

2311 if changed_scope.strictly_contains(s.scope_id) 

2312 ] 

2313 logger.debug(f"🔧 mark_saved: Found {len(descendant_scopes)} descendant scopes: {descendant_scopes}") 

2314 

2315 for descendant_scope in descendant_scopes: 

2316 state = ObjectStateRegistry._states.get(descendant_scope) 

2317 if state is not None: 

2318 logger.debug(f"🔧 mark_saved: Processing descendant state scope={descendant_scope!r}, obj_type={type(state.object_instance).__name__}") 

2319 logger.debug(f"🐛 mark_saved descendant: _saved_parameters is None={state._saved_parameters is None}, parameters is None={state.parameters is None}") 

2320 # Log dirty fields BEFORE recompute 

2321 logger.debug(f"🔧 mark_saved: BEFORE recompute - dirty_fields={state._dirty_fields}, _saved_resolved_keys={list(state._saved_resolved.keys())[:3]}") 

2322 # Recompute descendant's saved_resolved using new ancestor saved values 

2323 try: 

2324 state._saved_resolved = state._compute_resolved_snapshot(use_saved=True) 

2325 except AttributeError as e: 

2326 logger.error(f"🐛 mark_saved descendant: ERROR in _compute_resolved_snapshot for scope={descendant_scope!r}! Error: {e}") 

2327 logger.error(f"🐛 mark_saved descendant: state._saved_parameters type={type(state._saved_parameters).__name__}, is None={state._saved_parameters is None}") 

2328 logger.error(f"🐛 mark_saved descendant: state.parameters type={type(state.parameters).__name__}, is None={state.parameters is None}") 

2329 raise 

2330 # Sync materialized state so dirty fields are recalculated 

2331 state._sync_materialized_state() 

2332 # Log dirty fields AFTER recompute 

2333 logger.debug(f"🔧 mark_saved: AFTER recompute - dirty_fields={state._dirty_fields}, _saved_resolved_keys={list(state._saved_resolved.keys())[:3]}") 

2334 else: 

2335 logger.warning(f"🔧 mark_saved: Descendant scope {descendant_scope!r} not found in registry!") 

2336 

2337 def restore_saved(self, *, propagate_descendants: bool = True) -> None: 

2338 """Restore parameters to the last saved baseline (from object_instance). 

2339 

2340 UNIFIED: Works for any object_instance type. 

2341 

2342 CRITICAL: Invalidates descendant caches for any parameters that changed. 

2343 This ensures that when closing a window without saving, other windows 

2344 that inherited from the unsaved values get their caches invalidated. 

2345 

2346 Also emits on_resolved_changed for THIS state so same-level observers 

2347 (like list items subscribed to this ObjectState) flash when values revert. 

2348 """ 

2349 if isinstance(self.object_instance, type): 

2350 self.invalidate_cache() 

2351 self._sync_materialized_state() 

2352 return 

2353 

2354 # If there are no unsaved edits, restoring is a semantic no-op and should not 

2355 # create time-travel snapshots (noise). 

2356 if not self.is_raw_dirty: 

2357 return 

2358 

2359 # Coalesce all restore side-effects into a single snapshot 

2360 with ObjectStateRegistry.atomic(f"restore {self.scope_id}"): 

2361 self._restore_saved_impl(propagate_descendants=propagate_descendants) 

2362 return 

2363 

2364 def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: 

2365 """Internal restore implementation (wrapped by restore_saved atomic block).""" 

2366 

2367 # Find parameters that differ from saved baseline AND capture their container types 

2368 # BEFORE clearing parameters (we need _path_to_type) 

2369 changed_params_with_types = [] 

2370 logger.debug(f"🐛 restore_saved: About to iterate parameters, _saved_parameters type={type(self._saved_parameters).__name__}, is None={self._saved_parameters is None}") 

2371 for param_name, current_value in self.parameters.items(): 

2372 try: 

2373 saved_value = self._saved_parameters.get(param_name) 

2374 except AttributeError as e: 

2375 logger.error(f"🐛 restore_saved: ERROR accessing _saved_parameters.get({param_name!r})! _saved_parameters type={type(self._saved_parameters).__name__}, is None={self._saved_parameters is None}, scope={self.scope_id!r}") 

2376 raise 

2377 if current_value != saved_value: 

2378 container_type = self.parameter_structure.owner_paths.get(param_name, type(self.object_instance)) 

2379 leaf_field_name = DottedFieldPath(param_name).field_name 

2380 changed_params_with_types.append((param_name, container_type, leaf_field_name)) 

2381 

2382 # Clear and re-extract from the saved baseline 

2383 # CRITICAL: For delegation, extract from the delegate (pipeline_config), not the lifecycle object. 

2384 # This keeps flat parameters aligned with the form's target object after window close/reopen. 

2385 # Also refresh _extraction_target in case the delegate attribute was replaced externally. 

2386 extraction_target = self._extraction_target 

2387 if self._delegate_attr is not None: 

2388 try: 

2389 extraction_target = getattr(self.object_instance, self._delegate_attr) 

2390 self._extraction_target = extraction_target 

2391 except Exception: 

2392 # Fallback to existing extraction target if delegate access fails 

2393 extraction_target = self._extraction_target 

2394 self._replace_parameter_structure( 

2395 extraction_target, 

2396 exclude_params=self.parameter_structure.exclusions, 

2397 ) 

2398 

2399 # CRITICAL: Also restore _saved_parameters to match current parameters 

2400 # After restore, parameters == saved (both extracted from object_instance) 

2401 self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) 

2402 

2403 self.invalidate_cache() 

2404 # Invalidate cached reconstructed object (may contain unsaved edits) 

2405 self._cached_object = None 

2406 self._cached_object_applied = False 

2407 

2408 # CRITICAL: Recompute _saved_resolved to match the restored state 

2409 # Time travel may have overwritten _saved_resolved with snapshot values, 

2410 # but after restore_saved(), _saved_resolved should reflect object_instance 

2411 self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) 

2412 

2413 # Recompute _live_resolved to reflect any unsaved changes from higher-level ObjectStates 

2414 # This ensures that unsaved resolved changes from other windows are preserved 

2415 self._live_resolved = self._compute_resolved_snapshot(use_saved=False) 

2416 

2417 # NOW invalidate descendant caches for each changed parameter 

2418 # This must happen AFTER restoring parameters so descendants see restored values 

2419 for param_name, container_type, leaf_field_name in changed_params_with_types: 

2420 ObjectStateRegistry.invalidate_by_type_and_scope( 

2421 scope_id=self.scope_id, 

2422 changed_type=container_type, 

2423 field_name=leaf_field_name 

2424 ) 

2425 

2426 # Optionally propagate restore to descendant ObjectStates so their parameters reflect saved baseline. 

2427 # 

2428 # This is important when restoring a parent that *owns* descendant parameter state (e.g. Step -> function 

2429 # ObjectStates) so canceling the parent edit resets child ObjectStates. 

2430 # 

2431 # However, for delegation-based parents that act as context providers (e.g. orchestrator -> pipeline_config), 

2432 # restoring the parent should generally *not* restore descendant raw parameters (steps). Descendants should 

2433 # only have their resolved caches invalidated so they re-resolve against the restored context. 

2434 # The global scope only provides context to every other scope; it owns none of 

2435 # their parameters, so restoring it never restores them. 

2436 own_scope = ScopeKey(self.scope_id) 

2437 if propagate_descendants and not own_scope.is_global: 

2438 descendant_scopes = [ 

2439 scope 

2440 for scope in ObjectStateRegistry._states.keys() 

2441 if own_scope.strictly_contains(scope) 

2442 ] 

2443 for descendant_scope in descendant_scopes: 

2444 state = ObjectStateRegistry._states.get(descendant_scope) 

2445 if state: 

2446 state._restore_saved_impl(propagate_descendants=True) 

2447 

2448 # Emit on_resolved_changed for changed params so SAME-LEVEL observers flash 

2449 # (e.g., list item subscribed to this ObjectState sees the revert as a change) 

2450 did_atomic_restore = False 

2451 if changed_params_with_types: 

2452 changed_paths = {param_name for param_name, _, _ in changed_params_with_types} 

2453 # Coalesce any updates triggered by callbacks into a single snapshot 

2454 if self._parent_state is None: 

2455 with ObjectStateRegistry.atomic(f"restore {self.scope_id}"): 

2456 self._notify_resolved_changed( 

2457 changed_paths, 

2458 context="restore_saved", 

2459 ) 

2460 did_atomic_restore = True 

2461 else: 

2462 self._notify_resolved_changed( 

2463 changed_paths, 

2464 context="restore_saved", 

2465 ) 

2466 

2467 # Sync materialized state (single point for dirty/sig_diff update + notification) 

2468 self._sync_materialized_state() 

2469 

2470 # Record snapshot for time-travel (registry-level) - ONLY if there were changes 

2471 if changed_params_with_types and not did_atomic_restore: 

2472 ObjectStateRegistry.record_snapshot("restore", self.scope_id) 

2473 

2474 def should_skip_updates(self) -> bool: 

2475 """Check if updates should be skipped due to batch operations.""" 

2476 return self._in_reset or self._block_cross_window_updates 

2477 

2478 # ==================== FLAT STORAGE METHODS (NEW) ==================== 

2479 

2480 @classmethod 

2481 def _extract_all_parameters_flat( 

2482 cls, 

2483 obj: Any, 

2484 parameters: dict[str, Any], 

2485 structure: ParameterStructure, 

2486 prefix: str = '', 

2487 exclude_params: list[str] | None = None, 

2488 ) -> None: 

2489 """Recursively extract parameters into flat dict with dotted paths. 

2490 

2491 Populate raw parameters and the existing declaration structure through 

2492 the canonical parameter analyzer, without constructing an editing state. 

2493 

2494 Args: 

2495 obj: Object to extract from (dataclass instance, callable, or regular object) 

2496 prefix: Current path prefix (e.g., 'partition_filter_config') 

2497 exclude_params: List of top-level parameter names to exclude 

2498 """ 

2499 exclude_params = exclude_params or [] 

2500 

2501 obj_type = type(obj) 

2502 is_function = isinstance(obj, FunctionType) 

2503 owner_target: ParameterOwner = obj if is_function else obj_type 

2504 

2505 # Delegate signature default extraction to python_introspect (it must derive 

2506 # defaults from the type/signature and avoid instance attribute reads). 

2507 param_info = cls._analyze_parameters(obj, exclude_params if not prefix else []) 

2508 

2509 logger.debug(f"🔧 _extract_all_parameters_flat: obj_type={obj_type.__name__}, prefix={prefix!r}, param_info keys={list(param_info.keys())}") 

2510 

2511 for param_name, info in param_info.items(): 

2512 # Skip excluded parameters (only at top level) 

2513 if not prefix and param_name in exclude_params: 

2514 continue 

2515 

2516 # Build dotted path 

2517 dotted_path = f'{prefix}.{param_name}' if prefix else param_name 

2518 

2519 # Get current value 

2520 if is_function: 

2521 # For functions: use signature default from UnifiedParameterAnalyzer 

2522 # (functions don't have instance attributes) 

2523 current_value = info.default_value 

2524 else: 

2525 # For class instances: bypass lazy resolution via object.__getattribute__ 

2526 try: 

2527 current_value = object.__getattribute__(obj, param_name) 

2528 except AttributeError: 

2529 current_value = info.default_value 

2530 

2531 structure.declarations[dotted_path] = info 

2532 

2533 # Check if this is a nested dataclass 

2534 # First try from type annotation, then fall back to checking actual value 

2535 nested_type = cls._get_nested_dataclass_type(info.param_type) 

2536 

2537 # The actual dataclass owns both its flattened fields and their 

2538 # reconstruction, including concrete nominal subtypes and lazy 

2539 # overlays. An annotation supplies the owner only without a value. 

2540 if current_value is not None and is_dataclass(type(current_value)): 

2541 nested_type = type(current_value) 

2542 

2543 if nested_type is not None and current_value is not None: 

2544 # Store the nested config type reference at this path 

2545 structure.owner_paths[dotted_path] = nested_type 

2546 

2547 # Store the nested dataclass instance in parameters (needed for UI rendering) 

2548 parameters[dotted_path] = current_value 

2549 

2550 # Recurse into nested dataclass for child fields 

2551 cls._extract_all_parameters_flat( 

2552 current_value, parameters, structure, prefix=dotted_path, exclude_params=[] 

2553 ) 

2554 else: 

2555 # Leaf field - store value and container type 

2556 parameters[dotted_path] = current_value 

2557 # Store the owner target that has this field. 

2558 structure.owner_paths[dotted_path] = owner_target 

2559 

2560 def _replace_parameter_structure( 

2561 self, 

2562 obj: Any, 

2563 *, 

2564 exclude_params: list[str] | None = None, 

2565 initial_values: dict[str, Any] | None = None, 

2566 ) -> None: 

2567 """Extract one canonical flat parameter structure and index it once.""" 

2568 

2569 self.parameters.clear() 

2570 self.parameter_structure = ParameterStructure.for_target(obj, exclude_params) 

2571 self._extract_all_parameters_flat( 

2572 obj, 

2573 self.parameters, 

2574 self.parameter_structure, 

2575 prefix="", 

2576 exclude_params=exclude_params, 

2577 ) 

2578 if initial_values: 

2579 for param_name, value in initial_values.items(): 

2580 self.parameters[param_name] = value 

2581 self.parameters.update( 

2582 self._dataclass_parameter_updates(param_name, value) 

2583 ) 

2584 self._index_parameter_paths() 

2585 

2586 def _index_parameter_paths(self) -> None: 

2587 """Index immutable direct-child topology from canonical flat parameters.""" 

2588 

2589 paths_by_owner: dict[DottedFieldPath, list[DottedFieldPath]] = {} 

2590 for dotted_path in self.parameters: 

2591 path = DottedFieldPath(dotted_path) 

2592 paths_by_owner.setdefault(path.parent, []).append(path) 

2593 self.parameter_structure.direct_paths = { 

2594 owner_path: tuple(parameter_paths) 

2595 for owner_path, parameter_paths in paths_by_owner.items() 

2596 } 

2597 

2598 def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True) -> Any: 

2599 """Reconstruct object from flat parameters with updated nested configs. 

2600 

2601 BOUNDARY METHOD - EXPENSIVE - only call at system boundaries: 

2602 - Save operation 

2603 - Execute operation 

2604 - Serialization 

2605 

2606 UNIFIED: Works for ANY object_instance type. 

2607 - Python functions: can't copy, return original 

2608 - Everything else: shallow copy + reconstruct nested dataclass fields 

2609 

2610 DELEGATION: If __objectstate_delegate__ was used: 

2611 - Reconstructs the delegate (e.g., pipeline_config) 

2612 - If update_delegate=True, updates the delegate attribute on object_instance 

2613 - Returns the reconstructed delegate (NOT object_instance) 

2614 - Callers needing the lifecycle object (orchestrator) should use state.object_instance 

2615 

2616 Args: 

2617 update_delegate: If True, apply reconstructed delegate to object_instance 

2618 sync_delegate: If True (default), check for delegate changes before reconstruction. 

2619 Set to False during cache recomputation to avoid re-entrant invalidation. 

2620 

2621 Returns: 

2622 The reconstructed object that matches the stored parameters. 

2623 For delegation, this is the delegate type (config), not the lifecycle object. 

2624 """ 

2625 # Auto-detect delegate changes before reconstruction (unless explicitly disabled) 

2626 if sync_delegate: 

2627 self._check_and_sync_delegate() 

2628 

2629 if self._cached_object is not None: 

2630 if not update_delegate: 

2631 return self._cached_object 

2632 if self._delegate_attr is None or self._cached_object_applied: 

2633 return self._cached_object 

2634 # Apply cached delegate to lifecycle object when requested 

2635 setattr(self.object_instance, self._delegate_attr, self._cached_object) 

2636 self._cached_object_applied = True 

2637 return self._cached_object 

2638 

2639 # UNIFIED: reconstruct nested dataclass fields 

2640 # Works for dataclass, non-dataclass class instances, AND functions 

2641 import copy 

2642 

2643 # For delegation, work with the extraction target (delegate), not object_instance 

2644 target = self._extraction_target 

2645 

2646 # Collect ALL top-level field updates from self.parameters 

2647 # This includes both primitive fields AND nested dataclass fields 

2648 field_updates = {} 

2649 root_type = type(target) 

2650 for field_name in self.parameter_structure.owner_paths: 

2651 if '.' not in field_name: 

2652 # Check if this field's TYPE is a dataclass (not the instance value) 

2653 # We need to check the TYPE because the instance value might be stale 

2654 # (e.g., self.parameters['partition_filter_config'] might have partition_filter=2 

2655 # even though self.parameters['partition_filter_config.partition_filter'] = None) 

2656 field_type = self.parameter_structure.owner_paths.get(field_name) 

2657 # CRITICAL FIX: _path_to_type stores CONTAINER type for leaf fields, 

2658 # but FIELD type for nested dataclass fields. We must distinguish: 

2659 # - If field_type == root_type, it's a leaf field (container type stored) 

2660 # - If field_type != root_type AND is_dataclass, it's a nested dataclass 

2661 is_nested_dataclass = ( 

2662 field_type is not None and 

2663 is_dataclass(field_type) and 

2664 field_type != root_type # Not the container type 

2665 ) 

2666 if is_nested_dataclass: 

2667 # Nested dataclass: ALWAYS recursively reconstruct from flat storage 

2668 # This ensures we pick up changes to nested fields like 

2669 # 'partition_filter_config.partition_filter' 

2670 logger.debug(f"🔧 to_object: Reconstructing nested dataclass '{field_name}' from flat storage") 

2671 reconstructed = self._reconstruct_from_prefix(field_name) 

2672 logger.debug(f"🔧 to_object: Reconstructed '{field_name}' type={type(reconstructed).__name__}") 

2673 # Log some field values for debugging 

2674 if hasattr(reconstructed, 'enabled'): 

2675 logger.debug(f"🔧 to_object: '{field_name}'.enabled = {reconstructed.enabled}") 

2676 field_updates[field_name] = reconstructed 

2677 else: 

2678 # Primitive field: use value directly from parameters 

2679 value = self.parameters.get(field_name) 

2680 field_updates[field_name] = value 

2681 

2682 # Reconstruct the target object (either object_instance or delegate) 

2683 reconstructed = None 

2684 

2685 # Python functions can't be copied, but we CAN update their attributes 

2686 # This is critical for MRO resolution to see edited config values 

2687 if isinstance(target, FunctionType): 

2688 for field_name, field_value in field_updates.items(): 

2689 setattr(target, field_name, field_value) 

2690 reconstructed = target 

2691 elif is_dataclass(target): 

2692 # CRITICAL: Use replace_raw to preserve raw None values! 

2693 # dataclasses.replace triggers lazy resolution via __getattribute__, 

2694 # which resolves None -> concrete defaults and breaks inheritance. 

2695 from objectstate.lazy_factory import replace_raw 

2696 reconstructed = replace_raw(target, **field_updates) 

2697 else: 

2698 # Non-dataclass class instance - shallow copy + setattr 

2699 obj_copy = copy.copy(target) 

2700 obj_type = type(target) 

2701 for field_name, field_value in field_updates.items(): 

2702 # Skip read-only properties (those without setters) 

2703 prop = getattr(obj_type, field_name, None) 

2704 if isinstance(prop, property) and prop.fset is None: 

2705 continue 

2706 setattr(obj_copy, field_name, field_value) 

2707 reconstructed = obj_copy 

2708 

2709 # DELEGATION: If using delegation, update the delegate attribute on object_instance 

2710 # as a side effect, but return the reconstructed delegate (not object_instance). 

2711 # This ensures callers get the correct type (config, not orchestrator). 

2712 # Callers who need the lifecycle object should access state.object_instance directly. 

2713 if self._delegate_attr is not None: 

2714 if update_delegate: 

2715 setattr(self.object_instance, self._delegate_attr, reconstructed) 

2716 self._cached_object_applied = True 

2717 else: 

2718 self._cached_object_applied = False 

2719 # Return the reconstructed delegate - this is what the parameters represent 

2720 self._cached_object = reconstructed 

2721 else: 

2722 # NON-DELEGATION: Update object_instance to point to reconstructed object 

2723 # This ensures that when to_object() is called (e.g., on window save), 

2724 # the ObjectState automatically points to the new instance 

2725 if update_delegate: 

2726 self.object_instance = reconstructed 

2727 self._extraction_target = reconstructed 

2728 logger.debug(f"Auto-updated object_instance to new reconstructed object for scope={self.scope_id!r}") 

2729 self._cached_object = reconstructed 

2730 self._cached_object_applied = True 

2731 

2732 return self._cached_object 

2733 

2734 def _reconstruct_from_prefix(self, prefix: str) -> Any: 

2735 """Recursively reconstruct dataclass from flat parameters. 

2736 

2737 Args: 

2738 prefix: Current path prefix (e.g., 'partition_filter_config') 

2739 

2740 Returns: 

2741 Reconstructed dataclass instance 

2742 """ 

2743 return self._reconstruct_from_parameter_snapshot(prefix, self.parameters) 

2744 

2745 def _reconstruct_from_parameter_snapshot( 

2746 self, 

2747 prefix: str, 

2748 parameters: dict[str, Any], 

2749 ) -> Any: 

2750 """Recursively reconstruct dataclass from a flat parameter snapshot.""" 

2751 # Determine the type to reconstruct 

2752 if not prefix: 

2753 # Root level - use extraction target type (handles delegation) 

2754 obj_type = type(self._extraction_target) 

2755 else: 

2756 # Nested level - look up type from _path_to_type 

2757 obj_type = self.parameter_structure.owner_paths.get(prefix) 

2758 if obj_type is None: 

2759 raise ValueError(f"No type mapping for prefix: {prefix}") 

2760 

2761 prefix_dot = f'{prefix}.' if prefix else '' 

2762 

2763 # Collect direct fields and nested prefixes 

2764 direct_fields = {} 

2765 nested_prefixes = set() 

2766 

2767 for path, value in parameters.items(): 

2768 if not path.startswith(prefix_dot): 

2769 continue 

2770 

2771 remainder = path[len(prefix_dot):] 

2772 

2773 if '.' in remainder: 

2774 # This is a nested field - collect the first component 

2775 first_component = remainder.split('.')[0] 

2776 nested_prefixes.add(first_component) 

2777 else: 

2778 # Direct field of this object 

2779 direct_fields[remainder] = value 

2780 

2781 # Reconstruct nested dataclasses first 

2782 for nested_name in nested_prefixes: 

2783 nested_path = f'{prefix_dot}{nested_name}' 

2784 nested_obj = self._reconstruct_from_parameter_snapshot( 

2785 nested_path, 

2786 parameters, 

2787 ) 

2788 direct_fields[nested_name] = nested_obj 

2789 

2790 # CRITICAL: Do NOT filter out None values! 

2791 # None can be a semantic value, such as "inherit from parent context". 

2792 # When a user explicitly resets a field to None, we MUST pass that None 

2793 # to the dataclass constructor so lazy resolution can walk up the MRO. 

2794 # Filtering None would cause the dataclass to use its class-level default 

2795 # instead of the user's explicit None, breaking inheritance. 

2796 

2797 # At root level, include constructor params excluded from editing. 

2798 if not prefix: 

2799 direct_fields.update(self.parameter_structure.excluded_values) 

2800 

2801 

2802 # Instantiate the dataclass with ALL fields including None values 

2803 result = obj_type(**direct_fields) 

2804 

2805 

2806 return result 

2807 

2808 def _get_changed_params_with_types( 

2809 self, old_target: Any, new_target: Any 

2810 ) -> list[tuple[str, type, str]]: 

2811 """ 

2812 Compare old and new extraction targets to find changed parameters. 

2813 

2814 Returns a list of tuples: (param_name, container_type, leaf_field_name) 

2815 """ 

2816 changed_params = [] 

2817 

2818 # Get all parameter names from the new extraction target 

2819 for param_name in self.parameters.keys(): 

2820 old_value = self._get_param_value_from_target(old_target, param_name) 

2821 new_value = self._get_param_value_from_target(new_target, param_name) 

2822 

2823 if old_value != new_value: 

2824 container_type = self.parameter_structure.owner_paths.get(param_name, type(self.object_instance)) 

2825 leaf_field_name = DottedFieldPath(param_name).field_name 

2826 changed_params.append((param_name, container_type, leaf_field_name)) 

2827 

2828 return changed_params 

2829 

2830 def _get_param_value_from_target(self, target: Any, param_name: str) -> Any: 

2831 """ 

2832 Get a parameter value from an extraction target by dotted path. 

2833 

2834 Handles nested dataclass attributes. 

2835 """ 

2836 if target is None: 

2837 return None 

2838 

2839 parts = param_name.split('.') 

2840 current = target 

2841 

2842 for part in parts: 

2843 if not hasattr(current, part): 

2844 return None 

2845 current = getattr(current, part) 

2846 

2847 return current