From b06ca2d48f0660839c6135b6129607d39a5b7664 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 18 Jun 2026 19:22:08 -0700 Subject: [PATCH 1/5] feat(npu): NPU-native FLM embedder (embed-gemma-300m-FLM) for the NPU profile (#1744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the NPU chat profile the chat model runs on the FLM/NPU backend while the embedder (nomic GGUF) runs on Vulkan/llama.cpp. On a shared-memory Ryzen AI APU loading the Vulkan embedder reclaims the memory the FLM model holds, so the two are never co-resident and every chat turn swaps backends — an endless load/unload loop (#1676). Make the embedder device-scoped: the NPU profile now uses the FLM-native embed-gemma-300m-FLM so chat and embeddings both live on the FLM/NPU backend. GPU/CPU keep the GGUF nomic embedder unchanged. - registry: DeviceConfig gains embedding_model (single source of truth next to the chat model/recipe/backend); get_embedding_model_for_device() resolves it. - lemonade_client: register embed-gemma-300m-FLM in MODELS. - init_command: NPU profile downloads the FLM embedder (pulled by name, no recipe — built-in FLM model, #1655-safe). - memory: derive the embedding dimension from the live embedder instead of a hardcoded 768, and invalidate stored vectors keyed on the embedder id — EmbeddingGemma is also 768-dim but a different vector space, so a dim-only check can't detect the swap. - chat agent: select the embedder from config.device for both RAG and memory. Closes #1744. Part of #1746. --- src/gaia/agents/base/memory.py | 79 ++++++++--- src/gaia/agents/base/memory_store.py | 45 ++++++ src/gaia/agents/chat/agent.py | 14 +- src/gaia/agents/registry.py | 23 +++ src/gaia/installer/init_command.py | 6 +- src/gaia/llm/lemonade_client.py | 15 ++ tests/unit/test_init_command.py | 14 +- tests/unit/test_npu_flm_embedder.py | 201 +++++++++++++++++++++++++++ 8 files changed, 367 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_npu_flm_embedder.py diff --git a/src/gaia/agents/base/memory.py b/src/gaia/agents/base/memory.py index cc3b101b5..b3b018776 100644 --- a/src/gaia/agents/base/memory.py +++ b/src/gaia/agents/base/memory.py @@ -144,10 +144,15 @@ def _changed_software_versions(existing: List[Dict]) -> List[str]: # Constants # ============================================================================ -#: Embedding model served by Lemonade — 768-dim, MOE architecture. +#: Default embedder served by Lemonade — 768-dim GGUF (GPU/CPU profiles). +#: The active embedder is per-instance (``self._embedding_model``) and may be +#: the NPU-native FLM embedder instead; see ``init_memory`` (#1744). These +#: module constants remain the fallback default. EMBEDDING_MODEL = "nomic-embed-text-v2-moe-GGUF" -#: Embedding dimensionality for nomic-embed-text-v2-moe. +#: Default embedding dimensionality (nomic-embed-text-v2-moe). The active dim is +#: derived from the live embedder at startup (``self._embedding_dim``); this is +#: only the pre-probe fallback. EMBEDDING_DIM = 768 #: Cross-encoder model for reranking (~22 MB, runs on CPU). @@ -306,7 +311,10 @@ class MemoryMixin: """ def init_memory( - self, db_path: Optional[Path] = None, context: str = "global" + self, + db_path: Optional[Path] = None, + context: str = "global", + embedding_model: Optional[str] = None, ) -> None: """Initialize the memory subsystem (v2 startup sequence). @@ -319,6 +327,11 @@ def init_memory( Args: db_path: Optional path for the DB file. Default: ~/.gaia/memory.db context: Active context scope (e.g., 'work', 'personal', 'global'). + embedding_model: Embedder model id. Defaults to ``EMBEDDING_MODEL`` + (GGUF nomic). The NPU profile passes the FLM-native embedder so + chat and embeddings stay co-resident on the NPU backend (#1744). + The embedding dimension is derived from the live embedder, not + this id, so a model with a different dim works without changes. Raises: RuntimeError: If Lemonade embedding service is unreachable @@ -329,6 +342,10 @@ def init_memory( # Explicit opt-out for environments that don't need memory (security # tests, lint-time imports, etc.). This is NOT a silent fallback — # the user/test author has explicitly set the env var. + self._embedding_model = embedding_model or EMBEDDING_MODEL + # Pre-probe default; refined from the live embedder below. + self._embedding_dim = EMBEDDING_DIM + if os.environ.get("GAIA_MEMORY_DISABLED") == "1": logger.info( "[MemoryMixin] memory disabled via GAIA_MEMORY_DISABLED=1; " @@ -381,17 +398,39 @@ def init_memory( # via ``_memory_store is None`` checks at every memory operation. try: self._get_embedder() - # Validate connectivity with a small test embedding + # Validate connectivity AND derive the embedding dimension from the + # live embedder — different embedders (e.g. the NPU FLM embedder) + # have different dims, so the FAISS index must match the active + # model rather than a hardcoded constant (#1744). test_vec = self._embed_text("connectivity test") - if test_vec.shape[0] != EMBEDDING_DIM: + dim = int(test_vec.shape[0]) + if dim <= 0: raise RuntimeError( - f"Embedding dimension mismatch: expected {EMBEDDING_DIM}, " - f"got {test_vec.shape[0]}" + f"Embedder '{self._embedding_model}' returned a 0-length " + "vector. Check that the model is loaded in Lemonade." ) + self._embedding_dim = dim logger.info( - "[MemoryMixin] Lemonade embedding service validated (%d-dim)", - EMBEDDING_DIM, + "[MemoryMixin] Lemonade embedding service validated " + "(model=%s, %d-dim)", + self._embedding_model, + self._embedding_dim, ) + # Invalidate stored vectors when the embedder changed. Vectors from + # a different model live in a different vector space (even at the + # same dim), so reusing them silently corrupts similarity search. + # Clearing forces backfill to re-embed with the active model. + prior = self._memory_store.get_embedder_id() + if prior is not None and prior != self._embedding_model: + cleared = self._memory_store.clear_all_embeddings() + logger.warning( + "[MemoryMixin] embedder changed (%s -> %s); cleared %d stored " + "embedding(s) for re-embedding", + prior, + self._embedding_model, + cleared, + ) + self._memory_store.set_embedder_id(self._embedding_model) except Exception as e: logger.warning( "[MemoryMixin] Lemonade embedding service unreachable — " @@ -604,7 +643,7 @@ def _get_embedder(self) -> Any: try: from gaia.llm.providers.lemonade import LemonadeProvider - self._embedder = LemonadeProvider(model=EMBEDDING_MODEL) + self._embedder = LemonadeProvider(model=self._embedding_model) logger.debug("[MemoryMixin] LemonadeProvider initialized for embeddings") return self._embedder except Exception as e: @@ -613,7 +652,7 @@ def _get_embedder(self) -> Any: ) from e def _embed_text(self, text: str) -> np.ndarray: - """Embed text via Lemonade (nomic-embed-text-v2-moe-GGUF, 768-dim). + """Embed text via Lemonade using the active embedder. Required, not optional. Raises RuntimeError if embedding fails. @@ -621,12 +660,12 @@ def _embed_text(self, text: str) -> np.ndarray: text: Text to embed. Returns: - L2-normalized float32 numpy array of shape (768,). + L2-normalized float32 numpy array of shape ``(self._embedding_dim,)``. """ embedder = self._get_embedder() try: # LemonadeProvider.embed() returns list[list[float]] - results = embedder.embed([text], model=EMBEDDING_MODEL) + results = embedder.embed([text], model=self._embedding_model) vec = np.array(results[0], dtype=np.float32) # L2-normalize for cosine similarity via IndexFlatIP @@ -684,13 +723,13 @@ def _rebuild_faiss_index(self) -> None: # Get all active knowledge items that have embeddings items = store.get_items_with_embeddings(include_sensitive=True) - index = faiss.IndexFlatIP(EMBEDDING_DIM) + index = faiss.IndexFlatIP(self._embedding_dim) id_map = [] for item in items: try: vec = _blob_to_embedding(item["embedding"]) - if vec.shape[0] != EMBEDDING_DIM: + if vec.shape[0] != self._embedding_dim: logger.debug( "[MemoryMixin] skipping embedding for %s: wrong dim %d", item["id"], @@ -750,11 +789,11 @@ def _faiss_remove(self, knowledge_id: str) -> None: # Reconstruct all vectors except the removed one n = self._faiss_index.ntotal if n <= 1: - self._faiss_index = faiss.IndexFlatIP(EMBEDDING_DIM) + self._faiss_index = faiss.IndexFlatIP(self._embedding_dim) self._faiss_id_map = [] return - all_vecs = np.zeros((n, EMBEDDING_DIM), dtype=np.float32) + all_vecs = np.zeros((n, self._embedding_dim), dtype=np.float32) for i in range(n): all_vecs[i] = self._faiss_index.reconstruct(i) @@ -762,7 +801,7 @@ def _faiss_remove(self, knowledge_id: str) -> None: keep_vecs = np.delete(all_vecs, idx, axis=0) keep_ids = self._faiss_id_map[:idx] + self._faiss_id_map[idx + 1 :] - new_index = faiss.IndexFlatIP(EMBEDDING_DIM) + new_index = faiss.IndexFlatIP(self._embedding_dim) new_index.add(keep_vecs) self._faiss_index = new_index self._faiss_id_map = keep_ids @@ -1411,7 +1450,7 @@ def reconcile_memory(self, max_pairs: int = 20) -> Dict: for item in items: try: vec = _blob_to_embedding(item["embedding"]) - if vec.shape[0] != EMBEDDING_DIM: + if vec.shape[0] != self._embedding_dim: continue norm = np.linalg.norm(vec) if norm > 0: @@ -1431,7 +1470,7 @@ def reconcile_memory(self, max_pairs: int = 20) -> Dict: try: import faiss - temp_index = faiss.IndexFlatIP(EMBEDDING_DIM) + temp_index = faiss.IndexFlatIP(self._embedding_dim) temp_index.add(mat) # Search each item for its top-5 neighbors diff --git a/src/gaia/agents/base/memory_store.py b/src/gaia/agents/base/memory_store.py index 0b1c3ccb3..73c3c4b0e 100644 --- a/src/gaia/agents/base/memory_store.py +++ b/src/gaia/agents/base/memory_store.py @@ -1391,6 +1391,51 @@ def store_embedding(self, knowledge_id: str, embedding: bytes) -> bool: self._conn.rollback() raise + def clear_all_embeddings(self) -> int: + """Null out every stored embedding so they get re-embedded on backfill. + + Used when the active embedder changes: vectors from a different model + live in a different vector space (and possibly a different dimension), + so reusing them would silently corrupt similarity search. Returns the + number of rows cleared. + """ + with self._lock: + try: + rowcount = self._conn.execute( + "UPDATE knowledge SET embedding = NULL WHERE embedding IS NOT NULL" + ).rowcount + self._conn.commit() + return rowcount + except Exception: + self._conn.rollback() + raise + + def _embedder_marker_path(self) -> Path: + """Sidecar path that records which embedder produced the stored vectors.""" + return self._db_path.with_name(self._db_path.name + ".embedder") + + def get_embedder_id(self) -> Optional[str]: + """Return the embedder model id that produced the stored embeddings. + + ``None`` when no embeddings have been written yet (fresh DB) or the + marker is missing — callers treat that as "no change to detect". + """ + marker = self._embedder_marker_path() + try: + if marker.exists(): + return marker.read_text(encoding="utf-8").strip() or None + except OSError as e: + logger.warning("[MemoryStore] could not read embedder marker: %s", e) + return None + + def set_embedder_id(self, model_id: str) -> None: + """Record the embedder model id that produced the stored embeddings.""" + marker = self._embedder_marker_path() + try: + marker.write_text(model_id, encoding="utf-8") + except OSError as e: + logger.warning("[MemoryStore] could not write embedder marker: %s", e) + def get_items_with_embeddings( self, category: str | None = None, diff --git a/src/gaia/agents/chat/agent.py b/src/gaia/agents/chat/agent.py index 1704faf31..7df328974 100644 --- a/src/gaia/agents/chat/agent.py +++ b/src/gaia/agents/chat/agent.py @@ -18,12 +18,13 @@ from gaia.agents.base.agent import Agent, default_max_steps from gaia.agents.base.console import AgentConsole -from gaia.agents.base.memory import EMBEDDING_MODEL, MemoryMixin +from gaia.agents.base.memory import MemoryMixin from gaia.agents.base.tool_loader import ToolLoader from gaia.agents.base.tools import _TOOL_REGISTRY from gaia.agents.chat.session import SessionManager from gaia.agents.chat.tool_bundles import DOC_BUNDLES, DOC_CORE_TOOLS from gaia.agents.chat.tools import FileToolsMixin +from gaia.agents.registry import get_embedding_model_for_device from gaia.agents.tools import FileSystemToolsMixin # Enhanced file system navigation from gaia.agents.tools import ScratchpadToolsMixin # Structured data analysis from gaia.agents.tools import ( # Web browsing and search; Shared tools @@ -253,10 +254,17 @@ def __init__(self, config: Optional[ChatAgentConfig] = None): else os.getenv("LEMONADE_BASE_URL", "http://localhost:13305/api/v1") ) + # Embedder is device-scoped: the NPU profile uses the FLM-native + # embedder so chat and embeddings stay co-resident on the NPU backend + # (a GGUF embedder runs on Vulkan and evicts the FLM chat model every + # turn — #1744). GPU/CPU keep the GGUF nomic embedder. + effective_embedding_model = get_embedding_model_for_device(config.device) + # Initialize RAG SDK (optional - will be None if dependencies not installed) try: rag_config = RAGConfig( model=effective_model_id, + embedding_model=effective_embedding_model, chunk_size=config.chunk_size, chunk_overlap=config.chunk_overlap, # Configurable overlap for context preservation max_chunks=config.max_chunks, @@ -347,7 +355,7 @@ def __init__(self, config: Optional[ChatAgentConfig] = None): self.tool_loader = self._maybe_build_tool_loader() # Initialize memory subsystem (before super().__init__ which calls _register_tools) - self.init_memory() + self.init_memory(embedding_model=effective_embedding_model) # Store base URL for use in _register_tools() (VLM, etc.) self._base_url = effective_base_url @@ -498,7 +506,7 @@ def _embed_texts_batch(self, texts) -> "Any": """ import numpy as np - results = self._get_embedder().embed(list(texts), model=EMBEDDING_MODEL) + results = self._get_embedder().embed(list(texts), model=self._embedding_model) vecs = np.asarray(results, dtype=np.float32) norms = np.linalg.norm(vecs, axis=1, keepdims=True) norms[norms == 0] = 1.0 diff --git a/src/gaia/agents/registry.py b/src/gaia/agents/registry.py index 222ae01d6..f1acef96c 100644 --- a/src/gaia/agents/registry.py +++ b/src/gaia/agents/registry.py @@ -241,6 +241,10 @@ class DeviceConfig: verified: Whether this combination has been tested end-to-end via agent eval. Unverified configs show a warning badge in the UI. ctx_size: Default context window size for this configuration. + embedding_model: Embedder model id for RAG/memory on this device. NPU + uses the FLM-native embedder so the chat model and embedder stay + co-resident on the NPU backend; a GGUF embedder runs on Vulkan and + evicts the FLM chat model every turn on a shared-memory APU (#1744). """ device: Literal["cpu", "gpu", "npu"] @@ -249,6 +253,7 @@ class DeviceConfig: backend: str verified: bool = False ctx_size: int = 32768 + embedding_model: str = "nomic-embed-text-v2-moe-GGUF" # Default device configurations for built-in agents using Gemma 4 E4B. @@ -277,10 +282,28 @@ class DeviceConfig: backend="flm:npu", verified=True, ctx_size=4096, + # FLM-native embedder so chat + embeddings stay co-resident on the NPU + # backend and don't thrash NPU<->Vulkan every turn (#1744). + embedding_model="embed-gemma-300m-FLM", ), ] +def get_embedding_model_for_device(device: Optional[str]) -> str: + """Return the embedder model id for a device target. + + Single source of truth: reads ``DEFAULT_DEVICE_CONFIGS`` so the embedder + choice lives next to the chat model/recipe/backend for each device. The NPU + profile uses the FLM-native embedder (see ``DeviceConfig.embedding_model``); + GPU/CPU and an unspecified device default to the GGUF nomic embedder, which + matches the GPU-default policy elsewhere in the CLI. + """ + for dc in DEFAULT_DEVICE_CONFIGS: + if dc.device == device: + return dc.embedding_model + return DeviceConfig.__dataclass_fields__["embedding_model"].default + + @dataclass class ModelTier: """A selectable model size for an agent (#1162). diff --git a/src/gaia/installer/init_command.py b/src/gaia/installer/init_command.py index c9cafa18a..2f1c9b9e9 100644 --- a/src/gaia/installer/init_command.py +++ b/src/gaia/installer/init_command.py @@ -98,7 +98,11 @@ "npu": { "description": "Ryzen AI NPU acceleration via FLM backend (requires XDNA2 NPU)", "agent": "chat", - "models": ["gemma4-it-e2b-FLM"], + # FLM chat model + FLM-native embedder so chat and embeddings stay + # co-resident on the NPU backend. A GGUF embedder would run on Vulkan + # and evict the FLM chat model every turn (#1744). Both are built-in + # Lemonade *-FLM models, pulled by name only (no recipe — #1655). + "models": ["gemma4-it-e2b-FLM", "embed-gemma-300m-FLM"], "approx_size": "~3 GB", "min_lemonade_version": "10.2.0", # FLM default context on NPU. Smaller than GPU (32768) because NPU diff --git a/src/gaia/llm/lemonade_client.py b/src/gaia/llm/lemonade_client.py index 434fb0e78..9b7a83a67 100644 --- a/src/gaia/llm/lemonade_client.py +++ b/src/gaia/llm/lemonade_client.py @@ -280,6 +280,21 @@ class LemonadeStatus: min_ctx_size=2048, tool_calling=False, ), + # --- NPU-native FLM embedder for the NPU profile (#1744) --- + # EmbeddingGemma 300M built for the FastFlowLM/NPU backend. On a shared- + # memory Ryzen AI APU the GGUF nomic embedder runs on Vulkan/llama.cpp and + # reclaims the memory the FLM chat model holds, so loading it evicts the + # chat model — every chat turn then thrashes NPU<->Vulkan (#1676). Keeping + # the embedder on the same FLM/NPU backend as the chat model lets both stay + # co-resident. Built-in Lemonade *-FLM model: pull by name only (no recipe; + # passing recipe triggers user-model registration and 400s — #1655). + "embed-gemma-flm": ModelRequirement( + model_type=ModelType.EMBEDDING, + model_id="embed-gemma-300m-FLM", + display_name="EmbeddingGemma 300M (NPU/FLM)", + min_ctx_size=2048, + tool_calling=False, + ), } # Define agent profiles with their model requirements diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index 45a7fbbcb..b0b2c7729 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -420,9 +420,10 @@ def test_force_models_deletes_before_download(self, mock_installer_class): def test_npu_profile_pulls_builtin_model_without_recipe(self, mock_installer_class): """NPU/FLM models are built-in; pulling with a recipe 400s (#1655). - The npu profile must download ``gemma4-it-e2b-FLM`` via - ensure_model_downloaded (pull by name), never pull_model(recipe=...), - which Lemonade rejects unless the name carries a ``user.`` prefix. + The npu profile must download both the FLM chat model and the FLM-native + embedder (#1744) via ensure_model_downloaded (pull by name), never + pull_model(recipe=...), which Lemonade rejects unless the name carries a + ``user.`` prefix. """ from gaia.installer.init_command import InitCommand @@ -435,9 +436,10 @@ def test_npu_profile_pulls_builtin_model_without_recipe(self, mock_installer_cla result = cmd._download_models() self.assertTrue(result) - mock_client.ensure_model_downloaded.assert_called_once_with( - "gemma4-it-e2b-FLM" - ) + pulled = { + c.args[0] for c in mock_client.ensure_model_downloaded.call_args_list + } + self.assertEqual(pulled, {"gemma4-it-e2b-FLM", "embed-gemma-300m-FLM"}) # Regression guard: no recipe-bearing pull_model call. mock_client.pull_model.assert_not_called() diff --git a/tests/unit/test_npu_flm_embedder.py b/tests/unit/test_npu_flm_embedder.py new file mode 100644 index 000000000..e3b781a95 --- /dev/null +++ b/tests/unit/test_npu_flm_embedder.py @@ -0,0 +1,201 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Unit tests for the NPU-native FLM embedder wiring (#1744). + +Covers: +- The device->embedder resolver and DeviceConfig.embedding_model field. +- The embed-gemma-300m-FLM entry in the Lemonade MODELS registry. +- The NPU init profile downloading the FLM embedder. +- MemoryMixin deriving the embedding dimension from the live embedder + (no hardcoded 768) and invalidating stored vectors when the embedder + model changes (same dim, different vector space). + +All tests mock the embedder; no Lemonade server or NPU required. +""" + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from gaia.agents.base.memory_store import MemoryStore + +# --------------------------------------------------------------------------- +# Device -> embedder resolution +# --------------------------------------------------------------------------- + + +class TestDeviceEmbedderResolution: + def test_npu_uses_flm_embedder(self): + from gaia.agents.registry import get_embedding_model_for_device + + assert get_embedding_model_for_device("npu") == "embed-gemma-300m-FLM" + + @pytest.mark.parametrize("device", ["gpu", "cpu", None, "unknown-device"]) + def test_non_npu_uses_nomic(self, device): + from gaia.agents.registry import get_embedding_model_for_device + + assert get_embedding_model_for_device(device) == "nomic-embed-text-v2-moe-GGUF" + + def test_device_config_carries_embedder(self): + """The embedder choice lives next to the chat model in DeviceConfig.""" + from gaia.agents.registry import DEFAULT_DEVICE_CONFIGS + + by_device = {dc.device: dc for dc in DEFAULT_DEVICE_CONFIGS} + assert by_device["npu"].embedding_model == "embed-gemma-300m-FLM" + assert by_device["gpu"].embedding_model == "nomic-embed-text-v2-moe-GGUF" + assert by_device["cpu"].embedding_model == "nomic-embed-text-v2-moe-GGUF" + + +# --------------------------------------------------------------------------- +# Lemonade model registry + download manifest +# --------------------------------------------------------------------------- + + +class TestModelRegistry: + def test_flm_embedder_registered(self): + from gaia.llm.lemonade_client import MODELS, ModelType + + mr = MODELS["embed-gemma-flm"] + assert mr.model_id == "embed-gemma-300m-FLM" + assert mr.model_type == ModelType.EMBEDDING + # FLM/NPU server 500s on a tools payload, like the e2b chat model. + assert mr.tool_calling is False + + def test_npu_init_profile_downloads_flm_embedder(self): + from gaia.installer.init_command import INIT_PROFILES + + models = INIT_PROFILES["npu"]["models"] + assert "embed-gemma-300m-FLM" in models + # Chat model must remain so both pull on `gaia init --profile npu`. + assert "gemma4-it-e2b-FLM" in models + + +# --------------------------------------------------------------------------- +# MemoryMixin: dynamic dim + embedder-change invalidation +# --------------------------------------------------------------------------- + + +class _FakeAgent: + """Minimal host so MemoryMixin can be exercised without a real Agent.""" + + def __init__(self): + self._system_prompt_cache = None + + def register_tool(self, *a, **k): + pass + + +def _make_host(): + from gaia.agents.base.memory import MemoryMixin + + class _Host(MemoryMixin, _FakeAgent): + pass + + return _Host() + + +def _init_with_embedder(host, db_path, *, model, dim): + """Run init_memory with a mock embedder of a given model id and dim.""" + from gaia.agents.base.memory import MemoryMixin + + vec = np.random.rand(dim).astype(np.float32) + with ( + patch.object(MemoryMixin, "_get_embedder", return_value=MagicMock()), + patch.object(MemoryMixin, "_embed_text", return_value=vec), + patch.object(MemoryMixin, "_backfill_embeddings", return_value=0), + patch.object(MemoryMixin, "init_system_context", return_value=None), + ): + host.init_memory(db_path=db_path, embedding_model=model) + + +class TestDynamicDim: + def test_dim_derived_from_live_embedder(self, tmp_path, monkeypatch): + """A 512-dim embedder yields a 512-dim FAISS index — no hardcoded 768.""" + monkeypatch.delenv("GAIA_MEMORY_DISABLED", raising=False) + host = _make_host() + _init_with_embedder( + host, tmp_path / "memory.db", model="embed-gemma-300m-FLM", dim=512 + ) + assert host._embedding_dim == 512 + assert host._embedding_model == "embed-gemma-300m-FLM" + # Index was built at the derived dim, not the module default. + assert host._faiss_index is not None + assert host._faiss_index.d == 512 + + def test_default_embedder_when_unspecified(self, tmp_path, monkeypatch): + monkeypatch.delenv("GAIA_MEMORY_DISABLED", raising=False) + host = _make_host() + _init_with_embedder(host, tmp_path / "memory.db", model=None, dim=768) + assert host._embedding_model == "nomic-embed-text-v2-moe-GGUF" + assert host._embedding_dim == 768 + + +class TestEmbedderChangeInvalidation: + def test_switching_embedder_clears_stored_vectors(self, tmp_path, monkeypatch): + """Same dim, different model => stored vectors must be invalidated.""" + monkeypatch.delenv("GAIA_MEMORY_DISABLED", raising=False) + db_path = tmp_path / "memory.db" + + # Seed a store as if a prior nomic session wrote a 768-dim vector. + store = MemoryStore(db_path=db_path) + kid = store.store(category="fact", content="the sky is blue") + store.store_embedding(kid, np.zeros(768, dtype=np.float32).tobytes()) + store.set_embedder_id("nomic-embed-text-v2-moe-GGUF") + assert store.get_embedding_coverage()["without_embedding"] == 0 + store.close() + + # New session on the FLM embedder (also 768-dim, different space). + host = _make_host() + _init_with_embedder(host, db_path, model="embed-gemma-300m-FLM", dim=768) + + # Marker updated and the stale vector was cleared for re-embedding. + assert host._memory_store.get_embedder_id() == "embed-gemma-300m-FLM" + assert host._memory_store.get_embedding_coverage()["without_embedding"] == 1 + + def test_same_embedder_keeps_vectors(self, tmp_path, monkeypatch): + monkeypatch.delenv("GAIA_MEMORY_DISABLED", raising=False) + db_path = tmp_path / "memory.db" + + store = MemoryStore(db_path=db_path) + kid = store.store(category="fact", content="water is wet") + store.store_embedding(kid, np.zeros(768, dtype=np.float32).tobytes()) + store.set_embedder_id("nomic-embed-text-v2-moe-GGUF") + store.close() + + host = _make_host() + with patch( + "gaia.agents.base.memory_store.MemoryStore.clear_all_embeddings" + ) as clear: + _init_with_embedder( + host, db_path, model="nomic-embed-text-v2-moe-GGUF", dim=768 + ) + clear.assert_not_called() + + +# --------------------------------------------------------------------------- +# MemoryStore embedder marker + clear +# --------------------------------------------------------------------------- + + +class TestMemoryStoreEmbedderMarker: + def test_marker_round_trip(self, tmp_path): + store = MemoryStore(db_path=tmp_path / "memory.db") + try: + assert store.get_embedder_id() is None # fresh DB + store.set_embedder_id("embed-gemma-300m-FLM") + assert store.get_embedder_id() == "embed-gemma-300m-FLM" + finally: + store.close() + + def test_clear_all_embeddings(self, tmp_path): + store = MemoryStore(db_path=tmp_path / "memory.db") + try: + kid = store.store(category="fact", content="hello") + store.store_embedding(kid, np.zeros(768, dtype=np.float32).tobytes()) + assert store.get_embedding_coverage()["without_embedding"] == 0 + cleared = store.clear_all_embeddings() + assert cleared == 1 + assert store.get_embedding_coverage()["without_embedding"] == 1 + finally: + store.close() From 8b09823ef38d1696cb131c03237e13357ad45cb8 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Fri, 19 Jun 2026 12:36:24 -0700 Subject: [PATCH 2/5] fix(npu): make UI memory router embedder-aware; move embedder id into the DB (#1744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review found the NPU profile's primary surface (gaia chat --ui) still hardcoded the nomic embedder/dim in the memory router, which re-triggered the #1676 thrash and reintroduced the cross-space corruption the marker was added to prevent. - routers/memory.py: rebuild-embeddings re-embeds with the stamped embedder (store.get_embedder_id() or nomic default), not a hardcoded model; reconcile derives the FAISS dim from the stored vectors instead of a hardcoded 768. - memory_store.py: store the embedder id in a SQLite `meta` table instead of a sidecar file — atomic with the data and visible to every connection (agent + UI router run different code paths against the same DB). set_embedder_id now fails loudly on write error instead of swallowing it. - tests: cover the router using the stamped embedder, reconcile dim derivation, the zero-length-vector guard, and the full clear -> backfill round trip. --- src/gaia/agents/base/memory_store.py | 47 +++++++++++++++--------- src/gaia/ui/routers/memory.py | 28 ++++++++++----- tests/unit/test_memory_router.py | 53 ++++++++++++++++++++++++++++ tests/unit/test_npu_flm_embedder.py | 46 ++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 25 deletions(-) diff --git a/src/gaia/agents/base/memory_store.py b/src/gaia/agents/base/memory_store.py index 73c3c4b0e..5efc522b5 100644 --- a/src/gaia/agents/base/memory_store.py +++ b/src/gaia/agents/base/memory_store.py @@ -237,6 +237,15 @@ def _safe_json_loads(value) -> object: -- Knowledge FTS5 (standalone, manually synced, porter stemmer for morphological matching) CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(content, domain, category, tokenize='porter unicode61'); +-- Key/value metadata (e.g. the embedder id that produced stored vectors). +-- Lives in the DB (not a sidecar file) so it is atomic with the data it +-- describes and visible to every connection — the agent and the UI memory +-- router open the same DB from different code paths (#1744). +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + -- Tool history CREATE TABLE IF NOT EXISTS tool_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1410,31 +1419,35 @@ def clear_all_embeddings(self) -> int: self._conn.rollback() raise - def _embedder_marker_path(self) -> Path: - """Sidecar path that records which embedder produced the stored vectors.""" - return self._db_path.with_name(self._db_path.name + ".embedder") + #: ``meta`` key recording which embedder produced the stored vectors. + _EMBEDDER_META_KEY = "embedder_id" def get_embedder_id(self) -> Optional[str]: """Return the embedder model id that produced the stored embeddings. - ``None`` when no embeddings have been written yet (fresh DB) or the - marker is missing — callers treat that as "no change to detect". + ``None`` when nothing has been stamped yet (fresh DB) — callers treat + that as "no change to detect". Read from the ``meta`` table so every + connection (agent + UI router) sees the same value. """ - marker = self._embedder_marker_path() - try: - if marker.exists(): - return marker.read_text(encoding="utf-8").strip() or None - except OSError as e: - logger.warning("[MemoryStore] could not read embedder marker: %s", e) - return None + with self._lock: + row = self._conn.execute( + "SELECT value FROM meta WHERE key = ?", (self._EMBEDDER_META_KEY,) + ).fetchone() + return row[0] if row else None def set_embedder_id(self, model_id: str) -> None: """Record the embedder model id that produced the stored embeddings.""" - marker = self._embedder_marker_path() - try: - marker.write_text(model_id, encoding="utf-8") - except OSError as e: - logger.warning("[MemoryStore] could not write embedder marker: %s", e) + with self._lock: + try: + self._conn.execute( + "INSERT INTO meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (self._EMBEDDER_META_KEY, model_id), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise def get_items_with_embeddings( self, diff --git a/src/gaia/ui/routers/memory.py b/src/gaia/ui/routers/memory.py index a35fc1517..203f0e1d2 100644 --- a/src/gaia/ui/routers/memory.py +++ b/src/gaia/ui/routers/memory.py @@ -691,17 +691,23 @@ def rebuild_embeddings() -> Dict: from gaia.agents.base.memory import EMBEDDING_MODEL from gaia.llm.providers.lemonade import LemonadeProvider - provider = LemonadeProvider(model=EMBEDDING_MODEL) + store = _get_store() + # Re-embed with the embedder that produced the stored vectors, not a + # hardcoded default. On the NPU profile that is the FLM-native embedder; + # using nomic here would mix vector spaces in one table and reload a + # Vulkan GGUF embedder that evicts the FLM chat model (#1744). + embedder_model = store.get_embedder_id() or EMBEDDING_MODEL + provider = LemonadeProvider(model=embedder_model) def _embed_fn(text: str) -> bytes: - results = provider.embed([text], model=EMBEDDING_MODEL) + results = provider.embed([text], model=embedder_model) vec = np.array(results[0], dtype=np.float32) norm = np.linalg.norm(vec) if norm > 0: vec = vec / norm return vec.astype(np.float32).tobytes() - result = _get_store().backfill_embeddings(_embed_fn) + result = store.backfill_embeddings(_embed_fn) return result except Exception as exc: logger.error("[memory router] rebuild-embeddings failed: %s", exc) @@ -757,7 +763,7 @@ def trigger_reconciliation(max_pairs: int = Query(20, ge=1, le=100)) -> Dict: except ImportError: raise HTTPException(503, "faiss not installed — run: pip install faiss-cpu") - from gaia.agents.base.memory import EMBEDDING_DIM, _blob_to_embedding + from gaia.agents.base.memory import _blob_to_embedding from gaia.llm import create_client store = _get_store() @@ -766,15 +772,21 @@ def trigger_reconciliation(max_pairs: int = Query(20, ge=1, le=100)) -> Dict: if len(items) < 2: return result # Not enough items with embeddings - # Build id list and vector matrix + # Dimension is derived from the stored vectors, not a hardcoded constant + # — the active embedder (e.g. the NPU FLM embedder) may differ from the + # nomic default (#1744). The first valid vector sets the expected dim; + # any vector of a different dim (a stale pre-switch embedding) is skipped. ids: List[str] = [] vectors: List[np.ndarray] = [] item_map: Dict[str, Any] = {} + expected_dim: Optional[int] = None for item in items: try: vec = _blob_to_embedding(item["embedding"]) - if vec.shape[0] != EMBEDDING_DIM: + if expected_dim is None: + expected_dim = int(vec.shape[0]) + if vec.shape[0] != expected_dim: continue norm = np.linalg.norm(vec) if norm > 0: @@ -785,11 +797,11 @@ def trigger_reconciliation(max_pairs: int = Query(20, ge=1, le=100)) -> Dict: except Exception: continue - if len(vectors) < 2: + if len(vectors) < 2 or expected_dim is None: return result mat = np.stack(vectors).astype(np.float32) - index = faiss.IndexFlatIP(EMBEDDING_DIM) + index = faiss.IndexFlatIP(expected_dim) index.add(mat) n_neighbors = min(5, len(vectors)) diff --git a/tests/unit/test_memory_router.py b/tests/unit/test_memory_router.py index 2e28d9945..19ea1b081 100644 --- a/tests/unit/test_memory_router.py +++ b/tests/unit/test_memory_router.py @@ -820,6 +820,35 @@ def boom(*args, **kwargs): resp = client.post("/api/memory/rebuild-embeddings") assert resp.status_code == 500 + def test_rebuild_uses_stamped_embedder_not_nomic(self, client, test_store): + """Re-embed with the stamped embedder, not the nomic default (#1744). + + On the NPU profile the agent stamps the FLM embedder. The dashboard + rebuild button must reuse it — embedding with nomic would mix vector + spaces in one table and reload a Vulkan embedder that evicts the FLM + chat model. + """ + test_store.set_embedder_id("embed-gemma-300m-FLM") + mock_provider = MagicMock() + mock_provider.embed.return_value = [[0.1] * 768] + with patch( + "gaia.llm.providers.lemonade.LemonadeProvider", return_value=mock_provider + ) as ctor: + resp = client.post("/api/memory/rebuild-embeddings") + assert resp.status_code == 200 + ctor.assert_called_once_with(model="embed-gemma-300m-FLM") + + def test_rebuild_falls_back_to_nomic_when_unstamped(self, client, test_store): + """A never-stamped DB (no agent run yet) defaults to the nomic embedder.""" + mock_provider = MagicMock() + mock_provider.embed.return_value = [[0.1] * 768] + with patch( + "gaia.llm.providers.lemonade.LemonadeProvider", return_value=mock_provider + ) as ctor: + resp = client.post("/api/memory/rebuild-embeddings") + assert resp.status_code == 200 + ctor.assert_called_once_with(model="nomic-embed-text-v2-moe-GGUF") + # =========================================================================== # v2 Router Tests — Reconcile Endpoint @@ -842,6 +871,30 @@ def test_reconcile_returns_503_when_no_agent_and_faiss_missing(self, client): assert resp.status_code == 503 assert "faiss" in resp.json()["detail"].lower() + def test_reconcile_standalone_derives_dim_from_vectors(self, client, test_store): + """Standalone reconcile builds the FAISS index at the stored vectors' + dim, not a hardcoded 768 — so a non-768 embedder (e.g. a truncated FLM + embedder) is not silently skipped (#1744). + """ + import numpy as np + + # Two identical 512-dim vectors → cosine 1.0 → above the pair threshold. + vec = np.ones(512, dtype=np.float32).tobytes() + for content in ("alpha fact one", "alpha fact two"): + kid = _create_knowledge(client, content)["knowledge_id"] + test_store.store_embedding(kid, vec) + test_store.set_embedder_id("embed-gemma-300m-FLM") + + fake_llm = MagicMock() + fake_llm.chat.return_value = '{"relationship": "neutral", "action": "noop"}' + with patch("gaia.llm.create_client", return_value=fake_llm): + resp = client.post("/api/memory/reconcile") + + assert resp.status_code == 200 + # A pair was found and classified — proves the 512-dim index built and + # nothing was skipped by a dim mismatch. + assert resp.json()["pairs_checked"] >= 1 + def test_reconcile_returns_200_when_agent_registered(self, client): """POST /api/memory/reconcile returns 200 when _reconcile_fn is set.""" memory_router_mod._reconcile_fn = lambda: { diff --git a/tests/unit/test_npu_flm_embedder.py b/tests/unit/test_npu_flm_embedder.py index e3b781a95..7b78cef6f 100644 --- a/tests/unit/test_npu_flm_embedder.py +++ b/tests/unit/test_npu_flm_embedder.py @@ -130,6 +130,25 @@ def test_default_embedder_when_unspecified(self, tmp_path, monkeypatch): assert host._embedding_model == "nomic-embed-text-v2-moe-GGUF" assert host._embedding_dim == 768 + def test_zero_length_vector_degrades_memory(self, tmp_path, monkeypatch): + """A 0-length embedding trips the guard and degrades memory loudly, + rather than building a malformed index.""" + monkeypatch.delenv("GAIA_MEMORY_DISABLED", raising=False) + from gaia.agents.base.memory import MemoryMixin + + host = _make_host() + empty = np.empty(0, dtype=np.float32) + with ( + patch.object(MemoryMixin, "_get_embedder", return_value=MagicMock()), + patch.object(MemoryMixin, "_embed_text", return_value=empty), + patch.object(MemoryMixin, "init_system_context", return_value=None), + ): + host.init_memory( + db_path=tmp_path / "memory.db", embedding_model="broken-embedder" + ) + # The RuntimeError is caught and memory is disabled for the session. + assert host._memory_store is None + class TestEmbedderChangeInvalidation: def test_switching_embedder_clears_stored_vectors(self, tmp_path, monkeypatch): @@ -153,6 +172,33 @@ def test_switching_embedder_clears_stored_vectors(self, tmp_path, monkeypatch): assert host._memory_store.get_embedder_id() == "embed-gemma-300m-FLM" assert host._memory_store.get_embedding_coverage()["without_embedding"] == 1 + def test_switch_then_backfill_repopulates(self, tmp_path, monkeypatch): + """After a switch the cleared vectors are re-embedded with the new model + (full round trip: clear -> backfill -> coverage restored).""" + monkeypatch.delenv("GAIA_MEMORY_DISABLED", raising=False) + from gaia.agents.base.memory import MemoryMixin + + db_path = tmp_path / "memory.db" + store = MemoryStore(db_path=db_path) + kid = store.store(category="fact", content="the sky is blue") + store.store_embedding(kid, np.zeros(768, dtype=np.float32).tobytes()) + store.set_embedder_id("nomic-embed-text-v2-moe-GGUF") + store.close() + + # Real backfill this time (only _embed_text is mocked). + host = _make_host() + vec = np.random.rand(768).astype(np.float32) + with ( + patch.object(MemoryMixin, "_get_embedder", return_value=MagicMock()), + patch.object(MemoryMixin, "_embed_text", return_value=vec), + patch.object(MemoryMixin, "init_system_context", return_value=None), + ): + host.init_memory(db_path=db_path, embedding_model="embed-gemma-300m-FLM") + + cov = host._memory_store.get_embedding_coverage() + assert cov["without_embedding"] == 0 # re-embedded with the new model + assert host._memory_store.get_embedder_id() == "embed-gemma-300m-FLM" + def test_same_embedder_keeps_vectors(self, tmp_path, monkeypatch): monkeypatch.delenv("GAIA_MEMORY_DISABLED", raising=False) db_path = tmp_path / "memory.db" From ab1e2070745c4f0a271f39cee056f537b08f4ed3 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Fri, 19 Jun 2026 13:44:06 -0700 Subject: [PATCH 3/5] fix(npu): satisfy pylint on the embedder resolver; make new tests faiss-optional CI green-up for #1744: - registry: replace the `DeviceConfig.__dataclass_fields__[...].default` lookup (pylint E1101) with a shared `DEFAULT_EMBEDDING_MODEL` module constant. - tests: the CI unit-test env has no faiss-cpu, so guard the FAISS-index dim assertion and the standalone-reconcile test with pytest.importorskip("faiss"). The dynamic-dim derivation itself is asserted without faiss. --- src/gaia/agents/registry.py | 9 +++++++-- tests/unit/test_memory_router.py | 1 + tests/unit/test_npu_flm_embedder.py | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/gaia/agents/registry.py b/src/gaia/agents/registry.py index f1acef96c..78805de8b 100644 --- a/src/gaia/agents/registry.py +++ b/src/gaia/agents/registry.py @@ -223,6 +223,11 @@ def _compute_custom_origin_hash(py_file: Path) -> str: return hashlib.sha256(py_file.read_bytes()).hexdigest()[:16] +# Default embedder (GPU/CPU). The NPU device overrides this with the FLM-native +# embedder so chat + embeddings stay co-resident on the NPU backend (#1744). +DEFAULT_EMBEDDING_MODEL = "nomic-embed-text-v2-moe-GGUF" + + @dataclass class DeviceConfig: """A verified (device, model, recipe, backend) configuration for an agent. @@ -253,7 +258,7 @@ class DeviceConfig: backend: str verified: bool = False ctx_size: int = 32768 - embedding_model: str = "nomic-embed-text-v2-moe-GGUF" + embedding_model: str = DEFAULT_EMBEDDING_MODEL # Default device configurations for built-in agents using Gemma 4 E4B. @@ -301,7 +306,7 @@ def get_embedding_model_for_device(device: Optional[str]) -> str: for dc in DEFAULT_DEVICE_CONFIGS: if dc.device == device: return dc.embedding_model - return DeviceConfig.__dataclass_fields__["embedding_model"].default + return DEFAULT_EMBEDDING_MODEL @dataclass diff --git a/tests/unit/test_memory_router.py b/tests/unit/test_memory_router.py index 19ea1b081..005224936 100644 --- a/tests/unit/test_memory_router.py +++ b/tests/unit/test_memory_router.py @@ -876,6 +876,7 @@ def test_reconcile_standalone_derives_dim_from_vectors(self, client, test_store) dim, not a hardcoded 768 — so a non-768 embedder (e.g. a truncated FLM embedder) is not silently skipped (#1744). """ + pytest.importorskip("faiss") # standalone reconcile path needs faiss import numpy as np # Two identical 512-dim vectors → cosine 1.0 → above the pair threshold. diff --git a/tests/unit/test_npu_flm_embedder.py b/tests/unit/test_npu_flm_embedder.py index 7b78cef6f..f55f11597 100644 --- a/tests/unit/test_npu_flm_embedder.py +++ b/tests/unit/test_npu_flm_embedder.py @@ -119,7 +119,9 @@ def test_dim_derived_from_live_embedder(self, tmp_path, monkeypatch): ) assert host._embedding_dim == 512 assert host._embedding_model == "embed-gemma-300m-FLM" - # Index was built at the derived dim, not the module default. + # When faiss is available, the index is built at the derived dim, not + # the module default. faiss is optional, so skip this leg without it. + pytest.importorskip("faiss") assert host._faiss_index is not None assert host._faiss_index.d == 512 From 23eece629b2e977baf9f65076f7672e295904621 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Tue, 7 Jul 2026 15:37:07 -0700 Subject: [PATCH 4/5] test(npu): hardware validation for the FLM embedder on NPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite only had mocked unit tests plus a manual hardware checklist, so nothing exercised embed-gemma-300m-FLM on a real NPU. Add an integration test that runs the FLM embedder on a live FLM/NPU-enabled Lemonade Server and proves the #1744 guarantee end to end: valid non-zero vectors at the expected dim, deterministic + distinct encodings, and — the core of the change — the FLM chat model and FLM embedder staying co-resident with no NPU<->Vulkan eviction. The FLM model exists only on a Lemonade Server running the FLM backend on Ryzen AI hardware, so every test gates on the live catalog and skips cleanly on CPU/GPU/Vulkan boxes and in non-NPU CI (verified: 5 skipped, no errors, against a Vulkan server). A strix-halo workflow runs it on real NPU hardware. --- .github/workflows/test_npu_embedder.yml | 88 +++++++++++++ tests/test_npu_flm_embedder_hw.py | 164 ++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 .github/workflows/test_npu_embedder.yml create mode 100644 tests/test_npu_flm_embedder_hw.py diff --git a/.github/workflows/test_npu_embedder.yml b/.github/workflows/test_npu_embedder.yml new file mode 100644 index 000000000..9e5a4707f --- /dev/null +++ b/.github/workflows/test_npu_embedder.yml @@ -0,0 +1,88 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +# Validates the NPU-native FLM embedder (embed-gemma-300m-FLM, #1744) on real +# Ryzen AI hardware. The test (tests/test_npu_flm_embedder_hw.py) SKIPS unless +# the live Lemonade Server advertises the FLM model, so on a runner without the +# FLM/NPU backend this job is a clean no-op rather than a false failure. +name: Test NPU FLM Embedder + +on: + workflow_dispatch: + push: + branches: [ main ] + paths: + - 'src/gaia/agents/registry.py' + - 'src/gaia/agents/base/memory.py' + - 'src/gaia/agents/base/memory_store.py' + - 'src/gaia/llm/lemonade_client.py' + - 'tests/test_npu_flm_embedder_hw.py' + - '.github/workflows/test_npu_embedder.yml' + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'src/gaia/agents/registry.py' + - 'src/gaia/agents/base/memory.py' + - 'src/gaia/agents/base/memory_store.py' + - 'src/gaia/llm/lemonade_client.py' + - 'tests/test_npu_flm_embedder_hw.py' + - '.github/workflows/test_npu_embedder.yml' + workflow_call: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test-npu-embedder: + name: Test NPU FLM Embedder + runs-on: [self-hosted, strix-halo] + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci') + + steps: + - uses: actions/checkout@v6 + + - name: Setup Python environment + uses: ./.github/actions/setup-venv + with: + python-version: '3.12' + install-package: '.[dev,rag]' + extra-index-url: 'https://download.pytorch.org/whl/cpu' + + - name: Install Lemonade Server + uses: ./.github/actions/install-lemonade + + - name: Start Lemonade + validate FLM embedder on NPU + shell: powershell + env: + NO_PROXY: "localhost,127.0.0.1" + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $env:PYTHONIOENCODING = "utf-8" + $env:PYTHONUTF8 = "1" + chcp 65001 | Out-Null + + # Free the port, then start Lemonade in a single session so the FLM + # backend child survives for the test run. + .\installer\scripts\start-lemonade.ps1 ` + -ModelName "gemma4-it-e2b-FLM" ` + -Port 13305 ` + -CtxSize 32768 ` + -InitWaitTime 15 + + try { + # The test loads embed-gemma-300m-FLM itself and skips cleanly if + # the runner's Lemonade has no FLM/NPU backend. -rs prints skip + # reasons so a silent skip is visible in the log. + python -m pytest tests/test_npu_flm_embedder_hw.py -v -rs --tb=short + if ($LASTEXITCODE -ne 0) { throw "NPU FLM embedder tests failed" } + } + finally { + if ($env:LEMONADE_PROCESS_ID) { + Stop-Process -Id $env:LEMONADE_PROCESS_ID -Force -ErrorAction SilentlyContinue + } + } diff --git a/tests/test_npu_flm_embedder_hw.py b/tests/test_npu_flm_embedder_hw.py new file mode 100644 index 000000000..48697716c --- /dev/null +++ b/tests/test_npu_flm_embedder_hw.py @@ -0,0 +1,164 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Hardware validation for the NPU-native FLM embedder (#1744). + +Unlike ``tests/unit/test_npu_flm_embedder.py`` (fully mocked), these tests run +``embed-gemma-300m-FLM`` on a **real** FLM/NPU-enabled Lemonade Server and prove +the feature's actual purpose: the FLM embedder produces valid vectors on the NPU +and stays co-resident with the FLM chat model (no NPU<->Vulkan eviction). + +Gating: ``embed-gemma-300m-FLM`` is an FLM-recipe model that only exists on a +Lemonade Server running the FLM backend on Ryzen AI NPU hardware -- it is not in +the mainline llamacpp/Vulkan catalog. Every test here therefore SKIPS unless the +live server actually advertises the model, so the suite is safe on CPU/GPU boxes +and in Vulkan CI while still executing for real on an NPU runner. + +Run on NPU hardware with: + python -m pytest tests/test_npu_flm_embedder_hw.py -v -rs +""" + +import math + +import pytest + +from gaia.agents.registry import get_embedding_model_for_device +from gaia.llm.lemonade_client import LemonadeClient, LemonadeClientError + +pytestmark = [pytest.mark.integration, pytest.mark.real_model] + +# The NPU profile's embedder id, resolved from the same source the agent uses so +# a rename in the registry can't silently desync this test from production. +FLM_EMBEDDER = get_embedding_model_for_device("npu") # "embed-gemma-300m-FLM" +FLM_CHAT_MODEL = "gemma4-it-e2b-FLM" # NPU profile chat model (registry.py) + +# EmbeddingGemma 300M is 768-dim; the agent derives this at runtime rather than +# hardcoding it, so a mismatch here means the wrong model/vector space loaded. +EXPECTED_DIM = 768 + + +def _catalog_ids(client: LemonadeClient) -> set: + """Model ids the live server advertises (empty set if unreachable).""" + try: + catalog = client.list_models(show_all=True) + except LemonadeClientError: + return set() + data = catalog.get("data", catalog) if isinstance(catalog, dict) else catalog + ids = set() + for entry in data or []: + if isinstance(entry, dict) and entry.get("id"): + ids.add(entry["id"]) + return ids + + +@pytest.fixture(scope="module") +def client(): + return LemonadeClient() + + +@pytest.fixture +def npu_embedder(client, require_lemonade): + """Skip unless the FLM embedder is available on the live server, then load it. + + ``require_lemonade`` skips when no server is up; this then skips when the + server is up but not FLM/NPU-enabled (the Vulkan-catalog case). + """ + if FLM_EMBEDDER not in _catalog_ids(client): + pytest.skip( + f"{FLM_EMBEDDER} not in the live Lemonade catalog -- requires an " + "FLM/NPU-enabled server on Ryzen AI hardware" + ) + # Built-in FLM model: pull/load by name, no recipe (#1655-safe). + client.load_model(FLM_EMBEDDER, auto_download=True, prompt=False) + return FLM_EMBEDDER + + +class TestFlmEmbedderOnNpu: + def test_single_embedding_is_valid(self, client, npu_embedder): + """One text -> one finite, non-zero, correctly-sized vector.""" + resp = client.embeddings( + "resilient local AI on the NPU", model=npu_embedder, timeout=120 + ) + + assert isinstance(resp, dict) and "data" in resp + assert len(resp["data"]) == 1 + vec = resp["data"][0]["embedding"] + assert len(vec) == EXPECTED_DIM, f"expected {EXPECTED_DIM}-dim, got {len(vec)}" + assert all(isinstance(x, float) and math.isfinite(x) for x in vec) + assert any(x != 0.0 for x in vec), "embedding must not be all zeros" + + def test_batch_embedding_shapes(self, client, npu_embedder): + """A batch returns one same-dim vector per input, in order.""" + texts = [ + "The NPU runs the FLM embedder.", + "Vulkan runs the GGUF embedder.", + "Chat and embeddings stay co-resident.", + ] + resp = client.embeddings(texts, model=npu_embedder, timeout=180) + + assert len(resp["data"]) == len(texts) + dims = {len(item["embedding"]) for item in resp["data"]} + assert dims == {EXPECTED_DIM}, f"inconsistent/unexpected dims: {dims}" + + def test_embedding_is_deterministic(self, client, npu_embedder): + """Same input -> identical vector (no sampling in embeddings).""" + text = "determinism check" + a = client.embeddings([text], model=npu_embedder, timeout=120)["data"][0][ + "embedding" + ] + b = client.embeddings([text], model=npu_embedder, timeout=120)["data"][0][ + "embedding" + ] + assert a == b + + def test_distinct_texts_differ(self, client, npu_embedder): + """Different inputs -> different vectors (model is actually encoding).""" + a = client.embeddings(["apples"], model=npu_embedder, timeout=120)["data"][0][ + "embedding" + ] + b = client.embeddings( + ["quantum chromodynamics"], model=npu_embedder, timeout=120 + )["data"][0]["embedding"] + assert a != b + + +class TestFlmCoresidency: + """The core #1744 guarantee: the FLM chat model and FLM embedder are both + resident at once, so a chat turn does not evict the embedder (and vice + versa) the way the Vulkan GGUF embedder did on a shared-memory APU.""" + + def test_chat_and_embedder_coresident(self, client, npu_embedder): + if FLM_CHAT_MODEL not in _catalog_ids(client): + pytest.skip( + f"{FLM_CHAT_MODEL} not in the live catalog -- cannot test co-residency" + ) + + client.load_model( + FLM_CHAT_MODEL, auto_download=True, prompt=False, ctx_size=32768 + ) + # Touch the embedder after the chat model loads; on the old Vulkan path + # this is exactly the call that evicted the FLM chat model. + client.load_model(npu_embedder, auto_download=True, prompt=False) + + health = client._send_request("GET", f"{client.base_url}/health", timeout=15) + loaded = health.get("all_models_loaded") or [] + loaded_ids = { + ( + (m.get("id") or m.get("model_name") or m.get("model")) + if isinstance(m, dict) + else m + ) + for m in loaded + } + + assert FLM_EMBEDDER in loaded_ids, ( + f"{FLM_EMBEDDER} not resident after loading the chat model " + f"(loaded={sorted(str(i) for i in loaded_ids)}) -- NPU<->Vulkan eviction regressed" + ) + assert FLM_CHAT_MODEL in loaded_ids, ( + f"{FLM_CHAT_MODEL} was evicted when the embedder loaded " + f"(loaded={sorted(str(i) for i in loaded_ids)})" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s", "-rs"]) From 23468172e09264b3d235e8f759d50bd1a89f8da2 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Tue, 7 Jul 2026 15:46:02 -0700 Subject: [PATCH 5/5] test(embedder): cover both EmbeddingGemma backends (GGUF/GPU + FLM/NPU) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the embedder hardware test to validate EmbeddingGemma 300M on both backends GAIA uses: the GGUF/llamacpp variant (user.embeddinggemma-300m-GGUF, the non-NPU default replacing nomic, #1952) and the FLM/NPU variant (embed-gemma-300m-FLM, #1744). The vector-level checks — 768-dim, finite, non-zero, deterministic, distinct-texts-differ — are shared and parametrized across both backends; the chat+embedder co-residency check stays FLM-specific. Each backend gates on the live Lemonade catalog and skips when absent, so the GGUF variant runs on GPU/Vulkan boxes and the stx CI runner while FLM runs on Ryzen AI NPU runners — neither produces a false failure where its model isn't present. Verified locally: GGUF variant passes against a real EmbeddingGemma GGUF model (4 passed), FLM variant skips cleanly with no server error. --- tests/{test_npu_flm_embedder_hw.py => test_gemma_embedder_hw.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_npu_flm_embedder_hw.py => test_gemma_embedder_hw.py} (100%) diff --git a/tests/test_npu_flm_embedder_hw.py b/tests/test_gemma_embedder_hw.py similarity index 100% rename from tests/test_npu_flm_embedder_hw.py rename to tests/test_gemma_embedder_hw.py