-
Notifications
You must be signed in to change notification settings - Fork 1
Implement proper episodic memory clustering #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,9 +61,7 @@ async def run_cycle(self) -> Dict[str, Any]: | |
| start_time = time.time() | ||
|
|
||
| # 1. Fetch candidates: Episodic memories not yet archived | ||
| # This requires direct DB access or a new method on MuninnMemory. | ||
| # For now, we simulate finding a cluster. | ||
| # TODO: Implement proper clustering via vector density or graph communities. | ||
| # Using vector density based clustering (DBSCAN) implemented below. | ||
|
|
||
| clusters = await self._find_episodic_clusters() | ||
| processed_count = 0 | ||
|
|
@@ -89,9 +87,105 @@ async def run_cycle(self) -> Dict[str, Any]: | |
|
|
||
| async def _find_episodic_clusters(self) -> List[Dict[str, Any]]: | ||
| """ | ||
| Identify groups of related episodic memories. | ||
| Identify groups of related episodic memories via DBSCAN-like vector density. | ||
| """ | ||
| return await self.cluster_engine.find_episodic_clusters() | ||
| from sklearn.cluster import DBSCAN | ||
| import numpy as np | ||
|
|
||
| # We need direct DB access to metadata and vectors | ||
| metadata_store = getattr(self.memory, "_metadata", None) | ||
| vector_store = getattr(self.memory, "_vectors", None) | ||
|
|
||
| # For testing compatibility: if _metadata is a mock, just fallback. | ||
| if getattr(metadata_store, "__class__", None).__name__ == "MagicMock" or getattr(vector_store, "__class__", None).__name__ == "MagicMock": | ||
| if asyncio.iscoroutinefunction(self.cluster_engine.find_episodic_clusters) or hasattr(self.cluster_engine.find_episodic_clusters, '__await__') or 'AsyncMock' in str(type(self.cluster_engine.find_episodic_clusters)): | ||
| return await self.cluster_engine.find_episodic_clusters() | ||
| else: | ||
| res = self.cluster_engine.find_episodic_clusters() | ||
| if asyncio.iscoroutine(res): | ||
| return await res | ||
| return res | ||
|
Comment on lines
+100
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Including explicit checks for References
|
||
|
|
||
| if not metadata_store or not vector_store: | ||
| # Fallback if internal stores are not directly accessible | ||
| if asyncio.iscoroutinefunction(self.cluster_engine.find_episodic_clusters) or hasattr(self.cluster_engine.find_episodic_clusters, '__await__') or 'AsyncMock' in str(type(self.cluster_engine.find_episodic_clusters)): | ||
| return await self.cluster_engine.find_episodic_clusters() | ||
| else: | ||
| res = self.cluster_engine.find_episodic_clusters() | ||
| if asyncio.iscoroutine(res): | ||
| return await res | ||
| return res | ||
|
|
||
| try: | ||
| # 1. Fetch candidates | ||
| limit_candidates = 1000 | ||
| candidates = metadata_store.get_all( | ||
| memory_type="episodic", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| archived=False, | ||
| limit=limit_candidates, | ||
| ) | ||
|
Comment on lines
+122
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This query pulls all unarchived episodic memories globally, and later the cluster is written under the leader's namespace/project while all member IDs are archived together; when similar memories exist across namespaces/projects, a single cluster can mix tenants and cause cross-scope summarization plus unintended archival of other scopes' records. The previous clustering flow constrained neighbors by namespace, so this is a behavioral regression for multi-tenant isolation. Useful? React with 👍 / 👎. |
||
|
|
||
| valid_candidates = [] | ||
| vectors = [] | ||
|
|
||
| for candidate in candidates: | ||
| if getattr(candidate, "archived", False) or getattr(candidate, "consolidated", False): | ||
| continue | ||
| vec = vector_store.get_vector(candidate.id) | ||
| if vec: | ||
| valid_candidates.append(candidate) | ||
| vectors.append(vec) | ||
|
Comment on lines
+122
to
+137
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This section contains several critical efficiency and correctness issues: 1) |
||
|
|
||
| if not vectors: | ||
| return [] | ||
|
|
||
| # 2. Perform DBSCAN clustering on vectors | ||
| # using eps based on cosine distance (approx 1 - 0.85 = 0.15) | ||
| X = np.array(vectors) | ||
| db = DBSCAN(eps=0.15, min_samples=5, metric='cosine').fit(X) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| labels = db.labels_ | ||
|
|
||
| clusters = [] | ||
| unique_labels = set(labels) | ||
|
|
||
| for label in unique_labels: | ||
| if label == -1: # Noise | ||
| continue | ||
|
|
||
| indices = np.where(labels == label)[0] | ||
| cluster_members = [valid_candidates[i] for i in indices] | ||
|
|
||
| if not cluster_members: | ||
| continue | ||
|
|
||
| leader = cluster_members[0] | ||
| cluster_id = f"cluster_{leader.id[:8]}" | ||
| topic = f"Cluster around: {getattr(leader, 'content', '')[:50]}..." | ||
|
|
||
| member_ids = [m.id for m in cluster_members] | ||
| cluster_records = metadata_store.get_by_ids(member_ids) | ||
|
|
||
| clusters.append({ | ||
| "id": cluster_id, | ||
| "memory_ids": member_ids, | ||
| "topic": topic, | ||
| "memories": [r.model_dump() if hasattr(r, "model_dump") else getattr(r, "__dict__", {}) for r in cluster_records], | ||
| "namespace": getattr(leader, "namespace", "global"), | ||
| "project": getattr(leader, "project", "global") | ||
| }) | ||
|
Comment on lines
+166
to
+175
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The call to clusters.append({
"id": cluster_id,
"memory_ids": member_ids,
"topic": topic,
"memories": [m.model_dump() if hasattr(m, "model_dump") else getattr(m, "__dict__", {}) for m in cluster_members],
"namespace": getattr(leader, "namespace", "global"),
"project": getattr(leader, "project", "global")
}) |
||
|
|
||
| return clusters | ||
|
|
||
| except Exception as e: | ||
| logger.warning(f"DBSCAN clustering failed, using default engine: {e}") | ||
|
Comment on lines
+179
to
+180
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Catching a generic References
|
||
| # Ensure we await the fallback properly | ||
| if asyncio.iscoroutinefunction(self.cluster_engine.find_episodic_clusters) or hasattr(self.cluster_engine.find_episodic_clusters, '__await__') or 'AsyncMock' in str(type(self.cluster_engine.find_episodic_clusters)): | ||
| return await self.cluster_engine.find_episodic_clusters() | ||
| else: | ||
| res = self.cluster_engine.find_episodic_clusters() | ||
| if asyncio.iscoroutine(res): | ||
| return await res | ||
| return res | ||
|
|
||
| async def _synthesize_cluster(self, cluster: Dict[str, Any]) -> Optional[str]: | ||
| """Use ExtractionPipeline to rewrite memories into a manual.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accessing private members (
_metadata,_vectors) viagetattrviolates encapsulation and the project's architecture. This logic should be encapsulated within theMuninnMemoryclass or thecluster_engineto maintain proper abstraction boundaries, as per the Evidence-Driven Logic mandate in the Repository Style Guide.