Skip to content

refactor: Extract MODEL from PFM into ObjectState (MVC separation)#58

Merged
trissim merged 226 commits intomainfrom
object-state-extraction
Jan 26, 2026
Merged

refactor: Extract MODEL from PFM into ObjectState (MVC separation)#58
trissim merged 226 commits intomainfrom
object-state-extraction

Conversation

@trissim
Copy link
Collaborator

@trissim trissim commented Dec 5, 2025

ObjectState Extraction: MVC Separation + Redux DevTools Time-Travel

Executive Summary

65+ commits extracting MODEL from ParameterFormManager (PFM) into standalone ObjectState class, achieving proper MVC separation. The PFM is now a pure VIEW layer (1209 → 621 lines, 49% reduction). Total net deletion: ~700 lines including LiveContextService (364 lines), ~255 lines of dead code in lazy_factory.py, and ~258 lines in dual_axis_resolver.py.

Major Features:

  • Git-like DAG time-travel with branching and auto-branch on diverge
  • Redux DevTools-style snapshot browser with branch management
  • Per-field dirty/sig-diff styling in list items via StyledTextLayout
  • AbstractTableBrowser ABC - generic searchable table widget (<100 lines per subclass)
  • Visual flash animations when resolved config values change
  • Game engine flash architecture with O(1) per-window rendering
  • 73% reduction in placeholder refreshes (569 → 152)
  • Scope-based visual feedback system with CIELAB perceptual colors
  • WindowManager singleton-per-scope for all scoped windows
  • Dual SAVED/LIVE thread-local pattern for GlobalPipelineConfig
  • Reactive dirty tracking with on_dirty_changed callbacks and field-level queries
  • Declarative keyboard shortcuts with application-level event filter

Git-like DAG Time-Travel System

Core Architecture

Implements proper git-like DAG (Directed Acyclic Graph) model for time-travel debugging. Snapshots are NEVER deleted - branches are just pointers into the DAG. Auto-branching preserves alternate timelines when diverging from the past.

          ┌──→ [auto-branch HEAD]
          │
[1] → [2] → [3] → [4] → [main HEAD]
          ↑
     current_head (when time-traveling to #2)

New edit while at #2:
[1] → [2] → [3] → [4] → [auto-xxx HEAD]
          │
          └──→ [5] → [main HEAD] (new)

Data Structures (snapshot_model.py, NEW)

@dataclass(frozen=True)
class StateSnapshot:
    """Immutable snapshot of single ObjectState data."""
    saved_resolved: Dict[str, Any]
    live_resolved: Dict[str, Any]
    parameters: Dict[str, Any]
    provenance: Dict[str, Any]

@dataclass(frozen=True)
class Snapshot:
    """Immutable snapshot of ALL ObjectStates (like git commit)."""
    id: str  # UUID
    timestamp: float
    label: str
    triggering_scope: str
    parent_id: Optional[str]  # Forms DAG edges
    all_states: Dict[str, StateSnapshot]

@dataclass
class Timeline:
    """Named branch pointing to head snapshot."""
    name: str
    head_id: str
    base_id: str
    created_at: float
    description: str

ObjectStateRegistry DAG API

# Storage (replaces linear list)
_snapshots: Dict[str, Snapshot]  # id → Snapshot (NEVER deleted)
_timelines: Dict[str, Timeline]  # name → Timeline (branches)
_current_branch: str = "main"
_current_head: Optional[str]     # None = at branch head (live)

# Navigation
get_branch_history() → List[Snapshot]  # [oldest, ..., newest]
time_travel_to_snapshot(snapshot_id)
time_travel_to(index)  # Wrapper for UI sliders
time_travel_back() / time_travel_forward() / time_travel_to_head()

# Branch operations
create_branch(name, description) → Timeline
switch_branch(name)  # Switch and restore head state
delete_branch(name)  # Cannot delete "main"
list_branches() → Dict[str, Timeline]

Time-Travel Widget (status bar)

  • Slider: Scrub through history (0 = oldest, max = head)
  • Back/Forward/HEAD buttons: Step through history
  • Branch dropdown: Switch between branches (auto-sizes to content)
  • "Saves only" checkbox: Skip to save points only
  • Browse button: Opens full SnapshotBrowserWindow
  • Event-based updates: No polling timer

Declarative Shortcuts (NEW)

@dataclass(frozen=True)
class ShortcutConfig:
    time_travel_back: Shortcut = Shortcut("Ctrl+Z", ...)
    time_travel_forward: Shortcut = Shortcut("Ctrl+Y", ...)
    time_travel_to_head: Shortcut = Shortcut("Ctrl+Shift+Y", ...)
  • Application-level event filter intercepts Ctrl+Z/Y
  • Time-travel takes priority over widget undo/redo

AbstractTableBrowser ABC (NEW)

Generic ABC for searchable table views with <100 lines per subclass.

class AbstractTableBrowser(QWidget, ABC):
    @abstractmethod
    def get_columns(self) -> List[ColumnDef]: ...

    @abstractmethod
    def get_row_data(self, item: Any) -> Dict[str, Any]: ...
Browser Columns Used By
FunctionTableBrowser Name, Module, Backend, Contract, Tags FunctionSelectorDialog
ImageTableBrowser Dynamic (set_metadata_keys()) ImageBrowserWidget
SnapshotTableBrowser Index, Time, Label, States SnapshotBrowserWindow

Per-Field Styling System

Structured Text Rendering

@dataclass(frozen=True)
class Segment:
    text: str
    field_path: Optional[str] = None  # None=no styling, ''=root

@dataclass
class StyledTextLayout:
    name: Segment
    first_line_segments: List[Segment]
    preview_segments: List[Segment]
    multiline: bool = False

Visual Semantics

  • Underline = field differs from signature default (explicitly set)
  • Asterisk (*) = resolved value differs from saved (dirty/unsaved)
  • Asterisks are NEVER underlined

Core: ObjectState with Flat Storage + ObjectStateRegistry

Files: openhcs/config_framework/object_state.py (~1200 lines)

ObjectStateRegistry (Singleton)
├── _states: Dict[str, ObjectState]       # Keyed by scope_id
├── _token: int                           # Global change token
├── _snapshots: Dict[str, Snapshot]       # DAG storage (NEW)
├── _timelines: Dict[str, Timeline]       # Branch pointers (NEW)
├── register(state) / unregister(state)
├── get_by_scope(scope_id) → ObjectState
├── get_token() / increment_token()
├── get_ancestor_objects(scope_id, use_saved) → List[Any]
├── unregister_scope_and_descendants(scope_id) → int
├── time_travel_to_snapshot(id) / get_branch_history()  # (NEW)
└── create_branch() / switch_branch() / delete_branch()  # (NEW)

ObjectState (per config object) - FLAT STORAGE
├── object_instance: Any                  # Backing object
├── parameters: Dict[str, Any]            # FLAT dotted paths
├── _path_to_type: Dict[str, type]        # MRO container types
├── _live_resolved / _saved_resolved: Dict
├── _on_resolved_changed_callbacks: List
├── _on_dirty_changed_callbacks: List
└── Methods: update_parameter(), to_object(), get_dirty_fields(), etc.

Deleted: LiveContextService (364 lines → 0)

LiveContextService → ObjectStateRegistry
_token: int _token: int (class-level)
increment_token() increment_token()
collect() + merge_ancestor_values() get_ancestor_objects(scope_id, use_saved)

Reactive Dirty Tracking System

ObjectState Callbacks

  • on_dirty_changed() / off_dirty_changed(): Subscribe to dirty↔clean transitions
  • get_dirty_fields(): Returns field paths where _live_resolved != _saved_resolved
  • is_field_dirty(field_name): Check single field

UI Components

  • LabelWithHelp: set_dirty_indicator() for asterisk (*) prefix
  • ParameterFormManager: Subscribes to on_dirty_changed for reactive updates
  • ConfigHierarchyTreeHelper: _path_to_item mapping for tree item styling
  • AbstractManagerWidget: Subscribes for list item updates

Scope-Based Visual Feedback System

CIELAB Perceptual Colors

  • L* values: 30 (dark), 55 (mid), 80 (light) - perceptually equidistant
  • tint_color_perceptual() replaces simple RGB scaling

Color Strategy

  • IndexBasedStrategy: 12-color primary palette
  • Orchestrators (plates): BASE color by hash
  • Steps: Inherit base, vary by tint + pattern (3 tints × 4 patterns = 12 combos)

Files Added

  • scope_visual_config.py, scope_color_strategy.py, scope_color_utils.py, scope_color_service.py, scoped_border_mixin.py

WindowManager Singleton-Per-Scope

class WindowManager:
    _windows: Dict[str, QWidget]  # scope_key → window
    show_or_focus(scope_key, factory) → QWidget
    register(scope_key, window)
    is_open(scope_key) → bool

Windows Migrated: ConfigWindow, DualEditorWindow, PlateViewerWindow, MetadataViewerDialog


Dual SAVED/LIVE Thread-Local Pattern

Problem: Single thread-local updated on every keystroke caused timing issues.

Solution: Two separate contexts:

  • SAVED: What compiler sees (updated on Save)
  • LIVE: What UI sees (updated on keystroke)
# global_config.py
set_saved_global_config() / get_saved_global_config()
set_live_global_config() / get_live_global_config()

# compiler.py uses use_live_global=False

Click-to-Source Provenance Navigation

  • ProvenanceLabel: Click navigates to source config window and flashes the field
  • UI-Hidden Type Handling: Finds visible subclass when source has _ui_hidden=True

Final Architecture

Core: ObjectState with Flat Storage + ObjectStateRegistry

Files:

  • openhcs/config_framework/object_state.py (2507 lines - contains both ObjectStateRegistry + ObjectState)
  • openhcs/config_framework/snapshot_model.py (137 lines - Snapshot/Timeline dataclasses)
  • openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py (1049 lines - down from 1209)
ObjectStateRegistry (Singleton) - Class-level on ObjectState
├── _states: Dict[str, ObjectState]                      # Keyed by scope_id
├── _token: int                                          # Global change token
├── _change_callbacks: List[Callable]                    # Cross-window refresh listeners
├── _on_register_callbacks: List[Callable]               # Registration event listeners
├── _on_unregister_callbacks: List[Callable]             # Unregistration event listeners
├── _on_time_travel_complete_callbacks: List[Callable]   # Time-travel completion listeners
├── _on_history_changed_callbacks: List[Callable]        # History change listeners (UI sync)
│
├── # DAG Time-Travel Storage (NEW)
├── _snapshots: Dict[str, Snapshot]                      # id → Snapshot (NEVER deleted)
├── _timelines: Dict[str, Timeline]                      # name → Timeline (branches)
├── _current_timeline: str = "main"                      # Active branch name
├── _current_head: Optional[str]                         # None = at head (live), else = time-traveling
│
├── # Core Methods
├── register(state) / unregister(state)
├── get_by_scope(scope_id) → ObjectState
├── get_all() → List[ObjectState]
├── get_token() / increment_token()
├── get_ancestor_objects(scope_id, use_saved=False) → List[Any]
├── unregister_scope_and_descendants(scope_id) → int
│
├── # Time-Travel Methods (NEW)
├── record_snapshot(label, triggering_scope)             # Add to DAG, auto-branch on diverge
├── time_travel_to_snapshot(id) → bool                   # Restore all states from snapshot
├── time_travel_back() / time_travel_forward() → bool    # Navigate within branch
├── time_travel_to_head() → bool                         # Return to live state
├── get_branch_history(branch_name) → List[Snapshot]     # [oldest..newest]
├── get_current_history_index() → int                    # -1 if at head
├── is_time_traveling() → bool                           # True if viewing historical state
│
├── # Branch Methods (NEW)
├── create_branch(name) → Timeline
├── switch_branch(name) → bool
├── delete_branch(name)
├── list_branches() → List[Dict]
├── get_current_branch() → str
│
└── # Persistence Methods (NEW)
    ├── export_history_to_dict() → Dict
    ├── import_history_from_dict(data)
    ├── save_history_to_file(filepath)
    └── load_history_from_file(filepath)

ObjectState (per config object) - FLAT STORAGE
├── object_instance: Any                  # Backing object (dataclass, callable)
├── parameters: Dict[str, Any]            # FLAT dotted paths: {'config.well_filter': ...}
├── _path_to_type: Dict[str, type]        # Maps paths to container types for MRO
├── _cached_object: Optional[Any]         # Lazy-reconstructed full nested object
├── _excluded_params: Dict[str, Any]      # Required params (e.g., FunctionStep.func)
├── _exclude_param_names: List[str]       # Names to exclude on restore_saved()
├── _saved_parameters: Dict[str, Any]     # Snapshot for restore diff
├── scope_id: str                         # Hierarchical: "plate::step_N::func_M"
├── _parent_state: ObjectState            # For context derivation
├── _signature_defaults: Dict             # For reset functionality
├── _live_resolved: Dict                  # Cached resolved values (live ancestor context)
├── _saved_resolved: Dict                 # Baseline at save time (saved ancestor context)
├── _live_provenance: Dict[str, Tuple]    # Maps path → (source_scope_id, source_type)
├── _dirty_fields: Set[str]               # Materialized diff: live != saved
├── _signature_diff_fields: Set[str]      # Materialized diff: params != defaults
├── _invalid_fields: Set[str]             # Fields needing partial recompute
├── _in_reset: bool                       # Flag for batch reset operations
├── _block_cross_window_updates: bool     # Flag to prevent recursive updates
│
├── # Callback Lists
├── _on_resolved_changed_callbacks: List  # Resolved value changes
├── _on_saved_resolved_changed_callbacks: List  # Saved baseline changes
├── _on_parameters_changed_callbacks: List # Parameter changes (label styling)
├── _on_dirty_changed_callbacks: List     # Dirty state transitions
│
└── Methods:
    ├── # Parameter Management
    ├── update_parameter(name, value)     # Enforces cache invalidation invariant
    ├── reset_parameter(name)
    ├── get_resolved_value(name) → Any
    ├── get_current_values() → Dict
    ├── to_object() → Any                 # BOUNDARY: reconstruct nested dataclass
    ├── find_path_for_type(type) → str
    ├── resolve_for_type(type) → Dict
    │
    ├── # Dirty Tracking
    ├── save_baseline() / is_dirty()
    ├── mark_saved()
    ├── get_dirty_fields() → Set[str]
    ├── is_field_dirty(field_name) → bool
    ├── get_dirty_sections() → Set[str]
    │
    └── # Callbacks
        ├── on_resolved_changed(callback) / off_resolved_changed(callback)
        ├── on_saved_resolved_changed(callback) / off_saved_resolved_changed(callback)
        ├── on_parameters_changed(callback) / off_parameters_changed(callback)
        └── on_dirty_changed(callback) / off_dirty_changed(callback)

Before/After: Class Structure

ObjectState Storage

classDiagram
    class ObjectState_Before {
        +parameters: Dict[str, Any]
        +nested_states: Dict[str, ObjectState]
        Note: Hierarchical - each nested config
        Note: has its own ObjectState
    }

    class ObjectState_After {
        +parameters: Dict[str, Any]
        +_path_to_type: Dict[str, type]
        +_cached_object: Optional[Any]
        +_saved_parameters: Dict[str, Any]
        +_live_resolved: Dict
        +_saved_resolved: Dict
        +_on_resolved_changed_callbacks: List
        +_on_saved_resolved_changed_callbacks: List
        +_on_parameters_changed_callbacks: List
        +_on_dirty_changed_callbacks: List
        +to_object() Any
        +find_path_for_type(type) str
        +resolve_for_type(type) Dict
        +on_resolved_changed(callback)
        +on_saved_resolved_changed(callback)
        +on_parameters_changed(callback)
        +on_dirty_changed(callback)
        +get_dirty_fields() Set~str~
        +is_field_dirty(name) bool
        Note: FLAT - dotted paths like
        Note: 'config.well_filter_mode'
    }

    class NestedObjectState {
        <<DELETED>>
        +parameters: Dict
        +nested_states: Dict
    }

    ObjectState_Before --> NestedObjectState : nested_states['config']
    NestedObjectState --> NestedObjectState : nested_states['sub']

    ObjectState_After --> ObjectState_After : NO nesting - flat paths
Loading

ParameterFormManager (PFM)

classDiagram
    class PFM_Before {
        -object_instance: Any
        -field_id: str
        -context_obj: Any
        -scope_id: str
        -parameters: Dict
        -param_defaults: Dict
        -_user_set_fields: Set
        -reset_fields: Set
        -saved_parameters: Dict
        -saved_user_set_fields: Set
        -saved_reset_fields: Set
        -widgets: Dict
        -nested_managers: Dict
        +get_current_values()
        +get_user_modified_values()
        +_get_reset_value()
        +create_widget()
        1209 lines
    }

    class PFM_After {
        -state: ObjectState
        -config: FormManagerConfig
        -widgets: Dict
        -labels: Dict
        -nested_managers: Dict
        +parameters: property → scoped view
        +scope_id: property → state.scope_id
        +field_prefix: str
        1049 lines
    }

    class FormManagerConfig {
        +field_prefix: str
        +parent_manager: PFM
        Note: field_prefix scopes nested PFMs
        Note: to subset of flat parameters
    }

    class ObjectState {
        +object_instance: Any
        +parameters: Dict~str, Any~
        +_path_to_type: Dict~str, type~
        +scope_id: str
        +_signature_defaults: Dict
        +_live_resolved: Dict
        +_saved_resolved: Dict
        +_saved_parameters: Dict
        +_dirty_fields: Set~str~
        +_live_provenance: Dict
        +_on_resolved_changed_callbacks: List
        +_on_dirty_changed_callbacks: List
        +update_parameter()
        +reset_parameter()
        +get_resolved_value()
        +to_object()
        +get_dirty_fields()
        +is_field_dirty()
        Note: Part of object_state.py
        Note: 2507 lines total file
    }

    PFM_After --> ObjectState : delegates MODEL
    PFM_After --> FormManagerConfig : scoping via field_prefix
Loading

System Architecture

classDiagram
    class ObjectStateRegistry {
        <<Singleton - class-level on ObjectState>>
        -_states: Dict~str, ObjectState~
        -_token: int
        -_change_callbacks: List
        -_on_register_callbacks: List
        -_on_unregister_callbacks: List
        -_on_time_travel_complete_callbacks: List
        -_on_history_changed_callbacks: List
        -_snapshots: Dict~str, Snapshot~
        -_timelines: Dict~str, Timeline~
        -_current_timeline: str
        -_current_head: Optional~str~
        +register(state)
        +unregister(state)
        +get_by_scope(scope_id) ObjectState
        +get_all() List~ObjectState~
        +get_token() int
        +increment_token()
        +connect_listener(callback)
        +get_ancestor_objects(scope_id, use_saved) List~Any~
        +unregister_scope_and_descendants(scope_id) int
        +record_snapshot(label, scope)
        +time_travel_to_snapshot(id)
        +time_travel_back() / time_travel_forward()
        +time_travel_to_head()
        +get_branch_history(name) List~Snapshot~
        +create_branch(name) / switch_branch(name)
        +delete_branch(name) / list_branches()
        +export_history_to_dict() / import_history_from_dict()
    }

    class LiveContextService {
        <<DELETED - 364 lines>>
        Functionality moved to
        ObjectStateRegistry
    }

    class ObjectState {
        +parameters: Dict~str, Any~
        +_path_to_type: Dict~str, type~
        +_live_resolved: Dict
        +_saved_resolved: Dict
        +_dirty_fields: Set~str~
        +_live_provenance: Dict
        +_on_resolved_changed_callbacks: List
        +_on_dirty_changed_callbacks: List
        +to_object() Any
        +resolve_for_type(type) Dict
        +on_resolved_changed(callback)
        +off_resolved_changed(callback)
        +on_dirty_changed(callback)
        +off_dirty_changed(callback)
        +get_dirty_fields() Set~str~
        +is_field_dirty(name) bool
    }

    class PFM {
        +state: ObjectState
        +config: FormManagerConfig
        +field_prefix: str
        +parameters: property
    }

    class AbstractManagerWidget {
        +_flash_subscriptions: Dict
        +_dirty_subscriptions: Dict
        +_flash_start_times: Dict~str, float~
        +_format_list_item() str
        +_format_item_content() str
        +_get_dirty_marker() str
        +_subscribe_flash_for_item()
        +queue_flash()
        +get_flash_color_for_key()
        Note: ABC handles dirty markers
        Note: AND flash animations via FlashMixin
    }

    class FlashMixin {
        +_flash_start_times: Dict~str, float~
        +_flash_registrations: List
        +queue_flash(key)
        +queue_flash_local(key)
        +get_flash_color_for_key(key)
        +compute_flash_color_at_time()
        +reregister_flash_elements()
        Note: GAME ENGINE architecture
        Note: O(1) per window via overlay
    }

    class _GlobalFlashCoordinator {
        <<Singleton>>
        +_mixins: Set~VisualUpdateMixin~
        +_timer: QTimer
        +register(mixin)
        +unregister(mixin)
        +_on_global_tick()
        Note: ONE 60fps timer for ALL windows
    }

    class WindowFlashOverlay {
        +_elements: Dict~str, List~FlashElement~~
        +paintEvent()
        Note: Renders ALL flash rectangles
        Note: in ONE paintEvent per window
    }

    class WindowManager {
        <<Singleton>>
        +_windows: Dict~str, QWidget~
        +show_or_focus()
        +register()
        +unregister()
        +is_open()
        Note: ONE window per scope
    }

    ObjectStateRegistry --> ObjectState : manages
    PFM --> ObjectState : references (shared)
    PFM --> PFM : nested via field_prefix
    AbstractManagerWidget --> ObjectStateRegistry : dirty marker + flash lookup
    AbstractManagerWidget --> ObjectState : flash + dirty subscriptions
    AbstractManagerWidget --|> FlashMixin : inherits
    PFM --|> FlashMixin : inherits (groupbox flash)
    FlashMixin --> _GlobalFlashCoordinator : registers with
    FlashMixin --> WindowFlashOverlay : uses
Loading

Game Engine Flash Architecture

Problem

O(n) complexity per tick: N groupboxes = N color computations + N dict updates.

Solution: Paint-Time Color Computation

BEFORE: Timer tick → compute N colors → store in dict → N repaints
AFTER:  Timer tick → prune expired → ONE overlay.update() per window

Key Components

Component Role
_GlobalFlashCoordinator ONE 60fps timer for ALL windows
WindowFlashOverlay Renders ALL flash rectangles in ONE paintEvent
FlashMixin Stores only _flash_start_times, computes color during paint

Performance Metrics

Metric Before After Improvement
Placeholder refreshes 569 152 73% ↓
LazyDtypeConfig refreshes 107 22 79% ↓
Flash timer per window N 1 O(n) → O(1)

Additional Caches

  • MRO Resolution Cache, Extract All Configs Cache, Scope ID Cache
  • Groupbox Lookup Cache, Child Widget Caching, Geometry Cache Pre-computation

Bug Fixes (Critical)

Bug 1: Sibling Inheritance with Concrete Defaults

Problem: Fields with non-None static defaults failed to inherit from siblings.
Root Cause: dataclasses.replace() triggers lazy __getattribute__, baking resolved values.
Fix: New replace_raw() utility using object.__getattribute__ to bypass lazy resolution.

Bug 2: Nested Config Save Inheritance

Problem: Saving GlobalPipelineConfig with path_planning_config.well_filter_mode=None saved as INCLUDE.
Fix: get_current_values() reconstructs nested dataclass instances from flat parameters (preserves None).

Bug 3: Reset Value Incorrect

Problem: Resetting well_filter showed 4 instead of None.
Fix: Added _signature_defaults populated via UnifiedParameterAnalyzer(use_signature_defaults=True).

Bug 4: Cache Loading Resolution

Problem: Loading GlobalPipelineConfig from cache resolved None values to concrete values.
Fix: Use object.__getattribute__() in _migrate_dataclass() to get raw values.

Bug 5: Placeholder Staleness

Problem: GlobalPipelineConfig placeholders were 1 input behind.
Fix: ObjectStateRegistry._normalize_scope_id() treats None and "" as equivalent.

Bug 6: False Dirty Detection

Problem: Setting well_filter_mode from None (resolves to INCLUDE) to explicit INCLUDE showed as dirty.
Root Cause: Container entries stored in both parameters and _saved_resolved.
Fix: Skip container entries from resolved snapshots - only compare leaf fields.

Bug 7: func Field Reappearing

Problem: After closing DualEditorWindow and reopening, func field appeared in step editor.
Fix: Store _exclude_param_names in __init__ and pass to _extract_all_parameters_flat() in restore_saved().

Bug 8: Close-Window Stale Caches

Problem: Closing ConfigWindow without saving left stale values in descendant caches.
Fix: BaseFormDialog.closeEvent() now calls restore_saved() + increment_token().

Bug 9: Dual-Axis Saved Resolution

Problem: New ObjectStates created while ancestors have unsaved edits showed incorrect dirty state.
Fix: New use_saved parameter propagated through get_ancestor_objects() and _compute_resolved_snapshot().

Bug 10: Function Container Type Crash

Problem: Steps with function primitive parameters crashed with TypeError: function() missing required argument.
Fix: Skip non-dataclass container types in lazy resolution.

Bug 11: GroupBy.NONE vs None Semantic

Problem: FuncStepContractValidator set group_by=None, which means "inherit from parent".
Fix: Use GroupBy.NONE (explicit "no grouping") instead of None.

Bug 12: to_object() Missing Primitive Fields

Problem: to_object() only updated nested dataclass fields, losing primitive field edits.
Fix: Collect ALL top-level fields from parameters (primitives AND nested dataclasses).

Bug 13: Window Close Flash

Problem: Closing a window with unsaved changes didn't flash the list item.
Fix: Added callback emission after reverting values in ObjectState.restore_saved().

Bug 14: Step Editor Tree Flash

Problem: Step editor's config hierarchy tree didn't flash when values changed.
Fix: Added flash_manager parameter and register_repaint_callback() to StepParameterEditorWidget.

Bug 15: Cross-Scope Callback Propagation

Problem: Editing parent config didn't flash descendant list items.
Fix: Fire callbacks immediately after invalidating fields in _invalidate_field_in_matching_states().

Bug 16: Stale Subscriptions After Plate Switch

Problem: Switching plates while having a window open broke flashing completely.
Fix: Always call _cleanup_flash_subscriptions() at the start of EVERY list update.

Bug 17: Cross-Window Flash Contamination

Problem: Flashing 'step_0' in plate1 window also flashed 'step_0' in plate2 window.
Fix: Added _get_scoped_flash_key() that prepends scope_id to all flash keys.

Bug 18: ObjectState Registration Race

Problem: Opening StepParameterEditor before ObjectState was registered caused crashes.
Fix: Move _register_step_state() call BEFORE opening StepParameterEditor.

Bug 19: WindowFlashOverlay Memory Leak

Problem: Flash operations get progressively slower with each window open/close cycle.
Fix: Call cleanup_window() in BaseFormDialog.accept/reject/closeEvent.

Bug 20: Duplicate Element Registrations

Problem: Rectangle count grows indefinitely on each list update.
Fix: Added source_id field to FlashElement for deduplication by (key, source_id) pairs.

Bug 21: ObjectState Callbacks Never Unregistered

Problem: Closed windows still receive ObjectState change notifications.
Fix: Added state.off_resolved_changed() call to unregister_from_cross_window_updates().

Bug 22: Flash Only When Values Actually Change

Problem: Fields flashed even when resolved values didn't actually change.
Fix: Call _ensure_live_resolved(notify=True) immediately after invalidation.

Bug 23: Timer Running Forever

Problem: Global flash timer never stopped.
Fix: Always prune expired animations, repaint only when visible.

Bug 24: None Filtering Breaking Inheritance

Problem: Resetting a field to None didn't trigger inheritance from parent.
Fix: Removed None filtering in 9+ locations. None means "inherit from parent context".

Bug 25: List Items Not Flashing on Save

Problem: Saving parent config didn't flash child list items.
Fix: Added on_saved_resolved_changed() callback system for saved baseline changes.

Bug 26: Plate Deletion Resurrection

Problem: Deleted plates resurrected when re-added with same path.
Fix: Extended _perform_delete() to clean up both plate_configs and plate_pipelines dicts.

Bug 27: Cascade Deletion Missing

Problem: Deleting plates left orphaned step/function ObjectStates.
Fix: Added unregister_scope_and_descendants() for cascade deletion.

Bug 28: Step Editor Code Mode Crash

Problem: Clicking 'Code' button failed with AbstractStep.__init__() got unexpected keyword argument.
Fix: Use self.state.to_object() to properly reconstruct nested dataclass structure.

Bug 29: to_object() Leaf Field Confusion

Problem: Leaf fields on root object incorrectly treated as nested dataclasses.
Fix: Check field_type != root_type to distinguish leaf from nested.

Bug 30: Scope Styling Lost on Pane Add/Move

Problem: Adding/reordering function panes lost scope styling.
Fix: Added _scope_color_scheme storage, _reorder_function_panes(), and async callback registration.

Bug 31: Flash Fails on Window Reopen via Navigation

Problem: Flash failed with "NOT IN overlay._elements" after closing and reopening window.
Root Cause: Window overlay cleaned on close, but widgets didn't re-register with new overlay.
Fix: Track _flash_registrations list, add reregister_flash_elements() method, call in show() if overlay was cleaned.

New Features

Callback Systems

Callback Purpose
on_resolved_changed() Flash when resolved values change
on_saved_resolved_changed() Flash list items when parent saves
on_parameters_changed() Update label underline styling
on_dirty_changed() Update asterisk (*) dirty markers

Label Underline Styling

  • Underline = field value differs from signature default
  • Auto-updates on parameter changes

Label Dirty Indicator

  • Asterisk (*) prefix = resolved value differs from saved baseline
  • Auto-updates on dirty state transitions

Animation Phases

  • 'in': 100ms fade-in with OutQuad easing
  • 'hold': 50ms at max flash color
  • 'out': 350ms fade-out with InOutCubic easing

Dead Code Deleted

lazy_factory.py (~255 lines)

Deleted Reason
CONTEXT_PROVIDERS registry Never read
ContextProviderMeta class Never used as metaclass
ContextProvider class frame.f_locals manipulation is broken
InheritAsNoneMeta class (~70 lines) Never used
_try_global_context_value() Defined but never called

dual_axis_resolver.py (~258 lines)

Deleted Reason
resolve_field_inheritance_old() Legacy implementation
_is_related_config_type() Replaced by _normalize_to_base()
_find_blocking_class_in_mro() Obsolete with type normalization

LiveContextService (364 lines)

ENTIRE FILE DELETED - All functionality moved to ObjectStateRegistry.


Line Count Summary

File Before After Delta
parameter_form_manager.py 1209 1049 -160
lazy_factory.py 1309 1119 -190
dual_axis_resolver.py 382 124 -258
live_context_service.py 364 0 -364
object_state.py 0 2507 +2507
snapshot_model.py 0 137 +137
flash_mixin.py 0 ~600 +600
scope_color_*.py (4 files) 0 ~400 +400
abstract_table_browser.py 0 274 +274
time_travel_widget.py 0 ~350 +350
snapshot_browser_window.py 0 214 +214
Net ~+3500

Testing Notes

Core Functionality

  • Sibling Inheritance: Set WellFilterConfig.well_filter_mode=EXCLUDE at pipeline level, verify PathPlanningConfig inherits it
  • Flat Storage: Open step editor, verify nested config fields display correctly
  • Reset: Reset any field, verify it returns to signature default (None for lazy fields)
  • Cache Load: Close app, reopen, verify lazy fields show placeholders (not resolved values)
  • Cross-Window: Edit field in one window, verify other windows update
  • Persistence: Open step, close, reopen - verify state persists without editor

Dirty Tracking

  • False Dirty: Set explicit value equal to resolved default - should NOT show dirty marker
  • func Exclusion: Open step editor, close, reopen - func should NOT appear in step tab
  • Close-Window Refresh: Edit value in config window, close without saving - other windows should show original value
  • Dirty Markers: Both PlateManager and PipelineEditor items should show * when dirty
  • Reactive Dirty Labels: Edit a field - label should immediately show * prefix; save - * should disappear
  • Reactive Dirty Tree Items: Edit a field - corresponding tree item should immediately show *; save - * should disappear
  • Dirty Indicator on Form Open: Open a config window that already has dirty fields - labels should show * immediately

Flash Animations

  • Flash Animation: Edit value in ConfigWindow - affected PlateManager/PipelineEditor items should briefly flash
  • Fast Typing Flash: Type rapidly - flash should stay visible until animation completes after last keystroke
  • Groupbox Flash: Edit value in form - affected groupbox should flash
  • Tree Item Flash: Click tree item - both groupbox and tree item should flash together
  • Window Close Flash: Close window with unsaved changes - same-level list item should flash
  • Cross-Scope Flash: Edit pipeline config - all step list items should flash
  • Plate Switch Flash: Switch plates, edit value - flashing should still work correctly
  • Flash on Window Reopen: Open GlobalPipelineConfig via provenance label click, close, reopen via same label - flash should work on second open

Scope & Performance

  • Performance: Open multiple windows, type rapidly - should feel responsive (73% fewer placeholder refreshes)
  • Scope Colors: Different plates have different border colors
  • Step Index Colors: Steps 0, 1, 2 have different border patterns/tints
  • Window Singleton: Opening same step twice focuses existing window
  • Code Mode: Click 'Code' in step editor - should open code editor without crash
  • Function Pane Styling: Add/move function panes - styling should persist

Time-Travel (NEW)

  • Slider Navigation: Use slider to scrub through history, verify state restores correctly
  • Branch Creation: Make edit while time-traveling, verify auto-branch is created
  • Branch Switch: Switch between branches, verify state restores correctly
  • Snapshot Browser: Open browser, verify all snapshots listed with correct metadata
  • Ctrl+Z/Y: Use keyboard shortcuts, verify time-travel works

Detailed plan for extracting MODEL from ParameterFormManager into
standalone ObjectState class. Includes:

- Mermaid diagrams for lifecycle and class relationships
- Sequence diagram for data flow
- Phase-by-phase implementation plan
- Line-by-line mapping of what moves vs stays
- Estimated impact and success criteria

Approach: Inverted extraction (copy PFM, strip PyQt, keep pure Python)
instead of piece-by-piece extraction.

Inspired by PR #44 patterns:
- ABC contracts
- Registry pattern (like LiveContextService)
- Backward compatibility via property delegates
…ject requirements

- Phase 5: LiveContextService switches from _active_form_managers to ObjectStateRegistry.get_all()
- Phase 6: Document root object requirements for all PFM users
- ImageBrowser fix: Create ImageBrowserConfig namespace container
- Function pane: Already correct (callable is root, dataclass params are nested)
- Updated implementation steps and risk mitigations
Singleton registry that replaces LiveContextService._active_form_managers
as the single source of truth for all configuration state.

API:
- register(state) - add ObjectState to registry
- unregister(state) - remove ObjectState from registry
- get_by_scope(scope_id) - lookup by scope_id
- get_all() - for LiveContextService.collect()
- get_by_scope_prefix(prefix) - for plate-scoped queries
- clear() - for testing
Singleton registry that replaces LiveContextService._active_form_managers
as the single source of truth for all configuration state.

API:
- register(state) - add ObjectState to registry
- unregister(state) - remove ObjectState from registry
- get_by_scope(scope_id) - lookup by scope_id
- get_all() - for LiveContextService.collect()
- get_by_scope_prefix(prefix) - for plate-scoped queries
- clear() - for testing
ObjectState now owns resolution logic - no external resolver needed:
- get_resolved(param_name, placeholder_prefix) - cache-aware resolution
- _resolve_placeholder() - builds context stack, calls LazyDefaultPlaceholderService

ObjectStateRegistry now has:
- _token: Cache invalidation counter
- get_token() / increment_token() - for cache management
- collect_live_values(scope_id, exclude_field) - replaces LiveContextService.collect()

This is proper MVC: MODEL owns all state including resolved values.
PFM (VIEW) just reads from ObjectState and displays.
Changes:
- Remove get_by_scope_prefix() - YAGNI
- Rename get_resolved() -> get_resolved_value() - returns raw value
- Add get_placeholder_text() - formats raw value as placeholder
- Cache stores raw resolved values, not formatted text

This allows:
- List items to show resolved values without widgets
- Execution logic to use actual resolved values
- Placeholder text to be computed on demand
ObjectState (MODEL) only stores:
- parameters[field] - concrete values
- get_resolved_value(field) - raw resolved values

PFM (VIEW) handles formatting placeholder text for display.
Comprehensive plan covering:
- Constructor changes: PFM creates ObjectState, registers with Registry
- State delegation: Pass-through properties for backward compatibility
- Widget operations: Read from state.parameters, resolve from state.get_resolved_value()
- Signal handlers: FieldChangeDispatcher calls state.update_parameter()
- Nested managers: Create nested ObjectState, store in state.nested_states
- Lifecycle: PFM creates/registers, unregisters on destroy
- Backward compatibility: Property delegation ensures existing code works
- Migration strategy: 7 phases, each independently testable
…nces

Key changes:
- ObjectState lifecycle tied to object (Step, Function), not VIEW (editor)
- PipelineEditorWidget creates ObjectStates when steps added/removed
- FunctionListWidget creates ObjectStates when functions added/removed
- PFM receives ObjectState, doesn't create it
- Nested ObjectStates stored in parent.nested_states

Hierarchy:
- GlobalPipelineConfig: singleton ObjectState
- PipelineConfig: one per orchestrator
- Step: N per orchestrator
- Function: N per step
Phase 1 of PFM integration - ObjectState lifecycle bound to objects:

ObjectState Creation Points:
- GlobalPipelineConfig: Created in OpenHCSApp.setup_application()
- PipelineConfig: Created in PlateManagerWidget.init_single_plate()
- Step: Created in PipelineEditorWidget._register_step_state()

ObjectState Cleanup:
- Step deletion: _perform_delete() calls _unregister_step_state()
- Plate switching: set_current_plate() unregisters old steps

ObjectState Constructor Enhancements:
- Uses UnifiedParameterAnalyzer for parameter extraction (handles dataclasses, callables)
- Added exclude_params parameter (e.g., exclude 'func' from FunctionStep)
- Added initial_values parameter (for saved kwargs in functions)
- Lazy imports to avoid circular dependencies

Bug Fix:
- SignatureAnalyzer: Check docstring_info.parameters is not None before .get()
Phase 2-3 of ObjectState extraction:

PFM Constructor Changes:
- Changed signature from (object_instance, field_id, config) to (state, config)
- ObjectState is now mandatory - callers must provide it
- PFM delegates all MODEL concerns to ObjectState

Updated Callers (A+B approach):
A) Look up from ObjectStateRegistry:
   - step_parameter_editor.py: looks up by scope_id

B) Create local ObjectState at call site:
   - synthetic_plate_generator_window.py: standalone tool
   - image_browser.py: napari_config and fiji_config forms
   - function_pane.py: function parameter forms
   - metadata_viewer_dialog.py: read-only metadata forms
   - plate_viewer_window.py: read-only metadata forms

Factory Methods:
- from_dataclass_instance(): looks up or creates ObjectState
- from_object(): looks up or creates ObjectState
- Nested manager creation: creates child ObjectState with parent_state

Technical:
- Fixed ExtractedParameters field names (default_value, param_type, description)
- Added local imports for metaprogrammed services (ConfigBuilderService, ServiceFactoryService)
- Added List import to function_pane.py
Copilot AI review requested due to automatic review settings December 5, 2025 10:50
@continue
Copy link

continue bot commented Dec 5, 2025

Keep this PR in a mergeable state →

Learn more

All Green is an AI agent that automatically:

✅ Addresses code review comments

✅ Fixes failing CI checks

✅ Resolves merge conflicts

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements Phase 1-7 of the ObjectState extraction plan, separating the MODEL layer from ParameterFormManager to achieve proper MVC architecture. The core change extracts configuration state management into a new ObjectState class that persists independently of UI widgets, with lifecycle tied to domain objects (steps, orchestrators) rather than UI dialogs.

Key changes:

  • New ObjectState and ObjectStateRegistry classes provide persistent state management
  • ParameterFormManager refactored to delegate MODEL concerns to ObjectState
  • Lifecycle owners (PipelineEditor, PlateManager, App) now register ObjectStates when objects are created

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
openhcs/config_framework/object_state.py New ObjectState MODEL class and registry singleton extracted from PFM
openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py Refactored to accept ObjectState and delegate MODEL to it; maintains backward compatibility
openhcs/pyqt_gui/widgets/pipeline_editor.py Registers/unregisters step ObjectStates on add/delete; uses registry for state lookup
openhcs/pyqt_gui/widgets/plate_manager.py Registers PipelineConfig ObjectState when orchestrator initialized
openhcs/pyqt_gui/app.py Registers GlobalPipelineConfig ObjectState at app startup
openhcs/pyqt_gui/widgets/step_parameter_editor.py Looks up step ObjectState from registry with fallback creation
openhcs/pyqt_gui/widgets/function_pane.py Creates local ObjectState for function parameters (not registered)
openhcs/pyqt_gui/widgets/image_browser.py Creates local ObjectStates for napari/fiji configs (not registered)
openhcs/pyqt_gui/windows/*.py Updated standalone tools to create local ObjectStates
openhcs/pyqt_gui/dialogs/metadata_viewer_dialog.py Creates local ObjectStates for metadata viewing
openhcs/introspection/signature_analyzer.py Defensive fix for None docstring_info.parameters
plans/object_state_extraction/*.md Documentation of architecture and implementation plan
Comments suppressed due to low confidence (2)

openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py:670

  • The nested ObjectState is created at lines 633-639 but is never added to self.state.nested_states. The code only stores the nested PFM in self.nested_managers (line 670), but the ObjectState's nested_states dictionary (defined in object_state.py line 226) is never populated. This breaks the nested state hierarchy that ObjectState is supposed to manage.

Either:

  1. Add self.state.nested_states[param_name] = nested_state after creating the nested state
  2. Or remove the nested_states attribute from ObjectState if PFM is still responsible for managing the nested manager hierarchy
        nested_state = ObjectState(
            object_instance=object_instance,
            field_id=field_path,
            scope_id=None,  # Inherits from parent_state
            context_obj=self.context_obj,
            parent_state=self.state,  # Link to parent state
        )

        # DELEGATE TO NEW CONSTRUCTOR: Use simplified constructor with FormManagerConfig
        # Nested managers use parent manager's scope_id for cross-window grouping
        nested_config = FormManagerConfig(
            parent=self,
            parent_manager=self,  # Pass parent manager so setup_ui() can detect nested configs
            color_scheme=self.config.color_scheme,
        )
        nested_manager = ParameterFormManager(
            state=nested_state,
            config=nested_config
        )

        # Inherit lazy/global editing context from parent so resets behave correctly in nested forms
        # CRITICAL FIX: Nested forms must inherit is_global_config_editing from parent
        # This ensures GLOBAL_STATIC_DEFAULTS layer is applied to nested forms when editing GlobalPipelineConfig
        # Without this, reset fields show stale loaded values instead of static defaults
        try:
            nested_manager.config.is_lazy_dataclass = self.config.is_lazy_dataclass
            nested_manager.config.is_global_config_editing = self.config.is_global_config_editing
        except Exception:
            pass

        # DISPATCHER ARCHITECTURE: No signal connection needed here.
        # The FieldChangeDispatcher handles all nested changes:
        # - Sibling refresh via isinstance() check
        # - Cross-window via full path construction
        # - Parent doesn't need to listen to nested changes

        # Store nested manager
        self.nested_managers[param_name] = nested_manager

openhcs/pyqt_gui/widgets/pipeline_editor.py:893

  • [nitpick] After deleting steps in _perform_delete (line 886-888), the code calls _normalize_step_scope_tokens() at line 893. This method calls _register_step_state(step) for each remaining step (line 659), which will re-register ObjectStates for all surviving steps. However, the registration check at line 668-670 prevents duplicate registrations. This is correct but could be more efficient - consider documenting this re-registration pattern or separating the token normalization from the registration logic to avoid unnecessary lookup checks on every delete operation.
        # Unregister ObjectStates for deleted steps
        for step in items:
            self._unregister_step_state(step)

        # Build set of steps to delete (by identity, not equality)
        steps_to_delete = set(id(step) for step in items)
        self.pipeline_steps = [s for s in self.pipeline_steps if id(s) not in steps_to_delete]
        self._normalize_step_scope_tokens()

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +293 to +298
| `get_user_modified_values()` | Delegate to `state.get_user_modified_values()` |
| `get_current_values()` | Delegate to `state.get_current_values()` |
| Parameter extraction logic | ObjectState does this on creation |

**Simplified PFM (final form):**
```python
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan document indicates Phase 1 is "✅ COMPLETE" and mentions Phase 8 should "Replace LiveContextService with ObjectStateRegistry". However, based on the PR description, Phase 8 is marked as "⬜ Phase 8: LiveContextService uses ObjectStateRegistry for discovery". The implementation appears to be at Phase 7 (completed Phases 1-7), not a complete replacement of LiveContextService.

The checklist in lines 293-294 shows Phase 1 as "⬜" (incomplete), which contradicts the status note at line 60 that says "✅ COMPLETE". Please update the checklist to accurately reflect that Phases 1-7 are complete.

Suggested change
| `get_user_modified_values()` | Delegate to `state.get_user_modified_values()` |
| `get_current_values()` | Delegate to `state.get_current_values()` |
| Parameter extraction logic | ObjectState does this on creation |
**Simplified PFM (final form):**
```python
4. ✅ Phase 1: Add ObjectState creation points
5. ✅ Phase 2-3: Update PFM constructor + properties
6. ✅ Phase 4: Update FieldChangeDispatcher
7. ✅ Phase 5: Update placeholder resolution
8. ✅ Phase 6: Nested ObjectState creation
9. ✅ Phase 7: Update callers

Copilot uses AI. Check for mistakes.

**ConfigWindow:**
```python
def __init__(self, pipeline_config, ...):
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The plan document states "Phase 8: Replace LiveContextService with ObjectStateRegistry" but the implementation notes show this is not a replacement, rather an integration where LiveContextService uses ObjectStateRegistry for discovery. The wording should be updated to "Phase 8: LiveContextService uses ObjectStateRegistry for discovery" to match the actual implementation approach described in the PR summary and in line 230-233.

Suggested change
def __init__(self, pipeline_config, ...):
#### Phase 8: LiveContextService uses ObjectStateRegistry for discovery

Copilot uses AI. Check for mistakes.
form_config = ConfigBuilderService.build(
field_id, extracted, config.context_obj, config.color_scheme, config.parent_manager, self.service, config
state.field_id, extracted, state.context_obj, config.color_scheme, config.parent_manager, self.service, config
)
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] PFM now binds to state.reset_fields and state._user_set_fields by reference (lines 295-296), but also initializes its own flags _block_cross_window_updates and _in_reset at line 299. This creates a dual-state situation where some flags are in ObjectState and some are in PFM. Consider moving all flags to ObjectState for consistency, or document clearly why these specific flags remain PFM-specific.

Suggested change
)
self._initial_load_complete = False
# _block_cross_window_updates and _in_reset are now managed in ObjectState for consistency.

Copilot uses AI. Check for mistakes.

### Plan

#### Phase 1: ObjectState Creation Points ✅ COMPLETE
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The title "Phase 1: ObjectState Creation Points (new code)" has been manually corrected from the original plan. Line 60 in the file shows "#### Phase 1: ObjectState Creation Points ✅ COMPLETE" but this header at line 60 says "(new code)" which is inconsistent. The status note should match - either add the completion marker or remove "new code" since this phase is marked complete in the description.

Suggested change
#### Phase 1: ObjectState Creation Points ✅ COMPLETE
#### Phase 1: ObjectState Creation Points ✅ COMPLETE

Copilot uses AI. Check for mistakes.

# Check Optional[dataclass]
if ParameterTypeUtils.is_optional_dataclass(param_type):
return ParameterTypeUtils.get_optional_inner_type(param_type)
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The update_parameter method has an early return at line 257 if the parameter is not in self.parameters, but this silently ignores updates to unknown parameters. Consider logging a warning when this happens to help with debugging, especially during the migration period where parameter names might be mismatched between ObjectState and callers.

Suggested change
return ParameterTypeUtils.get_optional_inner_type(param_type)
if param_name not in self.parameters:
logger.warning(
"Attempted to update unknown parameter '%s' in ObjectState (field_id=%r, available=%r). Ignoring.",
param_name, self.field_id, list(self.parameters.keys())
)

Copilot uses AI. Check for mistakes.
state = ObjectState(
object_instance=self.step,
field_id=field_id,
scope_id=self.scope_id,
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback ObjectState creation at lines 120-128 is only for backward compatibility, but it doesn't register the state with ObjectStateRegistry.register(). This means if the state isn't found in the registry, the locally created state will not be visible to LiveContextService or other components that query the registry. Consider either:

  1. Registering the fallback state with the registry
  2. Logging a warning that the state wasn't pre-registered (indicates a bug in the lifecycle management)
  3. Removing the fallback entirely once the migration is complete

The same issue exists in other files where fallback ObjectState creation happens without registration.

Suggested change
scope_id=self.scope_id,
ObjectStateRegistry.register(state)

Copilot uses AI. Check for mistakes.
…lues

PROBLEM:
When resetting a field in GlobalPipelineConfig, the reset value was incorrect.
For example, resetting WellFilterConfig.well_filter showed 4 instead of None.

ROOT CAUSE:
ObjectState.__init__ populated param_defaults from instance values via
UnifiedParameterAnalyzer.analyze(). For lazy dataclasses, instance values
go through threadlocal resolution and return concrete values (e.g., 4),
not the actual signature defaults (e.g., None).

The reset flow expected param_defaults to contain SIGNATURE defaults
(what's written in the class definition), but it contained RESOLVED
instance values.

FIX:
ObjectState now uses SignatureAnalyzer.analyze(type(object_instance)) to
get the actual signature defaults for param_defaults. This returns the
static class defaults (e.g., None for well_filter) regardless of any
lazy resolution or threadlocal context.

ALSO INCLUDED:
- Removed duplicate _get_reset_value from PFM (now only in ParameterOpsService)
- Simplified ParameterOpsService._get_reset_value to just use param_defaults
- Added RESET_TRACE logging throughout the reset flow for debugging

Files changed:
- openhcs/config_framework/object_state.py
- openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py
- openhcs/pyqt_gui/widgets/shared/services/field_change_dispatcher.py
- openhcs/pyqt_gui/widgets/shared/services/parameter_ops_service.py
…lution to concrete types

## Summary
Major cleanup of lazy_factory.py and related files. Net deletion of 255 lines.

## Phase 1a: Dead Code Deletion (~100 lines)

- Delete unused PyQt6 import block (HAS_PYQT never used)
- Delete _try_global_context_value() (defined but never called)
- Delete InheritAsNoneMeta class (~70 lines, never used as metaclass)
- Remove InheritAsNoneMeta reference from metaclass safety check

## Phase 1b: ContextProvider Infrastructure (~100 lines)

The entire ContextProvider system was dead code feeding broken frame.f_locals
manipulation. Python's frame.f_locals is a dict COPY, not a live reference -
modifications do nothing. The injected context variables were never read.

Deleted:
- CONTEXT_PROVIDERS registry
- _CONTEXT_PROVIDER_REGISTRY_CONFIG
- ContextProviderMeta class
- ContextProvider class
- _detect_context_type() function
- All frame.f_locals manipulation in resolve_lazy_configurations_for_serialization()

Updated downstream consumers:
- AbstractStep: Remove ContextProvider inheritance and _context_type
- PipelineOrchestrator: Remove ContextProvider inheritance and _context_type
- auto_register_meta.py: Update docstrings to remove ContextProviderMeta references

## Phase 2: Add Lazy Resolution to Concrete Classes

The core fix: concrete types (like WellFilterConfig) stored in GlobalPipelineConfig
now have lazy resolution via __getattribute__, WITHOUT changing their static defaults.

Added:
- bind_lazy_resolution_to_class(cls): Binds __getattribute__ to any class
- Call to bind_lazy_resolution_to_class() in @global_pipeline_config decorator
- Bug fix in __getattribute__ fallback for concrete types not in lazy registry

## Phase 3 (Partial): Remove UI Workarounds

Now that concrete types have lazy resolution, removed workarounds that looked up
lazy types for resolution:

- object_state.py: Remove _get_lazy_type_for_base() lookup
- placeholder.py: Delete _get_lazy_type_for_base(), update has_lazy_resolution()
- lazy_placeholder_simplified.py: Same changes as placeholder.py
- lazy_factory.py: Remove lazy type conversion in rebuild_lazy_config_with_new_global_reference()

## Architectural Change

BEFORE: Concrete types had no lazy resolution. UI had to:
1. Look up lazy type via _get_lazy_type_for_base()
2. Create lazy instance
3. Access field to trigger resolution

AFTER: Concrete types decorated with @global_pipeline_config have __getattribute__
bound directly. They resolve None values without needing lazy type lookup.
…ults

Replace 3-stage approach (pre-process setattr, post-process Field patch)
with single make_dataclass rebuild:

- Add get_inherited_field_names() to identify inherited vs own fields
- Add rebuild_with_none_defaults() to rebuild dataclass with None defaults
- Store original defaults in _inherited_default metadata for fallback
- Update __getattribute__ to use inherited defaults for standalone usage
- Delete _fix_dataclass_field_defaults_post_processing() (~45 lines)

dual_axis_resolver: Separate LazyDataclass vs concrete class resolution
- LazyDataclass: check same-type in context, then MRO walk
- Concrete: skip same-type lookup, only MRO walk for parent inheritance
- Remove Step 3 class default fallback (handled by _inherited_default)

Result: Both standalone and in-context resolution work correctly
- Standalone: PathPlanningConfig().well_filter_mode → INCLUDE (parent default)
- In-context: resolves from WellFilterConfig → EXCLUDE
…vice

## Summary
Extract scope token generation from PipelineEditor into a centralized
ScopeTokenService, enabling hierarchical scope IDs for both steps and
functions with proper ObjectState lifecycle management.

## Changes

### ScopeTokenService (scope_token_service.py)
- Add ScopeTokenService class as registry of ScopeTokenGenerators
- Auto-derive prefix from object type (FunctionStep→'step', callable→'func')
- build_scope_id() creates hierarchical scopes: plate::step_N::func_M
- seed_from_objects() preserves tokens after deserialization
- clear_scope() for cleanup of generator hierarchies

### PipelineEditor (pipeline_editor.py)
- Remove local STEP_SCOPE_ATTR and _next_scope_token counter
- Delete _ensure_step_scope_token() and _transfer_scope_token() methods
- Delegate to ScopeTokenService for all scope operations
- Simplify _build_step_scope_id() to single service call

### FunctionPane (function_pane.py)
- Register ObjectState for each function with hierarchical scope
- Scope format: step_scope::func_N (e.g., plate::step_0::func_0)
- Add cleanup_object_state() for proper unregistration
- FunctionListWidget calls cleanup before destroying panes

### FunctionListEditor (function_list_editor.py)
- Unregister ObjectState in _remove_function() before removal
- Prevents orphaned ObjectState entries in registry

### DualEditorWindow (dual_editor_window.py)
- Simplify _build_step_scope_id() to delegate to ScopeTokenService
- Remove redundant fallback logic

### UnifiedRegistry (unified_registry.py)
- Add commented INJECTABLE_PARAMS for well_filter_config (future)

## Architecture
This centralizes the 'assign token once, stable forever' pattern that
was duplicated between PipelineEditor and FunctionListEditorWidget.
The ScopeTokenService becomes the single source of truth for scope
token generation across the GUI layer.
ARCHITECTURAL CLEANUP: Complete MVC separation where ParameterFormManager (PFM)
is purely VIEW and ObjectState is purely MODEL.

## Core Changes

### ObjectState as Single Source of Truth
- All state mutations now go through ObjectState methods:
  - update_parameter() for value changes
  - reset_parameter() for resets (handles tracking internally)
  - update_thread_local_global_config() moved from PFM
- PFM no longer owns state data - uses property delegators to access state.*

### PFM Cleanup (-589 lines, 49% reduction)
- DELETED: from_dataclass_instance() factory (0 callers)
- DELETED: MODEL delegation wrappers (get_current_values, get_user_modified_values)
- DELETED: create_widget() and _make_widget_readonly() from ABC
- DELETED: NoneAwareLineEdit, NoneAwareIntEdit classes (moved to widget_strategies)
- DELETED: Debug instrumentation (RESET_TRACE logs, fiji_streaming_config debug)
- ADDED: Property delegators (parameters, param_defaults, reset_fields, _user_set_fields)

### Widget Creation Simplification
- param_info types declare widget_creation_type as class attribute
- create_widget_parametric uses param_info.widget_creation_type (no isinstance dispatch)
- NoneAwareLineEdit, NoneAwareIntEdit moved to widget_strategies.py top

### Service Layer Cleanup
- ParameterOpsService._reset_* methods call manager.state.reset_parameter()
- Removed _get_reset_value(), _update_reset_tracking() (ObjectState handles these)
- All callers updated to use state.* instead of form_manager.* for MODEL operations
- LiveContextService methods used directly instead of PFM class methods

## Files Changed (21 files, -572 net lines)

Architecture:
- object_state.py: +update_thread_local_global_config()
- parameter_form_manager.py: -589 lines (616 total, was 1205)
- parameter_ops_service.py: -36 lines (256 total)
- widget_strategies.py: +NoneAwareLineEdit, NoneAwareIntEdit at top
- widget_creation_config.py: widget_creation_type dispatch
- widget_creation_types.py: removed ABC methods
- parameter_info_types.py: +widget_creation_type attribute

Callers (state.* delegation):
- config_window.py, dual_editor_window.py, step_parameter_editor.py
- image_browser.py, synthetic_plate_generator_window.py, function_pane.py

Services (LiveContextService):
- function_list_editor.py, pipeline_editor.py, plate_manager.py
- abstract_manager_widget.py, field_change_dispatcher.py
- signal_service.py, value_collection_service.py, widget_service.py
…eritance bug

MAJOR CHANGES:

1. ObjectState Simplification (object_state.py)
   - Reduced core attributes from ~15 to 8 essential ones:
     * object_instance, parameters, _saved_resolved, _live_resolved,
       _live_token, nested_states, _parent_state, scope_id
   - Removed redundant tracking: _user_set_fields, reset_fields, saved_user_set_fields,
     saved_reset_fields, saved_parameters, _dirty
   - Derived properties: context_obj from _parent_state, is_dirty() from comparing
     _live_resolved vs _saved_resolved
   - Removed field_id (now derived from type(object_instance).__name__)
   - Removed get_user_modified_values() and get_user_modified_overlay() - replaced
     by simpler parameters dict filtering {k: v for k, v in params.items() if v is not None}
   - Added _signature_defaults for reset functionality using use_signature_defaults=True
   - Added _ensure_live_resolved() for lazy recomputation of resolved values

2. CRITICAL BUG FIX: Nested Config Save Inheritance (object_state.py, config_window.py)
   - Problem: When saving GlobalPipelineConfig, nested configs (path_planning_config,
     step_well_filter_config, etc.) with inherited fields (well_filter_mode=None)
     were being saved with RESOLVED values (INCLUDE) instead of staying None
   - Impact: MRO inheritance broke because GlobalPipelineConfig.path_planning_config
     had concrete well_filter_mode=INCLUDE, so when PipelineConfig set WellFilterConfig
     to EXCLUDE, PathPlanningConfig would find the concrete GlobalPipelineConfig value
     instead of inheriting from WellFilterConfig
   - Fix: get_current_values() now reconstructs nested dataclass instances from
     nested_state.parameters (preserves None) instead of using stale instances
     from self.parameters (had resolved values baked in)
   - ConfigWindow.save_config() simplified to use get_current_values() directly

3. UnifiedParameterAnalyzer Enhancements (unified_parameter_analyzer.py)
   - Added use_signature_defaults parameter for reset functionality
   - When True, returns CLASS signature defaults (None for lazy fields) instead
     of instance values - ensures reset goes back to original defaults
   - Simplified instance value extraction: always use object.__getattribute__
     (bypasses lazy resolution for ALL types, not just Lazy* prefixed classes)

4. Dual Axis Resolver Cleanup (dual_axis_resolver.py)
   - Removed obsolete resolve_field_inheritance_old() (200+ lines)
   - Removed _is_related_config_type(), _find_blocking_class_in_mro()
   - Added _normalize_to_base() helper for type comparison
   - MRO type comparison now uses normalized types so LazyWellFilterConfig
     matches WellFilterConfig in the inheritance walk
   - Added well_filter_mode to debug logging fields

5. Context Manager Fix (context_manager.py)
   - build_context_stack() now injects ALL live types with values (not just containers)
   - Previously only injected types with nested dataclass values, which meant
     leaf configs like LazyWellFilterConfig with {well_filter_mode: EXCLUDE}
     were skipped and MRO couldn't find them

6. LiveContextService Type Normalization (live_context_service.py)
   - merge_ancestor_values() now normalizes types using get_base_type_for_lazy()
   - LazyWellFilterConfig and WellFilterConfig merge into same bucket
   - Added debug logging for well_filter_mode overwrites

7. ObjectState API Consumers Updated
   - Removed field_id and context_obj from all ObjectState constructors:
     * app.py, metadata_viewer_dialog.py, function_pane.py, image_browser.py,
       pipeline_editor.py, plate_manager.py, step_parameter_editor.py,
       config_window.py, plate_viewer_window.py, synthetic_plate_generator_window.py
   - Changed scope_id for global scope from None to empty string ("")
   - parent_state now passed instead of context_obj for context inheritance
   - FunctionPane/ImageBrowser use ObjectStateRegistry.get_by_scope() to find parent

8. ParameterFormManager Simplification (parameter_form_manager.py)
   - Removed delegation to ObjectState._user_set_fields, reset_fields
   - parameter_types and param_defaults now derived via UnifiedParameterAnalyzer
   - field_id derived from type(object_instance).__name__

9. Service Layer Updates
   - FieldChangeDispatcher: Removed user_set parameter tracking, simplified logging
   - ValueCollectionService: Uses {k:v for k,v in params.items() if v is not None}
     instead of get_user_modified_values()
   - SignalService: Uses direct parameters filtering instead of get_user_modified_values()
   - reconstruct_nested_dataclasses() simplified (already returns instances)

10. StepParameterEditor Fail-Loud (step_parameter_editor.py)
    - Removed fallback ObjectState creation
    - Now raises RuntimeError if ObjectState not found in registry
    - PipelineEditor MUST register step before opening editor
…ne values

PROBLEM:
When loading GlobalPipelineConfig from cache on app restart, nested configs
with None values (e.g., path_planning_config.well_filter_mode=None) were
being resolved to concrete values instead of staying None.

ROOT CAUSE:
_migrate_dataclass() used getattr() which triggers the lazy __getattribute__
resolution. This resolved None -> concrete value during cache load, breaking
MRO inheritance because the GlobalPipelineConfig now had concrete values
baked in instead of None values that would inherit from parent configs.

FIX:
Use object.__getattribute__() to get raw stored values without triggering
lazy resolution. This preserves None values so MRO inheritance continues
to work correctly after loading from cache.
Bug: GlobalPipelineConfig window placeholders were 1 input behind.

Root cause: Scope ID mismatch between registration and lookup:
- app.py registered GlobalPipelineConfig ObjectState with scope_id=''
- ConfigWindow.get_by_scope(None) didn't find it (None != '')
- ConfigWindow created a NEW unregistered ObjectState
- Dispatcher updated the unregistered state
- collect_live_context iterated the REGISTERED state with OLD values

Fix: ObjectStateRegistry now normalizes None to '' via _normalize_scope_id().
Both None and '' are treated as equivalent global scope identifiers.

Also added explicit scope_id='' at call sites for clarity.
@trissim trissim changed the title ObjectState lifecycle docs and plan updates refactor: Extract MODEL from PFM into ObjectState (MVC separation, -700 lines) Dec 7, 2025
…variant

Move increment_token() call from FieldChangeDispatcher to ObjectState.update_parameter().
This ensures any controller calling state.update_parameter() automatically gets cache
invalidation without needing to remember to call it separately.

Architectural principle: MODEL enforces invariants, controllers don't need to know
about infrastructure concerns like cache invalidation.
trissim and others added 29 commits January 24, 2026 13:41
The CI test for PR 58 was failing because openhcs/build_utils/__init__.py
used absolute imports (from openhcs.build_utils.zeroc_ice_installer import...)
which caused a ModuleNotFoundError during the build process, since the
openhcs package wasn't yet in the Python path.

Changed to relative imports (from .zeroc_ice_installer import...) to avoid
the circular dependency issue during setuptools build.
The OMERO test was failing because polystore backend expects metadata
with namespace 'polystore.metadata' and keys 'polystore.parser' and
'polystore.microscope_type', but test code was using 'openhcs.metadata'
namespace and 'openhcs.parser'/'openhcs.microscope_type' keys.

Changed to match polystore's expected metadata format:
- Namespace: 'polystore.metadata'
- Parser key: 'polystore.parser'
- Microscope type key: 'polystore.microscope_type'
- Implement get_well_values() and parse_metadata() in OMERO metadata handler
- Fix plate manager to use set_saved_global_config() instead of set_global_config_for_editing() to preserve unsaved edits
- Remove unnecessary FILENAME_PARSERS dependency from OMERO backend
…OLocalBackend

The OMERO test was failing because OMEROFilenameParser wasn't registered in
FilenameParser.__registry__ when OMEROLocalBackend tried to access it. This is a
lazy registry problem - the LazyDiscoveryDict only populates when first accessed.

Added import of openhcs.microscopes.omero before creating OMEROLocalBackend in:
- tests/integration/test_main.py
- openhcs/runtime/execution_server.py
- openhcs/runtime/zmq_execution_server.py
- pyqt-reactor: rename to qt-reactive for PyPI
- uneval: rename to unevalpy for PyPI
- All packages updated with proper PyPI publishing workflows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update .gitmodules URL to point to PyQT-reactive repository
- Update pyproject.toml dependency to pyqt-reactive
- Update all imports from pyqt_reactor to pyqt_reactive in openhcs
- Update .gitmodules path to external/pyqt-reactive
- Remove old external/pyqt-reactor submodule reference
- Add new external/pyqt-reactive submodule
Implement custom build backend that allows PyPI releases to use pip versions
of external modules while development mode uses local git submodules.

Changes:
- Add openhcs_build/ custom build backend extending setuptools.build_meta
- Update pyproject.toml to use openhcs_build build backend
- Remove external module dependencies from pyproject.toml (added dynamically)
- Add docs/development_setup.md with usage instructions
- Add docs/dynamic_dependencies_implementation.md with technical details
- Add scripts/dev_install.py convenience script
- Remove setup.py (no longer needed)

Development mode is detected when:
- PIP_EDITABLE_INSTALL=1 (set by pip install -e)
- external/ directory exists
- OPENHCS_DEV_MODE environment variable is set

This avoids using requirements-dev.txt while keeping all configuration
in pyproject.toml and using PEP621 compliant custom build backend.
Custom build backend cannot be imported during installation because it's
part of the package being installed. Switch to dynamic setup.py
approach which is simpler and more reliable.

Changes:
- Add setup.py with dynamic dependency selection
- Update pyproject.toml to use standard setuptools.build_meta
- Remove openhcs_build/ directory (no longer needed)
- Update docs to reflect setup.py approach

Development mode is detected when:
- PIP_EDITABLE_INSTALL=1 (set by pip install -e)
- external/ directory exists
- OPENHCS_DEV_MODE environment variable is set

This approach avoids the import issue with custom build backends
while still providing automatic dependency source selection.
Changed approach from dynamic dependency selection to simpler approach:
- Use pip versions of external modules in pyproject.toml for PyPI releases
- For development, install local external modules separately via dev_install.py
- Removed setup.py (was causing multiple .egg-info directories issue)
- Updated documentation to reflect simpler approach

This approach is more reliable and avoids build issues.
Created proper setup.py that:
- Detects development mode via PIP_EDITABLE_INSTALL, external/ directory, OPENHCS_DEV_MODE
- Returns local paths for development: file:////external/ObjectState
- Returns pip versions for production: objectstate>=0.1.0
- Properly merges with pyproject.toml dependencies via setup() function

Updated pyproject.toml to remove external module dependencies
(since they're added dynamically by setup.py)

Updated documentation and dev_install.py to reflect setup.py approach.

This resolves the 'Multiple .egg-info directories found' error.
The setup.py automatically handles dynamic dependency selection,
so dev_install.py script is not strictly needed. Updated
documentation to reflect this.
The setup.py should NOT add external module dependencies to install_requires
because they don't exist on PyPI yet. External modules should
only be used in development mode with local paths.

This fixes the CI error where pip tries to install
objectstate>=0.1.0 but it's not available on PyPI.
- Fix setup.py to actually pass external dependencies to setup()
- Replace LOCAL_EXTERNAL_DEPENDENCIES constant with get_local_external_dependencies() function
- Fix is_development_mode() to properly detect development mode (has_external or dev_mode_env)
- Remove broken PIP_EDITABLE_INSTALL check (not a standard pip env var)
- Use absolute paths for file:// URLs to external modules

This fixes CI failures where external modules were not being installed
during dependency installation.
- Add external module dependencies (zmqruntime, pycodify, objectstate, etc.) to pyproject.toml
- Remove install_requires from setup.py to avoid conflicts with pyproject.toml
- This ensures external modules are properly installed in CI wheel builds
@trissim trissim merged commit 0eea7ea into main Jan 26, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants