"""Tests for iai_mcp.pipeline (D-13 5-stage retrieval pipeline). Uses a FakeEmbedder fixture so tests don't pull BAAI/bge-small-en-v1.5 from HuggingFace during every run. The Embedder contract verified separately in test_embed.py. """ from __future__ import annotations from datetime import datetime, timezone from uuid import uuid4 from iai_mcp.community import CommunityAssignment from iai_mcp.graph import MemoryGraph from iai_mcp.pipeline import ( W_AAAK, W_AGE, W_COSINE, W_DEGREE, _aaak_overlap, _community_gate, _cosine, _pick_seeds, recall_for_response, ) from iai_mcp.store import MemoryStore from iai_mcp.types import EMBED_DIM, MemoryRecord class _FakeEmbedder: """Stand-in for the configured embedder so tests don't require the model. Returns a deterministic primary-axis vector for any input. recall_for_response uses embedder.embed() only for the cue, so this is sufficient. DIM follows the default registry (bge-m3 = 1024d). Plan-02 tests that hand-build vectors must use `[1.0] + [0.0] * (DIM - 1)` style via this constant so the store.insert() dim-check passes. """ DIM = EMBED_DIM # 1024 under default (bge-m3) def embed(self, text: str) -> list[float]: return [1.0] + [0.0] * (EMBED_DIM - 1) def embed_batch(self, texts: list[str]) -> list[list[float]]: return [self.embed(t) for t in texts] def _make(vec: list[float], text: str = "rec", aaak: str = "", detail: int = 2) -> MemoryRecord: """Construct a MemoryRecord for pipeline tests. Uses `tier="episodic"` to stay within TIER_ENUM; created_at at current UTC. language="en" required. """ now = datetime.now(timezone.utc) return MemoryRecord( id=uuid4(), tier="episodic", literal_surface=text, aaak_index=aaak, embedding=vec, community_id=None, centrality=0.0, detail_level=detail, pinned=False, stability=0.0, difficulty=0.0, last_reviewed=None, never_decay=False, never_merge=False, provenance=[], created_at=now, updated_at=now, tags=[], language="en", ) # ---------------------------------------------------------- stage-unit tests def test_community_gate_picks_nearest() -> None: """CONN-06: top-1 gate on 3 centroids picks the one nearest the cue.""" c0 = uuid4() c1 = uuid4() c2 = uuid4() centroids = { c0: [1.0] + [0.0] * (EMBED_DIM - 1), c1: [0.0] * 384, c2: [-1.0] + [0.0] * (EMBED_DIM - 1), } a = CommunityAssignment(community_centroids=centroids) cue = [1.0] + [0.0] * (EMBED_DIM - 1) gated = _community_gate(cue, a, top_n=1) assert len(gated) == 1 assert gated[0] == c0 def test_community_gate_returns_top_n_in_order() -> None: c0 = uuid4() c1 = uuid4() c2 = uuid4() centroids = { c0: [1.0] + [0.0] * (EMBED_DIM - 1), c1: [0.5, 0.5] + [0.0] * (EMBED_DIM - 2), c2: [-1.0] + [0.0] * (EMBED_DIM - 1), } a = CommunityAssignment(community_centroids=centroids) cue = [1.0] + [0.0] * (EMBED_DIM - 1) gated = _community_gate(cue, a, top_n=3) assert gated == [c0, c1, c2] def test_aaak_overlap_basic_jaccard() -> None: assert _aaak_overlap("", "anything") == 0.0 assert _aaak_overlap("x", "") == 0.0 assert _aaak_overlap("a b", "a b") == 1.0 # identical # Jaccard({a,b}, {b,c}) = 1 / 3 assert abs(_aaak_overlap("a b", "b c") - 1 / 3) < 1e-9 def test_aaak_overlap_slash_split_symmetric() -> None: """AAAK tokens use '/' as separator; both sides must split on it.""" # Identical slash-delimited paths -> 1.0 (bug fix: cue side also splits). assert _aaak_overlap("auth/login", "auth/login") == 1.0 # Partial share: {auth, login} vs {auth, logout} -> Jaccard = 1/3. assert abs(_aaak_overlap("auth/login", "auth/logout") - 1 / 3) < 1e-9 # Case-insensitive. assert _aaak_overlap("AUTH/Login", "auth/login") == 1.0 def test_cosine_basic_properties() -> None: assert _cosine([1.0, 0.0], [1.0, 0.0]) == 1.0 assert _cosine([1.0, 0.0], [-1.0, 0.0]) == -1.0 assert _cosine([1.0, 0.0], [0.0, 1.0]) == 0.0 assert _cosine([0.0, 0.0], [1.0, 0.0]) == 0.0 # zero vector guard def test_score_weight_constants_match_d13() -> None: """D-13 score = cos + 0.3*aaak + 0.1*log(1+deg) − 0.05*age.""" assert W_COSINE == 1.0 assert W_AAAK == 0.3 assert W_DEGREE == 0.1 assert W_AGE == 0.05 # ------------------------------------------------------------- end-to-end def test_pipeline_returns_hits_with_adjacent_suggestions(tmp_path) -> None: """End-to-end: pipeline returns ranked hits with non-empty activation_trace and adjacent_suggestions is a list on every hit (AUTIST-07 contract).""" store = MemoryStore(path=tmp_path) records = [ _make([1.0] + [0.0] * (EMBED_DIM - 1), text="primary match", aaak="test match"), _make([0.9, 0.1] + [0.0] * (EMBED_DIM - 2), text="close match"), _make([0.0, 1.0] + [0.0] * (EMBED_DIM - 2), text="orthogonal"), _make([-1.0] + [0.0] * (EMBED_DIM - 1), text="opposite"), _make([0.5, 0.5] + [0.0] * (EMBED_DIM - 2), text="mid"), ] for r in records: store.insert(r) graph = MemoryGraph() for r in records: graph.add_node(r.id, community_id=None, embedding=r.embedding) for i in range(len(records) - 1): graph.add_edge(records[i].id, records[i + 1].id) community_id = uuid4() assignment = CommunityAssignment( node_to_community={r.id: community_id for r in records}, community_centroids={community_id: [1.0] + [0.0] * (EMBED_DIM - 1)}, modularity=0.0, backend="flat", top_communities=[community_id], mid_regions={community_id: [r.id for r in records]}, ) resp = recall_for_response( store=store, graph=graph, assignment=assignment, rich_club=[], embedder=_FakeEmbedder(), cue="test match", session_id="s1", ) assert len(resp.hits) >= 1 # Primary record has aaak overlap ("test match" in both cue and aaak_index), # cosine=1.0, and degree=1: score = 1.0 + 0.3*1.0 + 0.1*log(2) ≈ 1.369. # Close record has cos≈0.994, no aaak, degree=2: 0.994 + 0.1*log(3) ≈ 1.104. # Primary must win thanks to the AAAK overlap bonus. assert resp.hits[0].literal_surface == "primary match" # Opposite record must NOT appear as a top hit (negative cosine). assert all(h.literal_surface != "opposite" for h in resp.hits[:2]) # adjacent_suggestions must be a list on every hit. for h in resp.hits: assert isinstance(h.adjacent_suggestions, list) # activation_trace = seeds ∪ spread; must not be empty here. assert len(resp.activation_trace) >= 1 def test_pipeline_provenance_appended_to_every_hit(tmp_path) -> None: """MEM-05 regression: every hit returned gets a provenance entry.""" store = MemoryStore(path=tmp_path) r1 = _make([1.0] + [0.0] * (EMBED_DIM - 1), text="primary") store.insert(r1) graph = MemoryGraph() graph.add_node(r1.id, community_id=None, embedding=r1.embedding) community_id = uuid4() assignment = CommunityAssignment( node_to_community={r1.id: community_id}, community_centroids={community_id: [1.0] + [0.0] * (EMBED_DIM - 1)}, modularity=0.0, backend="flat", top_communities=[community_id], mid_regions={community_id: [r1.id]}, ) recall_for_response( store=store, graph=graph, assignment=assignment, rich_club=[], embedder=_FakeEmbedder(), cue="anything", session_id="session-42", ) refreshed = store.get(r1.id) assert refreshed is not None assert len(refreshed.provenance) == 1 assert refreshed.provenance[0]["session_id"] == "session-42" assert refreshed.provenance[0]["cue"] == "anything" def test_pipeline_budget_caps_hit_count(tmp_path) -> None: """Budget enforcement: when tokens exceeded, pipeline stops adding hits.""" store = MemoryStore(path=tmp_path) # 5 records each with ~200 chars (~50 tokens). Budget=60 -> only first fits. long_text = "x" * 200 records = [] for i in range(5): r = _make( [1.0, float(i) * 0.001] + [0.0] * (EMBED_DIM - 2), text=f"{long_text}-{i}", ) records.append(r) store.insert(r) graph = MemoryGraph() for r in records: graph.add_node(r.id, community_id=None, embedding=r.embedding) community_id = uuid4() assignment = CommunityAssignment( node_to_community={r.id: community_id for r in records}, community_centroids={community_id: [1.0] + [0.0] * (EMBED_DIM - 1)}, modularity=0.0, backend="flat", top_communities=[community_id], mid_regions={community_id: [r.id for r in records]}, ) resp = recall_for_response( store=store, graph=graph, assignment=assignment, rich_club=[], embedder=_FakeEmbedder(), cue="c", session_id="s", budget_tokens=60, ) # With 50-token records and 60-token budget, at most 1 hit fits then loop breaks. # (We always admit 1 even if it exceeds budget, per the len(hits)>=1 guard.) assert len(resp.hits) == 1 def test_pipeline_anti_hits_from_contradicts_edge(tmp_path) -> None: """D-13 anti-hit contract: contradicts-edge neighbours of a top hit surface.""" from iai_mcp.core import dispatch store = MemoryStore(path=tmp_path) r1 = _make([1.0] + [0.0] * (EMBED_DIM - 1), text="original") store.insert(r1) dispatch( store, "memory_contradict", { "id": str(r1.id), "new_fact": "refuted version", "cue_embedding": r1.embedding, }, ) graph = MemoryGraph() graph.add_node(r1.id, community_id=None, embedding=[1.0] + [0.0] * (EMBED_DIM - 1)) community_id = uuid4() assignment = CommunityAssignment( node_to_community={r1.id: community_id}, community_centroids={community_id: [1.0] + [0.0] * (EMBED_DIM - 1)}, modularity=0.0, backend="flat", top_communities=[community_id], mid_regions={community_id: [r1.id]}, ) resp = recall_for_response( store=store, graph=graph, assignment=assignment, rich_club=[], embedder=_FakeEmbedder(), cue="anything", session_id="s1", ) assert len(resp.anti_hits) >= 1 assert "refuted" in resp.anti_hits[0].literal_surface def test_pipeline_activation_trace_includes_seeds(tmp_path) -> None: """activation_trace = seeds ∪ spread; must contain each seed.""" store = MemoryStore(path=tmp_path) a = _make([1.0] + [0.0] * (EMBED_DIM - 1), text="A") b = _make([0.9, 0.1] + [0.0] * (EMBED_DIM - 2), text="B") c = _make([0.0, 1.0] + [0.0] * (EMBED_DIM - 2), text="C") for r in (a, b, c): store.insert(r) graph = MemoryGraph() for r in (a, b, c): graph.add_node(r.id, community_id=None, embedding=r.embedding) graph.add_edge(a.id, b.id) graph.add_edge(b.id, c.id) community_id = uuid4() assignment = CommunityAssignment( node_to_community={r.id: community_id for r in (a, b, c)}, community_centroids={community_id: [1.0] + [0.0] * (EMBED_DIM - 1)}, modularity=0.0, backend="flat", top_communities=[community_id], mid_regions={community_id: [a.id, b.id, c.id]}, ) resp = recall_for_response( store=store, graph=graph, assignment=assignment, rich_club=[], embedder=_FakeEmbedder(), cue="c", session_id="s", ) # The top-cosine seed is A; its 2-hop neighbourhood is {B, C}. Trace must contain A. assert a.id in resp.activation_trace def test_pick_seeds_ranks_by_blended_score(tmp_path) -> None: """Stage 3 blend: 0.6*cos + 0.4*centrality picks the high-blend record first. redesign: `_pick_seeds` now operates over a precomputed shared cosine array; positions, not UUIDs, flow through. Reproduces the pre-redesign assertion: r2 (cos=0.707, cen=1.0, blend=0.82) beats r1 (cos=1.0, cen=0.0, blend=0.6) at n=1. """ import numpy as np # Pool layout: position 0 = r1, position 1 = r2. # cue = axis 0 -> shared_cos = [1.0, 0.707]. shared_cos = np.array([1.0, 0.7071068], dtype=np.float32) centrality_arr = np.array([0.0, 1.0], dtype=np.float32) candidate_indices = np.array([0, 1], dtype=np.int64) seed_indices = _pick_seeds( candidate_indices, shared_cos, centrality_arr, n=1, ) # r2 (position 1): blend = 0.6 * 0.707 + 0.4 * 1.0 = 0.824 > r1's 0.6. assert list(seed_indices) == [1] def test_pipeline_core_dispatch_integration(tmp_path, monkeypatch) -> None: """core.dispatch("memory_recall", ...) routes to pipeline for non-empty store.""" import iai_mcp.pipeline as pipeline_mod from iai_mcp.core import dispatch store = MemoryStore(path=tmp_path) r = _make([1.0] + [0.0] * (EMBED_DIM - 1), text="integration") store.insert(r) # Stub out Embedder inside core to avoid HF download. class _StubEmbedder: DIM = 384 def embed(self, text: str) -> list[float]: return [1.0] + [0.0] * (EMBED_DIM - 1) # core.py imports Embedder lazily inside dispatch -> patch at module level. import iai_mcp.embed as embed_mod monkeypatch.setattr(embed_mod, "Embedder", _StubEmbedder) resp = dispatch( store, "memory_recall", {"cue": "integration", "session_id": "s-int"}, ) assert "hits" in resp assert isinstance(resp["hits"], list) # activation_trace field always present, list of string UUIDs. assert isinstance(resp["activation_trace"], list) assert "budget_used" in resp def test_pipeline_empty_gate_falls_back_to_all_nodes(tmp_path) -> None: """If community gate returns no candidates, pipeline falls back to all nodes.""" store = MemoryStore(path=tmp_path) r = _make([1.0] + [0.0] * (EMBED_DIM - 1), text="lonely") store.insert(r) graph = MemoryGraph() graph.add_node(r.id, community_id=None, embedding=r.embedding) # Assignment whose mid_regions is empty (degenerate) -> pipeline must fall back. assignment = CommunityAssignment( node_to_community={}, community_centroids={}, modularity=0.0, backend="flat", top_communities=[], mid_regions={}, ) resp = recall_for_response( store=store, graph=graph, assignment=assignment, rich_club=[], embedder=_FakeEmbedder(), cue="c", session_id="s", ) # The lone record is still reachable via the fallback. assert len(resp.hits) == 1