Status: Design Phase
Created: December 12, 2024
Priority: Critical - Resolves architectural debt causing state synchronization bugs
-
Manual State Synchronization: Perspective switching requires manually coordinating 3+ components
- Controller updates
self.perspective - Controller directly sets
primitive_panel.current_perspective - Controller directly sets
trajectory_panel.current_perspective - Controller manually clears/recreates label dictionaries
- Components can get out of sync
- Controller updates
-
No Single Source of Truth: Perspective state scattered across components
- Model has perspective-INDEPENDENT
modified_primitives(shared between M1/M2) - Panels maintain separate
current_perspectiveattributes - No atomic transaction for perspective changes
- Model has perspective-INDEPENDENT
-
Bug Manifestation: Labels from M1 perspective appear in M2 perspective
- Despite 15+ removal attempts (removeItem, deleteLater, nuclear cleanup)
- Labels are responsive (move with M2 markers) indicating active creation
- Hidden code paths recreate labels without debug tracing catching them
-
Architectural Debt: Similar bugs will emerge with future features
- Any new perspective-aware component requires manual coordination
- Easy to miss cleanup in one component
- Difficult to debug event ordering issues
- Centralized Coordination: Single source managing perspective state
- Atomic Transactions: Perspective switches happen completely or not at all
- Observable by Design: Built-in logging and event tracing
- Extensible: 3rd parties can add perspective-aware components
- Not changing the dual-perspective model (M1/M2 stays)
- Not affecting user-facing functionality
- Not requiring rewrites of working single-perspective logic
┌─────────────────────────────────────────────────────────────┐
│ EditorController │
│ - Owns perspective state │
│ - Emits Qt signal: perspective_changed(old, new) │
│ - Logs all perspective operations │
└─────────────────────────────────────────────────────────────┘
│
│ Signal: perspective_changed
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────┐ ┌────────────────┐
│ PrimitivePanel │ │ Model │ │ TrajectoryPanel│
│ - Subscribes │ │ - Subscribes│ │ - Subscribes │
│ - Cleans labels │ │ - Returns │ │ - Cleans markers│
│ - Logs actions │ │ perspective│ │ - Logs actions │
│ │ │ data │ │ │
└─────────────────┘ └─────────────┘ └────────────────┘
Responsibilities:
- Single source of truth for current perspective
- Emits
perspective_changed(old_perspective, new_perspective)Qt signal - Logs all perspective operations to observability system
API:
class EditorController(QObject):
# Qt Signal
perspective_changed = pyqtSignal(str, str) # (old, new)
def switch_perspective(self, new_perspective: str):
"""
Atomically switch perspective with full observability.
Emits perspective_changed signal.
"""
old = self.perspective
ObservabilityLog.event("perspective_switch_start", old=old, new=new_perspective)
self.perspective = new_perspective
self.perspective_changed.emit(old, new_perspective)
ObservabilityLog.event("perspective_switch_complete", old=old, new=new_perspective)Responsibilities:
- Listens to
perspective_changedsignal - Manages its own label cleanup/recreation
- Logs all label operations
API:
class PrimitivePanelPyQtGraph(QWidget):
def __init__(self, ...):
# Subscribe to controller's signal
controller.perspective_changed.connect(self._on_perspective_changed)
def _on_perspective_changed(self, old_perspective: str, new_perspective: str):
"""
Handle perspective switch.
Called automatically via Qt signal.
"""
ObservabilityLog.event("primitive_panel_perspective_change",
component="PrimitivePanel",
old=old_perspective,
new=new_perspective)
# Hide old perspective labels
old_labels = self._get_labels_for_perspective(old_perspective)
for key, text_item in old_labels.items():
self.plot_items[key[1]].removeItem(text_item)
ObservabilityLog.event("label_hidden", key=key, perspective=old_perspective)
# Show new perspective labels
new_labels = self._get_labels_for_perspective(new_perspective)
for key, text_item in new_labels.items():
self.plot_items[key[1]].addItem(text_item)
ObservabilityLog.event("label_shown", key=key, perspective=new_perspective)
self.current_perspective = new_perspective
ObservabilityLog.event("primitive_panel_perspective_complete", new=new_perspective)Responsibilities:
- Track perspective-specific modifications
- Return perspective-filtered data
API:
class EditorModel:
def __init__(self):
# Perspective-aware modification tracking
self.modified_primitives_m1: Dict[float, Set[str]] = {} # {time: {prims}}
self.modified_primitives_m2: Dict[float, Set[str]] = {}
def get_modified_primitives(self, perspective: str) -> Dict[float, Set[str]]:
"""Return modifications for specified perspective only."""
return self.modified_primitives_m1 if perspective == "M1" else self.modified_primitives_m2
def mark_primitive_modified(self, time: float, primitive: str, perspective: str):
"""Mark a primitive as modified in the specified perspective."""
modified = self.get_modified_primitives(perspective)
if time not in modified:
modified[time] = set()
modified[time].add(primitive)
ObservabilityLog.event("primitive_modified",
time=time,
primitive=primitive,
perspective=perspective)Responsibilities:
- Listens to
perspective_changedsignal - Manages its own marker cleanup
- Logs trajectory operations
API: (Similar pattern to PrimitivePanel)
# tools/editor/observability.py
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict
import json
class ObservabilityLog:
"""
Centralized observability system for editor debugging.
Design Principles:
- Separate from business logic
- Toggle-able via environment variable or config
- Structured output for analysis
- Minimal performance impact
"""
_enabled = False
_logger = None
_log_file = None
@classmethod
def initialize(cls, enabled: bool = None):
"""
Initialize observability system.
Args:
enabled: True/False to override, None to read from environment
"""
if enabled is None:
import os
enabled = os.environ.get("EDITOR_DEBUG", "false").lower() == "true"
cls._enabled = enabled
if cls._enabled:
# Create logs directory
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
# Setup file logger
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
cls._log_file = log_dir / f"editor_debug_{timestamp}.log"
cls._logger = logging.getLogger("EditorObservability")
cls._logger.setLevel(logging.DEBUG)
handler = logging.FileHandler(cls._log_file)
handler.setFormatter(logging.Formatter(
'%(asctime)s.%(msecs)03d | %(message)s',
datefmt='%H:%M:%S'
))
cls._logger.addHandler(handler)
cls._logger.info(f"=== Editor Observability Initialized ===")
cls._logger.info(f"Log file: {cls._log_file}")
@classmethod
def event(cls, event_type: str, **kwargs):
"""
Log a structured event.
Args:
event_type: Event identifier (e.g., "perspective_switch_start")
**kwargs: Event-specific data
"""
if not cls._enabled or cls._logger is None:
return
event_data = {
"event": event_type,
**kwargs
}
cls._logger.info(json.dumps(event_data))
@classmethod
def is_enabled(cls) -> bool:
"""Check if observability is enabled."""
return cls._enabled
@classmethod
def get_log_file(cls) -> Path:
"""Get path to current log file."""
return cls._log_file
# Usage in application.py or interactive_editor.py:
from tools.editor.observability import ObservabilityLog
# Initialize at startup
ObservabilityLog.initialize() # Reads EDITOR_DEBUG env var
# Or force enable for debugging:
# ObservabilityLog.initialize(enabled=True)Each log entry is a JSON object for easy parsing:
{"event": "perspective_switch_start", "old": "M1", "new": "M2"}
{"event": "primitive_panel_perspective_change", "component": "PrimitivePanel", "old": "M1", "new": "M2"}
{"event": "label_hidden", "key": [42.0, "v"], "perspective": "M1"}
{"event": "label_hidden", "key": [42.0, "v"], "perspective": "M1"}
{"event": "label_shown", "key": [35.0, "r"], "perspective": "M2"}
{"event": "primitive_panel_perspective_complete", "new": "M2"}
{"event": "trajectory_panel_perspective_change", "component": "TrajectoryPanel", "old": "M1", "new": "M2"}
{"event": "perspective_switch_complete", "old": "M1", "new": "M2"}Via Environment Variable (recommended):
# Windows PowerShell
$env:EDITOR_DEBUG="true"
python tools\interactive_editor_new.py data\library\love\single_dating_to_love_M1.csv
# Linux/Mac
EDITOR_DEBUG=true python tools/interactive_editor_new.py data/library/love/single_dating_to_love_M1.csvVia Config File:
// config/editor_config.json
{
"debug": {
"observability_enabled": true
}
}Programmatically:
# Force enable for debugging session
ObservabilityLog.initialize(enabled=True)Third-party developers can add perspective-aware components by:
- Subscribe to Signal:
class CustomPanel(QWidget):
def __init__(self, controller):
super().__init__()
# Subscribe to perspective changes
controller.perspective_changed.connect(self._on_perspective_changed)
def _on_perspective_changed(self, old: str, new: str):
# Component-specific cleanup/update logic
self.update_for_perspective(new)- Use Observability:
from tools.editor.observability import ObservabilityLog
def _on_perspective_changed(self, old: str, new: str):
ObservabilityLog.event("custom_panel_perspective_change",
component="CustomPanel",
old=old,
new=new)
# ... component logic ...
ObservabilityLog.event("custom_panel_perspective_complete", new=new)- Query Model by Perspective:
# Instead of accessing shared modified_primitives:
# BAD: modifications = model.modified_primitives
# GOOD: Get perspective-specific data:
modifications = model.get_modified_primitives(controller.perspective)Documented extension points for 3rd parties:
- Perspective Change Listeners: Subscribe to
perspective_changedsignal - Observability Events: Log custom events via
ObservabilityLog.event() - Model Queries: Use perspective-aware API methods
- Custom Panels: Follow PrimitivePanel/TrajectoryPanel patterns
- Create
observability.pymodule - Add
ObservabilityLoginitialization to application startup - Document usage and configuration
- Split
modified_primitivesintomodified_primitives_m1andmodified_primitives_m2 - Add perspective-aware query methods
- Update all model access to use new API
- Comprehensive testing
- Add
perspective_changedsignal to EditorController - Refactor PrimitivePanel to subscribe and self-manage
- Refactor TrajectoryPanel to subscribe and self-manage
- Add observability logging to all operations
- Remove manual coordination code from controller
- Remove scattered perspective state
- Comprehensive integration testing
- Update architecture documentation
- Verify label bug is resolved
- Performance testing (observability overhead)
- Third-party extension validation
- Documentation review
- Bug Resolution: Labels no longer persist across perspective switches
- Atomic Operations: Perspective switches complete fully or not at all
- Observability: Full event trace available for debugging
- Extensibility: Third party can add perspective-aware panel in <50 lines
- Performance: <5ms overhead for perspective switch with logging enabled
- Maintainability: Adding new perspective-aware component requires <3 touch points
Mitigation: Performance testing, fallback to direct calls if needed
Mitigation: Comprehensive test suite, gradual migration, feature flags
Mitigation: Disabled by default, async logging if needed, sampling for high-frequency events
Mitigation: Backward compatibility layer during migration, comprehensive test updates
- Qt Signals and Slots Documentation
- Observer Pattern
- DEBUG.md - Debugging methodology
- ARCHITECTURE.md - Overall system architecture