Add change detection, structural diff-based impact analysis, configurable-depth incremental recomputation with circular suppression, and impact debt tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
242 lines
7.7 KiB
Python
242 lines
7.7 KiB
Python
"""
|
|
Data models for incremental execution.
|
|
|
|
Implements FR-7: Incremental Recomputation
|
|
Implements FR-8: Impact Analysis
|
|
|
|
Provides ChangeType enum, ArtifactChange, ImpactDebt, RecomputeConfig,
|
|
and RecomputeResult dataclasses.
|
|
"""
|
|
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
class ChangeType(Enum):
|
|
"""Type of artifact change detected."""
|
|
CREATED = "created"
|
|
MODIFIED = "modified"
|
|
DELETED = "deleted"
|
|
|
|
|
|
@dataclass
|
|
class ArtifactChange:
|
|
"""
|
|
Record of a detected artifact change.
|
|
|
|
Attributes:
|
|
id: Unique change identifier
|
|
artifact_id: ID of changed artifact
|
|
old_digest: Previous content digest (None for created)
|
|
new_digest: New content digest
|
|
change_type: Type of change
|
|
detected_at: When change was detected
|
|
"""
|
|
id: str
|
|
artifact_id: str
|
|
old_digest: Optional[str]
|
|
new_digest: str
|
|
change_type: ChangeType
|
|
detected_at: datetime = field(default_factory=datetime.utcnow)
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
artifact_id: str,
|
|
old_digest: Optional[str],
|
|
new_digest: str,
|
|
change_type: ChangeType,
|
|
) -> "ArtifactChange":
|
|
"""
|
|
Create a new ArtifactChange record.
|
|
|
|
Args:
|
|
artifact_id: ID of changed artifact
|
|
old_digest: Previous digest (None for created)
|
|
new_digest: New digest
|
|
change_type: Type of change
|
|
|
|
Returns:
|
|
New ArtifactChange instance
|
|
"""
|
|
return cls(
|
|
id=str(uuid.uuid4()),
|
|
artifact_id=artifact_id,
|
|
old_digest=old_digest,
|
|
new_digest=new_digest,
|
|
change_type=change_type,
|
|
)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for serialization."""
|
|
return {
|
|
"id": self.id,
|
|
"artifact_id": self.artifact_id,
|
|
"old_digest": self.old_digest,
|
|
"new_digest": self.new_digest,
|
|
"change_type": self.change_type.value,
|
|
"detected_at": self.detected_at.isoformat(),
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "ArtifactChange":
|
|
"""Create from dictionary."""
|
|
return cls(
|
|
id=data["id"],
|
|
artifact_id=data["artifact_id"],
|
|
old_digest=data.get("old_digest"),
|
|
new_digest=data["new_digest"],
|
|
change_type=ChangeType(data["change_type"]),
|
|
detected_at=datetime.fromisoformat(data["detected_at"]),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ImpactDebt:
|
|
"""
|
|
Record of a suppressed recomputation.
|
|
|
|
When a dependent is not recomputed (due to circular dependency,
|
|
below-threshold impact, or budget exhaustion), an ImpactDebt
|
|
record is created to track the outstanding work.
|
|
|
|
Attributes:
|
|
id: Unique debt identifier
|
|
artifact_id: ID of the changed artifact that triggered this
|
|
dependent_run_id: Run ID of the dependent that was not recomputed
|
|
change_magnitude: Measured impact magnitude (0.0-1.0)
|
|
suppression_reason: Why recomputation was suppressed
|
|
recorded_at: When debt was recorded
|
|
"""
|
|
id: str
|
|
artifact_id: str
|
|
dependent_run_id: str
|
|
change_magnitude: float
|
|
suppression_reason: str
|
|
recorded_at: datetime = field(default_factory=datetime.utcnow)
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
artifact_id: str,
|
|
dependent_run_id: str,
|
|
change_magnitude: float,
|
|
suppression_reason: str,
|
|
) -> "ImpactDebt":
|
|
"""
|
|
Create a new ImpactDebt record.
|
|
|
|
Args:
|
|
artifact_id: Changed artifact ID
|
|
dependent_run_id: Dependent run ID
|
|
change_magnitude: Impact magnitude (0.0-1.0)
|
|
suppression_reason: Reason for suppression
|
|
|
|
Returns:
|
|
New ImpactDebt instance
|
|
"""
|
|
return cls(
|
|
id=str(uuid.uuid4()),
|
|
artifact_id=artifact_id,
|
|
dependent_run_id=dependent_run_id,
|
|
change_magnitude=change_magnitude,
|
|
suppression_reason=suppression_reason,
|
|
)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for serialization."""
|
|
return {
|
|
"id": self.id,
|
|
"artifact_id": self.artifact_id,
|
|
"dependent_run_id": self.dependent_run_id,
|
|
"change_magnitude": self.change_magnitude,
|
|
"suppression_reason": self.suppression_reason,
|
|
"recorded_at": self.recorded_at.isoformat(),
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "ImpactDebt":
|
|
"""Create from dictionary."""
|
|
return cls(
|
|
id=data["id"],
|
|
artifact_id=data["artifact_id"],
|
|
dependent_run_id=data["dependent_run_id"],
|
|
change_magnitude=data["change_magnitude"],
|
|
suppression_reason=data["suppression_reason"],
|
|
recorded_at=datetime.fromisoformat(data["recorded_at"]),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class RecomputeConfig:
|
|
"""
|
|
Configuration for incremental recomputation.
|
|
|
|
Attributes:
|
|
max_depth: Maximum BFS depth for finding dependents (FR-7.2)
|
|
impact_threshold: Minimum magnitude to trigger recompute (FR-8.2)
|
|
max_recomputes: Budget limit on recomputations per change
|
|
suppress_circular: Whether to suppress circular dependencies
|
|
"""
|
|
max_depth: int = 1
|
|
impact_threshold: float = 0.0
|
|
max_recomputes: int = 100
|
|
suppress_circular: bool = True
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for serialization."""
|
|
return {
|
|
"max_depth": self.max_depth,
|
|
"impact_threshold": self.impact_threshold,
|
|
"max_recomputes": self.max_recomputes,
|
|
"suppress_circular": self.suppress_circular,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "RecomputeConfig":
|
|
"""Create from dictionary."""
|
|
return cls(
|
|
max_depth=data.get("max_depth", 1),
|
|
impact_threshold=data.get("impact_threshold", 0.0),
|
|
max_recomputes=data.get("max_recomputes", 100),
|
|
suppress_circular=data.get("suppress_circular", True),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class RecomputeResult:
|
|
"""
|
|
Result of an incremental recomputation triggered by a change.
|
|
|
|
Attributes:
|
|
changed_artifact_id: ID of the artifact that changed
|
|
change: The detected change record
|
|
executed_run_ids: Run IDs that were recomputed
|
|
suppressed: List of suppressed recomputation debt records
|
|
total_dependents: Total number of dependents found
|
|
recomputed_count: Number actually recomputed
|
|
suppressed_count: Number suppressed
|
|
"""
|
|
changed_artifact_id: str
|
|
change: ArtifactChange
|
|
executed_run_ids: List[str] = field(default_factory=list)
|
|
suppressed: List[ImpactDebt] = field(default_factory=list)
|
|
total_dependents: int = 0
|
|
recomputed_count: int = 0
|
|
suppressed_count: int = 0
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for serialization."""
|
|
return {
|
|
"changed_artifact_id": self.changed_artifact_id,
|
|
"change": self.change.to_dict(),
|
|
"executed_run_ids": self.executed_run_ids,
|
|
"suppressed": [d.to_dict() for d in self.suppressed],
|
|
"total_dependents": self.total_dependents,
|
|
"recomputed_count": self.recomputed_count,
|
|
"suppressed_count": self.suppressed_count,
|
|
}
|