Coverage for astrocyte/pipeline/utility.py: 99%
69 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
1"""Utility scoring — per-memory usage tracking and composite scoring.
3Combines recency, frequency, relevance, and freshness into a 0-1 score
4that drives TTL decisions, recall ranking boosts, and bank health metrics.
6Sync, self-contained — Rust migration candidate.
7Inspired by ByteRover's lifecycle metadata and Hindsight's consolidation quality.
8"""
10from __future__ import annotations
12import math
13import time
14from collections import OrderedDict
15from dataclasses import dataclass
18@dataclass
19class UtilityScore:
20 """Composite utility score with individual components."""
22 recency: float # 0-1: how recently was this memory recalled
23 frequency: float # 0-1: how often is it recalled
24 relevance: float # 0-1: average relevance score when recalled
25 freshness: float # 0-1: how new is the memory
26 composite: float # 0-1: weighted combination
29@dataclass
30class _MemoryStats:
31 recall_count: int = 0
32 last_recalled_at: float = 0.0 # monotonic time
33 total_relevance: float = 0.0
34 created_at: float = 0.0 # monotonic time
37def compute_utility(
38 recall_count: int,
39 last_recalled_seconds_ago: float,
40 avg_relevance: float,
41 created_seconds_ago: float,
42 *,
43 recency_half_life_days: float = 7.0,
44 max_frequency: int = 100,
45 weight_recency: float = 0.3,
46 weight_frequency: float = 0.2,
47 weight_relevance: float = 0.3,
48 weight_freshness: float = 0.2,
49) -> UtilityScore:
50 """Compute composite utility score for a memory.
52 All inputs are non-negative. Returns UtilityScore with components in [0, 1].
53 Sync, pure computation — Rust migration candidate.
54 """
55 half_life_seconds = recency_half_life_days * 86400.0
57 # Recency: exponential decay from last recall (1.0 = just recalled, decays to 0)
58 if last_recalled_seconds_ago <= 0:
59 recency = 1.0
60 else:
61 recency = math.exp(-0.693 * last_recalled_seconds_ago / max(half_life_seconds, 1.0))
63 # Frequency: normalized recall count (capped at max_frequency)
64 frequency = min(recall_count / max(max_frequency, 1), 1.0)
66 # Relevance: average score when recalled (already 0-1)
67 relevance = max(0.0, min(1.0, avg_relevance))
69 # Freshness: how new the memory is (exponential decay from creation)
70 if created_seconds_ago <= 0:
71 freshness = 1.0
72 else:
73 freshness = math.exp(-0.693 * created_seconds_ago / max(half_life_seconds * 4, 1.0))
75 # Composite: weighted sum
76 composite = (
77 weight_recency * recency
78 + weight_frequency * frequency
79 + weight_relevance * relevance
80 + weight_freshness * freshness
81 )
82 # Normalize to [0, 1]
83 total_weight = weight_recency + weight_frequency + weight_relevance + weight_freshness
84 if total_weight > 0:
85 composite /= total_weight
87 return UtilityScore(
88 recency=recency,
89 frequency=frequency,
90 relevance=relevance,
91 freshness=freshness,
92 composite=composite,
93 )
96class UtilityTracker:
97 """Per-memory usage tracking for utility scoring.
99 Maintains recall counts, timestamps, and relevance scores in memory.
100 LRU eviction when over capacity.
102 Sync, self-contained — Rust migration candidate.
103 """
105 def __init__(
106 self,
107 max_entries: int = 10000,
108 recency_half_life_days: float = 7.0,
109 ) -> None:
110 self.max_entries = max_entries
111 self.recency_half_life_days = recency_half_life_days
112 self._stats: OrderedDict[str, _MemoryStats] = OrderedDict() # memory_id → stats (LRU order)
114 def record_recall(self, memory_id: str, relevance_score: float) -> None:
115 """Record that a memory was recalled with a given relevance score."""
116 now = time.monotonic()
118 if memory_id not in self._stats:
119 self._stats[memory_id] = _MemoryStats(created_at=now)
121 stats = self._stats[memory_id]
122 stats.recall_count += 1
123 stats.last_recalled_at = now
124 stats.total_relevance += relevance_score
126 # Move to end (most recently used) — O(1) with OrderedDict
127 self._stats.move_to_end(memory_id)
129 # Evict LRU if over capacity
130 while len(self._stats) > self.max_entries:
131 self._stats.popitem(last=False)
133 def record_creation(self, memory_id: str) -> None:
134 """Record that a new memory was created."""
135 now = time.monotonic()
136 self._stats[memory_id] = _MemoryStats(created_at=now)
138 # Move to end (most recently used) — O(1) with OrderedDict
139 self._stats.move_to_end(memory_id)
141 # Evict LRU if over capacity
142 while len(self._stats) > self.max_entries:
143 self._stats.popitem(last=False)
145 def get_utility(self, memory_id: str) -> UtilityScore | None:
146 """Compute current utility score for a memory. Returns None if not tracked."""
147 stats = self._stats.get(memory_id)
148 if stats is None:
149 return None
151 now = time.monotonic()
152 avg_relevance = stats.total_relevance / max(stats.recall_count, 1)
153 last_recalled_ago = now - stats.last_recalled_at if stats.last_recalled_at > 0 else now - stats.created_at
154 created_ago = now - stats.created_at
156 return compute_utility(
157 recall_count=stats.recall_count,
158 last_recalled_seconds_ago=last_recalled_ago,
159 avg_relevance=avg_relevance,
160 created_seconds_ago=created_ago,
161 recency_half_life_days=self.recency_half_life_days,
162 )
164 def get_recall_count(self, memory_id: str) -> int:
165 """Get the recall count for a memory."""
166 stats = self._stats.get(memory_id)
167 return stats.recall_count if stats else 0
169 def clear(self) -> None:
170 """Clear all tracked stats."""
171 self._stats.clear()