Coverage for src / kemi / lifecycle.py: 100%
23 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-05 15:47 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-05 15:47 +0000
1from datetime import datetime, timezone
3from kemi.models import LifecycleState, MemoryObject
6def evaluate_lifecycle(
7 memory: MemoryObject,
8 decay_threshold_hours: float = 720.0,
9) -> LifecycleState:
10 """Evaluate what lifecycle state a memory should be in.
12 Based on last_accessed_at compared to decay_threshold_hours.
13 Default: 720 hours = 30 days.
15 If last_accessed_at is in the future (clock skew), returns ACTIVE.
16 """
17 if memory.lifecycle_state == LifecycleState.DELETED:
18 return LifecycleState.DELETED
20 if memory.lifecycle_state == LifecycleState.ARCHIVED:
21 return LifecycleState.ARCHIVED
23 now = datetime.now(timezone.utc)
24 hours_since_access = (now - memory.last_accessed_at).total_seconds() / 3600.0
26 if hours_since_access < 0:
27 return LifecycleState.ACTIVE
29 if hours_since_access > decay_threshold_hours:
30 return LifecycleState.DECAYING
32 return LifecycleState.ACTIVE
35def transition(memory: MemoryObject, new_state: LifecycleState) -> MemoryObject:
36 """Create a new MemoryObject with updated lifecycle state.
38 Does not mutate the original. Returns a new instance.
40 Raises ValueError if the transition is not allowed.
41 """
42 validate_transition(memory.lifecycle_state, new_state)
44 return MemoryObject(
45 memory_id=memory.memory_id,
46 user_id=memory.user_id,
47 content=memory.content,
48 embedding=memory.embedding,
49 score=memory.score,
50 created_at=memory.created_at,
51 last_accessed_at=memory.last_accessed_at,
52 source=memory.source,
53 importance=memory.importance,
54 lifecycle_state=new_state,
55 metadata=memory.metadata.copy() if memory.metadata else {},
56 embedding_dim=memory.embedding_dim,
57 tags=memory.tags,
58 confidence=memory.confidence,
59 memory_type=memory.memory_type,
60 session_id=memory.session_id,
61 namespace=memory.namespace,
62 version=memory.version,
63 )
66def get_recall_filter() -> list[LifecycleState]:
67 """Return the list of lifecycle states that should be included in recall results.
69 Excludes ARCHIVED and DELETED. Includes ACTIVE and DECAYING.
70 """
71 return [LifecycleState.ACTIVE, LifecycleState.DECAYING]
74_VALID_TRANSITIONS = {
75 LifecycleState.ACTIVE: {
76 LifecycleState.DECAYING,
77 LifecycleState.DELETED,
78 LifecycleState.ARCHIVED,
79 },
80 LifecycleState.DECAYING: {
81 LifecycleState.ACTIVE,
82 LifecycleState.DELETED,
83 LifecycleState.ARCHIVED,
84 },
85 LifecycleState.ARCHIVED: set(),
86 LifecycleState.DELETED: set(),
87}
90def validate_transition(from_state: LifecycleState, to_state: LifecycleState) -> None:
91 """Validate a state transition.
93 Raises ValueError if the transition is not allowed.
94 Valid transitions:
95 - ACTIVE → DECAYING
96 - ACTIVE → DELETED
97 - ACTIVE → ARCHIVED
98 - DECAYING → ACTIVE
99 - DECAYING → DELETED
100 - DECAYING → ARCHIVED
102 ARCHIVED and DELETED are terminal.
103 """
104 if to_state not in _VALID_TRANSITIONS.get(from_state, set()):
105 raise ValueError(
106 f"Invalid transition from {from_state.value} to {to_state.value}. "
107 f"Valid transitions from {from_state.value}: "
108 f"{[s.value for s in _VALID_TRANSITIONS.get(from_state, set())]}"
109 )