diff --git a/plugins/memory/holographic/retrieval.py b/plugins/memory/holographic/retrieval.py index 6fb6da2b77ca..95b8f2ae95db 100644 --- a/plugins/memory/holographic/retrieval.py +++ b/plugins/memory/holographic/retrieval.py @@ -70,6 +70,15 @@ def search( # Stage 2: Rerank with Jaccard + trust + optional decay query_tokens = self._tokenize(query) + # The query vector is loop-invariant — encode it at most once, on + # the first candidate that actually carries an HRR vector. Lazy on + # purpose: migrated stores can have FTS candidates whose hrr_vector + # was never backfilled (MemoryStore._init_db adds the column + # without backfilling), and those must not pay for an encode + # nothing will use. encode_text is deterministic (SHA-256 counter + # blocks), so the hoisted vector is bit-identical to what the + # per-candidate calls produced. + query_vec = None scored = [] for fact in candidates: @@ -83,7 +92,8 @@ def search( # HRR similarity if self.hrr_weight > 0 and fact.get("hrr_vector"): fact_vec = hrr.bytes_to_phases(fact["hrr_vector"]) - query_vec = hrr.encode_text(query, self.hrr_dim) + if query_vec is None: + query_vec = hrr.encode_text(query, self.hrr_dim) hrr_sim = (hrr.similarity(query_vec, fact_vec) + 1.0) / 2.0 # shift to [0,1] else: hrr_sim = 0.5 # neutral @@ -173,6 +183,9 @@ def probe( # Final fallback: keyword search return self.search(entity, category=category, limit=limit) + # role_content is loop-invariant — encode it once (deterministic + # SHA-256-based atom) instead of once per fact row. + role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim) scored = [] for row in rows: fact = dict(row) @@ -180,7 +193,6 @@ def probe( # Unbind probe key from fact to see if entity is structurally present residual = hrr.unbind(fact_vec, probe_key) # Compare residual against content signal - role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim) content_vec = hrr.bind(hrr.encode_text(fact["content"], self.hrr_dim), role_content) sim = hrr.similarity(residual, content_vec) fact["score"] = (sim + 1.0) / 2.0 * fact["trust_score"] @@ -234,6 +246,10 @@ def related( # Score each fact by how much the entity's atom appears in its vector # This catches both role-bound entity matches AND content word matches + # Both role atoms are loop-invariant — encode them once here + # (deterministic SHA-256-based atoms) instead of twice per fact row. + role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim) + role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim) scored = [] for row in rows: fact = dict(row) @@ -243,8 +259,6 @@ def related( residual = hrr.unbind(fact_vec, entity_vec) # A high-similarity residual to ANY known role vector means this entity # plays a structural role in the fact - role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim) - role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim) entity_role_sim = hrr.similarity(residual, role_entity) content_role_sim = hrr.similarity(residual, role_content) diff --git a/tests/plugins/memory/test_holographic_retrieval.py b/tests/plugins/memory/test_holographic_retrieval.py index d999bc0cd47e..3b5f8997884c 100644 --- a/tests/plugins/memory/test_holographic_retrieval.py +++ b/tests/plugins/memory/test_holographic_retrieval.py @@ -95,3 +95,141 @@ def test_prefetch_recovers_prose_query(retriever_with_facts): assert "deployment rollback" in results[0]["content"].lower() + + +# --------------------------------------------------------------------------- +# Loop-invariant encode hoists (perf) — search/probe/related must encode +# constant vectors ONCE per call, not once per candidate/row. +# encode_text/encode_atom are deterministic (SHA-256 counter blocks), so the +# hoisted vectors are bit-identical to the per-iteration values they replace. +# --------------------------------------------------------------------------- + +from plugins.memory.holographic import holographic as hrr + + +@pytest.fixture +def hoisted_retriever(): + """30 facts with HRR vectors, default dim (smaller dims trip an + inhomogeneous-shape edge in the fact encoder).""" + store = MemoryStore(":memory:") + for i in range(30): + store.add_fact( + content=f"deploy target {i} setting alpha beta gamma option {i % 7}", + category="fact" if i % 2 else "preference", + tags=f"entity_{i % 5} deploy", + ) + retriever = FactRetriever(store=store) + yield retriever + store.close() + + +def _counting_spy(monkeypatch, attr): + calls = [] + real = getattr(hrr, attr) + + def wrapper(*args, **kwargs): + calls.append(args) + return real(*args, **kwargs) + + monkeypatch.setattr(hrr, attr, wrapper) + return calls + + +def test_encode_functions_are_deterministic(): + """Soundness premise of the hoists: same input -> identical vector.""" + import numpy as np + + assert np.array_equal(hrr.encode_text("deploy target", 1024), + hrr.encode_text("deploy target", 1024)) + assert np.array_equal(hrr.encode_atom("__hrr_role_content__", 1024), + hrr.encode_atom("__hrr_role_content__", 1024)) + + +def test_search_encodes_query_vector_once(hoisted_retriever, monkeypatch): + calls = _counting_spy(monkeypatch, "encode_text") + results = hoisted_retriever.search("deploy target setting") + assert results # the HRR path actually engaged + assert len(calls) == 1, ( + f"query vector encoded {len(calls)}x in one search() — " + "loop-invariant hoist regressed" + ) + + +def test_search_results_bit_identical_to_unhoisted(hoisted_retriever): + """Parity: hoisted search() must produce the exact pre-fix results. + + Replicates the pre-fix loop (query vector encoded per candidate) as the + reference and compares full scored output for exact equality. + """ + r = hoisted_retriever + query = "deploy target setting" + new_results = r.search(query) + + # --- pre-fix reference --- + candidates = r._fts_candidates(query, None, 0.3, 10 * 3) + query_tokens = r._tokenize(query) + scored = [] + for fact in candidates: + content_tokens = r._tokenize(fact["content"]) + tag_tokens = r._tokenize(fact.get("tags", "")) + all_tokens = content_tokens | tag_tokens + jaccard = r._jaccard_similarity(query_tokens, all_tokens) + fts_score = fact.get("fts_rank", 0.0) + if r.hrr_weight > 0 and fact.get("hrr_vector"): + fact_vec = hrr.bytes_to_phases(fact["hrr_vector"]) + query_vec = hrr.encode_text(query, r.hrr_dim) # per-candidate + hrr_sim = (hrr.similarity(query_vec, fact_vec) + 1.0) / 2.0 + else: + hrr_sim = 0.5 + relevance = (r.fts_weight * fts_score + + r.jaccard_weight * jaccard + + r.hrr_weight * hrr_sim) + fact["score"] = relevance * fact["trust_score"] + scored.append(fact) + scored.sort(key=lambda x: x["score"], reverse=True) + old_results = scored[:10] + for fact in old_results: + fact.pop("hrr_vector", None) + + assert new_results == old_results + + +def test_related_encodes_role_atoms_once(hoisted_retriever, monkeypatch): + calls = _counting_spy(monkeypatch, "encode_atom") + results = hoisted_retriever.related("entity_1") + assert results + role_calls = [a for a in calls + if a and str(a[0]).startswith("__hrr_role_")] + assert len(role_calls) == 2, ( + f"role atoms encoded {len(role_calls)}x in one related() — " + "expected exactly 2 (role_entity + role_content, hoisted)" + ) + + +def test_probe_encodes_role_atom_once(hoisted_retriever, monkeypatch): + calls = _counting_spy(monkeypatch, "encode_atom") + results = hoisted_retriever.probe("entity_1") + assert results + role_content_calls = [a for a in calls + if a and a[0] == "__hrr_role_content__"] + assert len(role_content_calls) == 1, ( + f"role_content atom encoded {len(role_content_calls)}x in one " + "probe() — loop-invariant hoist regressed" + ) + + +def test_search_without_vectors_never_encodes(hoisted_retriever, monkeypatch): + """Migrated DBs can have FTS candidates with NULL hrr_vector + (MemoryStore._init_db adds the column without backfilling existing + facts). The lazy hoist must not encode a query vector nothing will + use — pre-fix main encoded only beneath fact.get('hrr_vector').""" + store = hoisted_retriever.store + store._conn.execute("UPDATE facts SET hrr_vector = NULL") + store._conn.commit() + calls = _counting_spy(monkeypatch, "encode_text") + results = hoisted_retriever.search("deploy target setting") + assert results # candidates exist; neutral hrr_sim=0.5 path + assert calls == [], ( + f"encode_text called {len(calls)}x with zero vector candidates — " + "lazy hoist regressed to eager" + )