diff --git a/plugins/memory/holographic/retrieval.py b/plugins/memory/holographic/retrieval.py index a673dcef846c..04adbac4aa9b 100644 --- a/plugins/memory/holographic/retrieval.py +++ b/plugins/memory/holographic/retrieval.py @@ -106,6 +106,7 @@ def search( # Sort by score descending, return top limit scored.sort(key=lambda x: x["score"], reverse=True) results = scored[:limit] + self._mark_retrieved(results) # Strip raw HRR bytes — callers expect JSON-serializable dicts for fact in results: fact.pop("hrr_vector", None) @@ -187,7 +188,9 @@ def probe( scored.append(fact) scored.sort(key=lambda x: x["score"], reverse=True) - return scored[:limit] + results = scored[:limit] + self._mark_retrieved(results) + return results def related( self, @@ -255,7 +258,9 @@ def related( scored.append(fact) scored.sort(key=lambda x: x["score"], reverse=True) - return scored[:limit] + results = scored[:limit] + self._mark_retrieved(results) + return results def reason( self, @@ -333,7 +338,9 @@ def reason( scored.append(fact) scored.sort(key=lambda x: x["score"], reverse=True) - return scored[:limit] + results = scored[:limit] + self._mark_retrieved(results) + return results def contradict( self, @@ -476,7 +483,19 @@ def _score_facts_by_vector( scored.append(fact) scored.sort(key=lambda x: x["score"], reverse=True) - return scored[:limit] + results = scored[:limit] + self._mark_retrieved(results) + return results + + def _mark_retrieved(self, facts: list[dict]) -> None: + """Record that returned facts were retrieved by a user/tool query.""" + fact_ids = [fact["fact_id"] for fact in facts if "fact_id" in fact] + try: + self.store.mark_retrieved(fact_ids) + except Exception: + # Retrieval counters are advisory; never fail a memory lookup over + # usage-metric bookkeeping. + pass def _fts_candidates( self, diff --git a/plugins/memory/holographic/store.py b/plugins/memory/holographic/store.py index 67628102d883..61c6c70f565a 100644 --- a/plugins/memory/holographic/store.py +++ b/plugins/memory/holographic/store.py @@ -229,16 +229,24 @@ def search_facts( results = [self._row_to_dict(r) for r in rows] if results: - ids = [r["fact_id"] for r in results] - placeholders = ",".join("?" * len(ids)) - self._conn.execute( - f"UPDATE facts SET retrieval_count = retrieval_count + 1 WHERE fact_id IN ({placeholders})", - ids, - ) - self._conn.commit() + self.mark_retrieved([r["fact_id"] for r in results]) return results + def mark_retrieved(self, fact_ids: list[int]) -> None: + """Increment retrieval_count once for each unique fact id.""" + unique_ids = sorted({int(fid) for fid in fact_ids}) + if not unique_ids: + return + + with self._lock: + placeholders = ",".join("?" * len(unique_ids)) + self._conn.execute( + f"UPDATE facts SET retrieval_count = retrieval_count + 1 WHERE fact_id IN ({placeholders})", + unique_ids, + ) + self._conn.commit() + def update_fact( self, fact_id: int, diff --git a/tests/plugins/memory/test_holographic_retrieval_count.py b/tests/plugins/memory/test_holographic_retrieval_count.py new file mode 100644 index 000000000000..f6e6cbc31771 --- /dev/null +++ b/tests/plugins/memory/test_holographic_retrieval_count.py @@ -0,0 +1,104 @@ +"""Regression coverage for holographic fact retrieval counters.""" + +from __future__ import annotations + +import json + +import pytest + +from plugins.memory.holographic import HolographicMemoryProvider +from plugins.memory.holographic import holographic as hrr + + +def _make_provider(tmp_path): + provider = HolographicMemoryProvider( + config={"db_path": str(tmp_path / "memory_store.db"), "hrr_dim": 64} + ) + provider.initialize(session_id="test-session") + return provider + + +def _call_fact_store(provider, **args): + return json.loads(provider.handle_tool_call("fact_store", args)) + + +def _counts_by_id(provider): + return { + fact["fact_id"]: fact["retrieval_count"] + for fact in provider._store.list_facts(limit=20) + } + + +def test_search_increments_retrieval_count_for_returned_facts(tmp_path): + provider = _make_provider(tmp_path) + try: + kept = _call_fact_store( + provider, + action="add", + content='"Hermes" records holographic memory retrieval counts.', + category="tool", + )["fact_id"] + other = _call_fact_store( + provider, + action="add", + content='"Cafe24" deployment cache behavior is unrelated.', + category="tool", + )["fact_id"] + + result = _call_fact_store( + provider, + action="search", + query="Hermes holographic retrieval", + limit=1, + ) + + assert [fact["fact_id"] for fact in result["results"]] == [kept] + assert _counts_by_id(provider) == {kept: 1, other: 0} + + _call_fact_store( + provider, + action="search", + query="Hermes holographic retrieval", + limit=1, + ) + assert _counts_by_id(provider) == {kept: 2, other: 0} + finally: + provider.shutdown() + + +@pytest.mark.parametrize( + ("args"), + [ + {"action": "probe", "entity": "Hermes", "limit": 2}, + {"action": "related", "entity": "Hermes", "limit": 2}, + {"action": "reason", "entities": ["Hermes", "Memory"], "limit": 2}, + ], +) +def test_structural_retrieval_increments_returned_fact_counts(tmp_path, args): + if not hrr._HAS_NUMPY: + pytest.skip("structural holographic retrieval requires numpy") + + provider = _make_provider(tmp_path) + try: + _call_fact_store( + provider, + action="add", + content='"Hermes" and "Memory" should update retrieval counters.', + category="tool", + ) + _call_fact_store( + provider, + action="add", + content='"Hermes" and "Cron" share operational context.', + category="tool", + ) + + result = _call_fact_store(provider, **args) + returned_ids = {fact["fact_id"] for fact in result["results"]} + + assert returned_ids + counts = _counts_by_id(provider) + for fact_id in returned_ids: + assert counts[fact_id] == 1 + finally: + provider.shutdown()