From a9226d120e46ac82c4076b37b38831d0ec548b84 Mon Sep 17 00:00:00 2001 From: Oranje AI <293843428+oranjeai@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:58:35 +1200 Subject: [PATCH 1/5] Seed the memory categories memU actually advertises nerve forwards its own memory categories to memU only when config.memory.categories is non-empty; otherwise memU falls back to its 10 built-in defaults. Those defaults are formatted into the memorize prompt, so the LLM is told they exist. But _ensure_categories keyed its work on nerve's config, early-returning when it was empty, and nerve also sets categories_ready=True, which suppresses memU's own category initializer. On a default-config install nothing was ever created: 10 categories advertised, 0 rows, an empty name-to-id map, and every category name the LLM emits silently dropped by _map_category_names_to_ids. Category-scoped recall and every category summary stay permanently empty with no error anywhere. config.example.yaml ships a memory: block with no categories key and MemoryConfig.categories defaults to an empty list, so only wizard-created installs avoid this. Seed from self._service.category_configs, the effective set memU advertises, and load the persisted rows unconditionally so the name-to-id rebuild sees a warm cache even when nothing needs creating. Seeding writes through the category repository rather than _create_category_impl. That method appends a CategoryConfig for a name memU already advertises, so using it here doubles the advertised set. This is also live today on the configured path: a cold start with 3 configured categories left 6 advertised entries and listed each one twice in the memorize prompt. Runtime creation keeps using _create_category_impl, which is correct for a category memU does not yet advertise. Embeddings are computed in one batched call before any write, and a failure propagates. A row persisted with a null embedding is never repaired, because get_or_create_category returns an existing row untouched and a later boot skips the name, while both category rankers drop null vectors. Falling back to None would trade one silent failure for a permanent one. embedding=None remains correct when no provider is configured, where retrieval does not rank by vector. Availability moves to the end of _initialize_impl. It was published ~290 lines early, before category seeding, and engine.py discards initialize()'s return value, so _available is the only failure signal the running agent sees. A category created at runtime is resolvable again after a restart, but memU rebuilds the advertised set from config at construction, so the LLM is still not told it exists. That half needs the category_configs lifecycle and is not addressed here. --- nerve/memory/memu_bridge.py | 89 +++++--- tests/test_memu_bridge.py | 390 +++++++++++++++++++++++++++++++++++- 2 files changed, 455 insertions(+), 24 deletions(-) diff --git a/nerve/memory/memu_bridge.py b/nerve/memory/memu_bridge.py index 9642175b..b9c4bca6 100644 --- a/nerve/memory/memu_bridge.py +++ b/nerve/memory/memu_bridge.py @@ -1564,10 +1564,10 @@ async def _initialize_impl(self) -> bool: if not self.config.openai_api_key else {}), }, ) - self._available = True - self._metrics.service_available = True - self._metrics.initialized_at = datetime.now(timezone.utc).isoformat() - logger.info("memU service initialized with SQLite at %s", sqlite_dsn) + # Availability is published at the end of this method, not here: + # ~290 further lines of init follow (category seeding, interceptors, + # vector preload), and engine.py discards initialize()'s return value, + # so _available is the only failure signal the running agent sees. # Per-connection pragmas (busy_timeout, synchronous=NORMAL). self._attach_engine_pragmas() @@ -1791,7 +1791,8 @@ def _log_after_step(step_ctx, state): # recall doesn't pay the 2s JSON-parse cost. try: self._service.database.memory_item_repo.list_items() - self._service.database.memory_category_repo.list_categories() + # Categories are already cached: _ensure_categories loads them + # unconditionally, before the name-to-ID rebuild above. # Convert cached embeddings from list[float] to numpy float32. # Pydantic coerces numpy → list during model construction, so we @@ -1851,6 +1852,11 @@ def _numpy_create_item(self, *args, **kwargs): _numpy_create_item._nerve_numpy_wrapped = True # type: ignore[attr-defined] SQLiteMemoryItemRepo.create_item = _numpy_create_item + self._available = True + self._metrics.service_available = True + self._metrics.initialized_at = datetime.now(timezone.utc).isoformat() + logger.info("memU service initialized with SQLite at %s", sqlite_dsn) + return True except ImportError: @@ -1861,27 +1867,64 @@ def _numpy_create_item(self, *args, **kwargs): return False async def _ensure_categories(self) -> None: - """Create seed categories from config that don't already exist in the DB.""" - if not self._service or not self.config.memory.categories: + """Create the categories memU advertises that don't already exist in the DB. + + Seeds from ``self._service.category_configs``, the *effective* set memU + formats into the memorize prompt, not ``config.memory.categories``. When + Nerve configures none, memU falls back to its own defaults, so the two sets + differ and every advertised name would be left with no row to resolve to. + """ + if not self._service: return - existing: set[str] = set() - try: - cats = self._service.database.memory_category_repo.list_categories() - for _, cat in cats.items(): - existing.add(getattr(cat, "name", "")) - except Exception: - pass + repo = self._service.database.memory_category_repo + # Unconditional, and read failures propagate: this call also warms the + # repo cache that _initialize_impl's name-to-ID rebuild reads, and an + # ``existing`` left empty by a swallowed error would re-seed every name. + existing = {getattr(cat, "name", "") for cat in repo.list_categories().values()} - for cat_cfg in self.config.memory.categories: - if cat_cfg.name in existing: - continue - try: - # Already on the memU loop (called from _initialize_impl) — - # invoke the impl directly instead of re-submitting. - await self._create_category_impl(cat_cfg.name, cat_cfg.description) - except Exception as e: - logger.warning("Failed to seed category %s: %s", cat_cfg.name, e) + # Snapshot: never iterate the advertised list while seeding from it. + missing = [c for c in list(self._service.category_configs) if c.name not in existing] + await self._seed_categories(missing) + + async def _seed_categories(self, cat_cfgs: list[Any]) -> None: + """Persist startup seed categories WITHOUT touching the advertised set. + + memU built ``category_configs`` / ``category_config_map`` / + ``_category_prompt_str`` from these same entries before this runs, so + appending to them here (what ``_create_category_impl`` does for runtime + creation of a category memU does *not* yet advertise) would list every + category twice in the memorize prompt. + """ + if not self._service or not cat_cfgs: + return + + # Embed before any write. A row persisted with a null embedding is never + # repaired by a later boot (get_or_create_category returns an existing row + # untouched) and both category rankers skip null vectors, so an embed + # failure must propagate instead of falling back to None. One batched + # call, matching memU's own category initializer. + embeddings: list[Any] = [None] * len(cat_cfgs) + if self._has_embeddings: + texts = [ + f"{c.name}: {c.description}" if c.description else c.name + for c in cat_cfgs + ] + embeddings = list(await self._service._get_llm_client("embedding").embed(texts)) + + repo = self._service.database.memory_category_repo + for cat_cfg, embedding in zip(cat_cfgs, embeddings, strict=True): + repo.get_or_create_category( + name=cat_cfg.name, + description=cat_cfg.description, + embedding=embedding, + user_data={}, + ) + logger.info("Seeded category: %s", cat_cfg.name) + await self._audit( + "category_created", "category", cat_cfg.name, "bridge", + {"description": cat_cfg.description}, + ) # Maximum time (seconds) for a single memorize operation before cancellation. # Try to load malloc_trim for returning freed arenas to the OS. diff --git a/tests/test_memu_bridge.py b/tests/test_memu_bridge.py index 92037666..47d6c21e 100644 --- a/tests/test_memu_bridge.py +++ b/tests/test_memu_bridge.py @@ -3,6 +3,7 @@ import asyncio import json import sqlite3 +import sys from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +11,7 @@ import pytest import pytest_asyncio -from nerve.config import MemoryConfig, NerveConfig +from nerve.config import MemoryCategoryConfig, MemoryConfig, NerveConfig from nerve.memory.memu_bridge import ( MemoryBackendUnavailable, MemUBridge, @@ -1134,3 +1135,390 @@ async def test_transient_llm_error_still_raises_backend_unavailable(self, tmp_pa await bridge.memorize_file(str(target)) assert bridge._service.memorize.await_count == 1 + + +# --------------------------------------------------------------------------- +# Category seeding (_ensure_categories / _seed_categories) +# --------------------------------------------------------------------------- + +# memU's SQLModel table classes are process-global: a second set raises +# "Column object 'url' already assigned to Table 'memu_resources'". Build them +# once and inject them, so each test still gets a real store on a fresh file. +_SQLA_MODELS: dict[str, object] = {} + + +def _memu_store(db_path: Path): + """A genuine memU SQLite store, safe to build repeatedly in one process.""" + import memu.app.service # noqa: F401 - first, else the patcher hits a circular import + + MemUBridge._patch_sqlite_bugs() # required before the first store is built + from memu.database.sqlite.schema import get_sqlite_sqlalchemy_models + from memu.database.sqlite.sqlite import SQLiteStore + from pydantic import BaseModel + + class _Scope(BaseModel): + pass + + if "models" not in _SQLA_MODELS: + _SQLA_MODELS["scope"] = _Scope + _SQLA_MODELS["models"] = get_sqlite_sqlalchemy_models(scope_model=_Scope) + return SQLiteStore( + dsn=f"sqlite:///{db_path}", + scope_model=_SQLA_MODELS["scope"], + sqla_models=_SQLA_MODELS["models"], + ) + + +class _StubCategoryConfig: + """Stands in for memu.app.service.CategoryConfig (name + description).""" + + def __init__(self, name: str, description: str = ""): + self.name = name + self.description = description + + +class _StubService: + """Stand-in for MemoryService: the advertised set, a real store, a real context. + + Mirrors every attribute the seeding path touches on either branch, so a run + against unfixed code exercises its true behaviour (``_create_category_impl`` + appending to the advertised set) instead of tripping over a missing stub + attribute and failing for the wrong reason. + """ + + def __init__(self, store, advertised: list[tuple[str, str]]): + from memu.app.service import Context + + self.database = store + self.category_configs = [_StubCategoryConfig(n, d) for n, d in advertised] + self.category_config_map = {c.name: c for c in self.category_configs} + self._category_prompt_str = self._format_categories_for_prompt(self.category_configs) + self._context = Context() + self._embed_client = None + + @staticmethod + def _format_categories_for_prompt(cfgs) -> str: + return "\n".join(f"- {c.name}: {c.description}" for c in cfgs) + + def _get_context(self): + return self._context + + def _get_llm_client(self, _profile): + return self._embed_client + + +def _seed_bridge(tmp_path, advertised, *, configured=(), has_embeddings=False, db_name="memu.sqlite"): + """A bridge wired for _ensure_categories only: stub service, real store.""" + config = _make_config(tmp_path) + config.memory.categories = [ + MemoryCategoryConfig(name=n, description=d) for n, d in configured + ] + bridge = MemUBridge(config, audit_db=None) + bridge._service = _StubService(_memu_store(tmp_path / db_name), advertised) + # _has_embeddings reads config.openai_api_key; set it so no network is touched. + config.openai_api_key = "test-embed-key" if has_embeddings else "" + return bridge + + +def _rebuild_map(bridge) -> dict[str, str]: + """The name->ID rebuild _initialize_impl runs after _ensure_categories.""" + repo = bridge._service.database.memory_category_repo + return {cat.name.lower(): cat.id for cat in repo.categories.values()} + + +_INIT_PROBE = """ +import asyncio, sys, json +from pathlib import Path +import memu.app.service # imported first: avoids a circular import in the patcher +from nerve.config import MemoryCategoryConfig, MemoryConfig, NerveConfig +from nerve.memory.memu_bridge import MemUBridge + +async def main(): + tmp = Path(sys.argv[1]) + configured = json.loads(sys.argv[2]) + fail_load = sys.argv[3] == "fail-load" + cfg = NerveConfig() + cfg.memory = MemoryConfig( + sqlite_dsn=f"sqlite:///{tmp / 'memu.sqlite'}", + categories=[MemoryCategoryConfig(name=n, description=d) for n, d in configured], + ) + cfg.anthropic_api_key = "test-key" + bridge = MemUBridge(cfg, audit_db=None) + if fail_load: + real_ensure = bridge._ensure_categories + async def _boom(): + raise RuntimeError("category load exploded") + bridge._ensure_categories = _boom + del real_ensure + ok = await bridge.initialize() + out = {"initialize": ok, "available": bridge._available, + "service_available": bridge._metrics.service_available} + if bridge._service is not None: + svc = bridge._service + ctx = svc._get_context() + advertised = [c.name for c in svc.category_configs] + out["advertised"] = advertised + out["prompt_lines"] = [ln for ln in svc._category_prompt_str.splitlines() if ln.strip()] + out["map"] = dict(ctx.category_name_to_id) + out["resolved"] = svc._map_category_names_to_ids(advertised, ctx) + out["rows"] = sorted(c.name for c in + svc.database.memory_category_repo.list_categories().values()) + print("PROBE_JSON " + json.dumps(out)) + +asyncio.run(main()) +""" + + +def _run_init(tmp_path, configured=(), mode="normal"): + """Run a full MemUBridge.initialize() in a subprocess and return its report. + + Out of process because memU allows exactly one MemoryService per interpreter. + """ + import subprocess + + proc = subprocess.run( + [sys.executable, "-c", _INIT_PROBE, str(tmp_path), + json.dumps([list(c) for c in configured]), mode], + capture_output=True, text=True, timeout=300, + cwd=str(Path(__file__).resolve().parent.parent), + ) + line = next((ln for ln in proc.stdout.splitlines() if ln.startswith("PROBE_JSON ")), None) + assert line is not None, f"probe produced no report\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" + return json.loads(line[len("PROBE_JSON "):]) + + +class TestEnsureCategoriesSeeding: + """Every category advertised to the LLM must resolve through the name->ID map.""" + + @pytest.mark.asyncio + async def test_empty_config_seeds_the_advertised_defaults(self, tmp_path): + """The filed defect: no configured categories -> memU's defaults are advertised. + + Fails before the fix with map=0 / resolvable=0 of 10. + """ + advertised = [(f"cat_{i}", f"desc {i}") for i in range(4)] + bridge = _seed_bridge(tmp_path, advertised, configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + mapping = _rebuild_map(bridge) + assert sorted(c.name for c in rows.values()) == sorted(n for n, _ in advertised) + assert [n for n, _ in advertised if n.lower() not in mapping] == [] + # Descriptions carry through, so category summaries keep meaningful text. + assert {c.name: c.description for c in rows.values()} == dict(advertised) + + @pytest.mark.asyncio + async def test_configured_path_rows_and_map_unchanged(self, tmp_path): + """Seeding from the effective set does not change what a configured install gets.""" + configured = [("task_domain", "Domain knowledge"), ("patterns", "Recurring patterns")] + bridge = _seed_bridge(tmp_path, configured, configured=configured) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert sorted(c.name for c in rows.values()) == ["patterns", "task_domain"] + assert {c.name: c.description for c in rows.values()} == dict(configured) + assert sorted(_rebuild_map(bridge)) == ["patterns", "task_domain"] + + @pytest.mark.asyncio + async def test_configured_cold_start_does_not_inflate_advertised_set(self, tmp_path): + """A cold start must not list every category twice in the memorize prompt. + + Seeding via _create_category_impl appends a CategoryConfig for a name memU + already advertises: 3 configured categories became 6 advertised entries. + """ + configured = [("task_domain", "Domain"), ("patterns", "Recurring"), ("procedures", "How to")] + bridge = _seed_bridge(tmp_path, configured, configured=configured) + svc = bridge._service + before = [c.name for c in svc.category_configs] + + await bridge._ensure_categories() + + after = [c.name for c in svc.category_configs] + assert after == before + assert len(after) == len(set(after)) + assert len(svc._category_prompt_str.splitlines()) == len(configured) + + @pytest.mark.asyncio + async def test_empty_config_does_not_inflate_advertised_set(self, tmp_path): + """Same guard on the empty-config path, where the advertised set is memU's own. + + Pins the seeding primitive: routing this through _create_category_impl + duplicates all 10 default names (or loops, if the live list is iterated). + """ + advertised = [(f"cat_{i}", f"desc {i}") for i in range(4)] + bridge = _seed_bridge(tmp_path, advertised, configured=()) + svc = bridge._service + before = [c.name for c in svc.category_configs] + + await bridge._ensure_categories() + + after = [c.name for c in svc.category_configs] + assert after == before + assert len(after) == len(set(after)) + assert len(svc._category_prompt_str.splitlines()) == len(advertised) + + @pytest.mark.asyncio + async def test_reinit_creates_no_duplicates(self, tmp_path): + """"Only missing ones are created": a second boot adds nothing.""" + advertised = [("alpha", "A"), ("beta", "B")] + bridge = _seed_bridge(tmp_path, advertised, configured=()) + + await bridge._ensure_categories() + first = {c.id for c in bridge._service.database.memory_category_repo.list_categories().values()} + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert sorted(c.name for c in rows.values()) == ["alpha", "beta"] + assert {c.id for c in rows.values()} == first + assert sorted(_rebuild_map(bridge)) == ["alpha", "beta"] + + @pytest.mark.asyncio + async def test_persisted_row_is_remapped_when_no_categories_configured(self, tmp_path): + """A row created at runtime is resolvable again after a restart. + + Only the map half: the advertised set is rebuilt by memU from config at + construction, so the LLM is still not told this category exists. That + remaining half is out of scope here. + """ + store = _memu_store(tmp_path / "memu.sqlite") + store.memory_category_repo.get_or_create_category( + name="work", description="Work stuff", embedding=None, user_data={}, + ) + + config = _make_config(tmp_path) + config.memory.categories = [] + config.openai_api_key = "" + bridge = MemUBridge(config, audit_db=None) + # A fresh store over the same file: a restart starts with a cold cache. + bridge._service = _StubService(_memu_store(tmp_path / "memu.sqlite"), [("alpha", "A")]) + + await bridge._ensure_categories() + + mapping = _rebuild_map(bridge) + assert "work" in mapping + # Pinned residual: the persisted row is resolvable but still not advertised. + assert "work" not in [c.name for c in bridge._service.category_configs] + assert "work" not in bridge._service._category_prompt_str + + @pytest.mark.asyncio + async def test_category_load_failure_propagates(self, tmp_path): + """A read error must surface, not leave ``existing`` empty and re-seed everything.""" + bridge = _seed_bridge(tmp_path, [("alpha", "A")], configured=()) + repo = bridge._service.database.memory_category_repo + repo.list_categories = MagicMock(side_effect=RuntimeError("db read failed")) + + with pytest.raises(RuntimeError, match="db read failed"): + await bridge._ensure_categories() + + assert repo.categories == {} + + @pytest.mark.asyncio + async def test_seeds_are_audited_as_category_created(self, tmp_path): + """Seeded categories keep the documented ``category_created`` audit record.""" + advertised = [("alpha", "A"), ("beta", "B")] + bridge = _seed_bridge(tmp_path, advertised, configured=()) + bridge._audit = AsyncMock() + + await bridge._ensure_categories() + + actions = [(c.args[0], c.args[1], c.args[2], c.args[3]) for c in bridge._audit.await_args_list] + assert actions == [ + ("category_created", "category", "alpha", "bridge"), + ("category_created", "category", "beta", "bridge"), + ] + # Nothing new on a re-init, so no further audit records. + bridge._audit.reset_mock() + await bridge._ensure_categories() + assert bridge._audit.await_count == 0 + + @pytest.mark.asyncio + async def test_seeds_are_audited_on_the_configured_path_too(self, tmp_path): + configured = [("task_domain", "Domain")] + bridge = _seed_bridge(tmp_path, configured, configured=configured) + bridge._audit = AsyncMock() + + await bridge._ensure_categories() + + assert [c.args[:4] for c in bridge._audit.await_args_list] == [ + ("category_created", "category", "task_domain", "bridge"), + ] + + @pytest.mark.asyncio + async def test_embeddings_are_batched_when_a_provider_is_configured(self, tmp_path): + """Category ranking is vector-based on RAG installs, so seeds must carry vectors.""" + advertised = [("alpha", "A desc"), ("beta", "")] + bridge = _seed_bridge(tmp_path, advertised, configured=(), has_embeddings=True) + embed = AsyncMock(return_value=[[0.1, 0.2], [0.3, 0.4]]) + bridge._service._embed_client = MagicMock(embed=embed) + + await bridge._ensure_categories() + + # One batched call, not one per category. + assert embed.await_count == 1 + assert embed.await_args.args[0] == ["alpha: A desc", "beta"] + rows = bridge._service.database.memory_category_repo.list_categories() + stored = {c.name: list(c.embedding) for c in rows.values()} + assert stored == {"alpha": [0.1, 0.2], "beta": [0.3, 0.4]} + + @pytest.mark.asyncio + async def test_no_embed_call_without_a_provider(self, tmp_path): + bridge = _seed_bridge(tmp_path, [("alpha", "A")], configured=(), has_embeddings=False) + embed = AsyncMock(return_value=[[0.1]]) + bridge._service._embed_client = MagicMock(embed=embed) + + await bridge._ensure_categories() + + assert embed.await_count == 0 + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.embedding for c in rows.values()] == [None] + + @pytest.mark.asyncio + async def test_embed_failure_writes_nothing(self, tmp_path): + """No null-embedding row may be written when a provider IS configured. + + get_or_create_category returns an existing row untouched, so such a row is + never repaired: a later boot sees the name and skips it, while both category + rankers drop null vectors. Assert the ABSENCE of rows, not just the error. + """ + advertised = [("alpha", "A"), ("beta", "B")] + bridge = _seed_bridge(tmp_path, advertised, configured=(), has_embeddings=True) + embed = AsyncMock(side_effect=RuntimeError("embedding provider down")) + bridge._service._embed_client = MagicMock(embed=embed) + + with pytest.raises(RuntimeError, match="embedding provider down"): + await bridge._ensure_categories() + + assert bridge._service.database.memory_category_repo.list_categories() == {} + + +class TestInitializeCategoryInvariant: + """End-to-end: a full initialize() in its own process (one MemoryService each).""" + + def test_empty_config_every_advertised_category_resolves(self, tmp_path): + report = _run_init(tmp_path, configured=()) + + assert report["initialize"] is True + assert report["available"] is True + assert len(report["advertised"]) == 10 # memU's defaults + assert len(report["resolved"]) == len(report["advertised"]) + assert sorted(report["rows"]) == sorted(report["advertised"]) + assert len(report["prompt_lines"]) == len(report["advertised"]) + + def test_configured_cold_start_prompt_lists_each_category_once(self, tmp_path): + configured = [("task_domain", "Domain"), ("patterns", "Recurring")] + report = _run_init(tmp_path, configured=configured) + + assert report["advertised"] == ["task_domain", "patterns"] + assert len(report["prompt_lines"]) == 2 + assert len(report["resolved"]) == 2 + assert sorted(report["rows"]) == ["patterns", "task_domain"] + + def test_availability_is_not_published_when_init_fails(self, tmp_path): + """_available is the only failure signal the agent sees: engine.py drops the return.""" + report = _run_init(tmp_path, configured=(), mode="fail-load") + + assert report["initialize"] is False + assert report["available"] is False + assert report["service_available"] is False From 2c5ecd82315e1e01b15d95a27dd6c3c08350e856 Mon Sep 17 00:00:00 2001 From: Oranje AI <293843428+oranjeai@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:13:54 +1200 Subject: [PATCH 2/5] Fail before the first seed write when the embedding count is wrong zip(..., strict=True) raises as the loop advances, so a provider returning fewer or more vectors than categories left rows already written before the error. Pair up front instead: nothing is written unless every category has a vector. --- nerve/memory/memu_bridge.py | 6 +++++- tests/test_memu_bridge.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/nerve/memory/memu_bridge.py b/nerve/memory/memu_bridge.py index b9c4bca6..7a7318ff 100644 --- a/nerve/memory/memu_bridge.py +++ b/nerve/memory/memu_bridge.py @@ -1912,8 +1912,12 @@ async def _seed_categories(self, cat_cfgs: list[Any]) -> None: ] embeddings = list(await self._service._get_llm_client("embedding").embed(texts)) + # Pair up front, so a provider returning the wrong number of vectors fails + # before the first write rather than part-way through the loop. + pairs = list(zip(cat_cfgs, embeddings, strict=True)) + repo = self._service.database.memory_category_repo - for cat_cfg, embedding in zip(cat_cfgs, embeddings, strict=True): + for cat_cfg, embedding in pairs: repo.get_or_create_category( name=cat_cfg.name, description=cat_cfg.description, diff --git a/tests/test_memu_bridge.py b/tests/test_memu_bridge.py index 47d6c21e..90b6ee32 100644 --- a/tests/test_memu_bridge.py +++ b/tests/test_memu_bridge.py @@ -1492,6 +1492,24 @@ async def test_embed_failure_writes_nothing(self, tmp_path): assert bridge._service.database.memory_category_repo.list_categories() == {} + @pytest.mark.parametrize("returned", [1, 3], ids=["too-few", "too-many"]) + @pytest.mark.asyncio + async def test_wrong_embedding_count_writes_nothing(self, tmp_path, returned): + """A provider returning the wrong number of vectors must not half-seed. + + The pairing is materialized before the write loop, so the length mismatch + is raised before the first row instead of part-way through. + """ + advertised = [("alpha", "A"), ("beta", "B")] + bridge = _seed_bridge(tmp_path, advertised, configured=(), has_embeddings=True) + embed = AsyncMock(return_value=[[0.1]] * returned) + bridge._service._embed_client = MagicMock(embed=embed) + + with pytest.raises(ValueError, match="zip"): + await bridge._ensure_categories() + + assert bridge._service.database.memory_category_repo.list_categories() == {} + class TestInitializeCategoryInvariant: """End-to-end: a full initialize() in its own process (one MemoryService each).""" From 5efb96d12f762420b2313ba32f41be655a88c1dc Mon Sep 17 00:00:00 2001 From: Oranje AI <293843428+oranjeai@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:43:18 +1200 Subject: [PATCH 3/5] Keep the initialize() probes offline and assert that they are The three end-to-end arms in TestInitializeCategoryInvariant run a real MemUBridge.initialize(), whose warmup loop opens three LLM clients. Nothing pointed those at a stub, so each arm dialled api.anthropic.com: measured with strace -f -e trace=connect, tests/test_memu_bridge.py made 12 connections to port 443 and took 20.4s, against 0 and 3.7s at the base commit. A unit suite must not depend on a third-party endpoint, and this one passed quickly only because that endpoint rejects a fake key promptly; behind a blackholing proxy each arm would instead burn the warmup's 15s-per-profile timeout. Neutralize MemoryService._get_llm_base_client for the duration of _initialize_impl. It is the only client factory the warmup calls and the loop already swallows its exceptions, so no observable behaviour changes: every existing assertion in all three arms is unchanged and still passes. Make the offline property an assertion rather than a comment. The probe installs a socket.socket.connect that records and rejects, and reports offline = (nothing dialled) and (the blocker is installed). Both conjuncts are load-bearing: httpx reaches the network through socket.socket.connect and never through socket.create_connection (measured), the warmup's except Exception swallows a raise so only the record can fail a test, and without the second conjunct an empty record would also be satisfied by a blocker that was never armed. Mutation-checked in both directions: removing the warmup stub or the blocker fails the arms, a semantically identical no-op edit does not. tests/test_memu_bridge.py: 6 failed / 87 passed, 0 connections to :443, 5.6s. Whole suite: 2957 collected, 7 failed / 2950 passed, with the failing-name set unchanged (all pre-existing timezone artifacts). ## Coordination The sibling branch oranjeai/memu-category-name-normalization rewrites the same function and conflicts with this one: git merge-tree --write-tree reports CONFLICT on both nerve/memory/memu_bridge.py and tests/test_memu_bridge.py. The two changes are complementary in intent but incompatible in code -- that branch keeps the per-category create loop and normalizes names, while this one replaces the loop with a repo-level seed, and it still iterates the configured categories so it does not fix the empty-config case. Whichever lands second must rebase rather than auto-resolve, since a careless resolution can reinstate the double-advertising or drop one of the two fixes. --- nerve/memory/memu_bridge.py | 2 +- tests/test_memu_bridge.py | 40 +++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/nerve/memory/memu_bridge.py b/nerve/memory/memu_bridge.py index 7a7318ff..fecf20c3 100644 --- a/nerve/memory/memu_bridge.py +++ b/nerve/memory/memu_bridge.py @@ -1792,7 +1792,7 @@ def _log_after_step(step_ctx, state): try: self._service.database.memory_item_repo.list_items() # Categories are already cached: _ensure_categories loads them - # unconditionally, before the name-to-ID rebuild above. + # whenever a service exists, which _initialize_impl guarantees. # Convert cached embeddings from list[float] to numpy float32. # Pydantic coerces numpy → list during model construction, so we diff --git a/tests/test_memu_bridge.py b/tests/test_memu_bridge.py index 90b6ee32..d78d8a30 100644 --- a/tests/test_memu_bridge.py +++ b/tests/test_memu_bridge.py @@ -1227,12 +1227,24 @@ def _rebuild_map(bridge) -> dict[str, str]: _INIT_PROBE = """ -import asyncio, sys, json +import asyncio, sys, json, socket from pathlib import Path import memu.app.service # imported first: avoids a circular import in the patcher from nerve.config import MemoryCategoryConfig, MemoryConfig, NerveConfig from nerve.memory.memu_bridge import MemUBridge +# This probe must stay offline: httpx dials through socket.socket.connect +# (measured -- socket.create_connection is never called on this path). +# Record as well as raise: the warmup's ``except Exception`` swallows the raise, +# so only the record proves nothing dialled out. +_dialled = [] + +def _blocked(self, address, *args, **kwargs): + _dialled.append(address) + raise AssertionError(f"probe attempted an outbound connection to {address!r}") + +socket.socket.connect = _blocked + async def main(): tmp = Path(sys.argv[1]) configured = json.loads(sys.argv[2]) @@ -1250,9 +1262,30 @@ async def _boom(): raise RuntimeError("category load exploded") bridge._ensure_categories = _boom del real_ensure + # _initialize_impl warms up three LLM profiles against the live endpoint. + # Make the sole client factory it calls raise; the loop already swallows it. + real_init = bridge._initialize_impl + + async def _init_without_warmup(): + from memu.app.service import MemoryService + orig = MemoryService._get_llm_base_client + + def _no_warmup(self, profile=None): + raise RuntimeError("LLM warmup disabled: this probe must stay offline") + + MemoryService._get_llm_base_client = _no_warmup + try: + return await real_init() + finally: + MemoryService._get_llm_base_client = orig + + bridge._initialize_impl = _init_without_warmup + ok = await bridge.initialize() out = {"initialize": ok, "available": bridge._available, - "service_available": bridge._metrics.service_available} + "service_available": bridge._metrics.service_available, + "offline": not _dialled and socket.socket.connect is _blocked, + "dialled": [str(a) for a in _dialled]} if bridge._service is not None: svc = bridge._service ctx = svc._get_context() @@ -1517,6 +1550,7 @@ class TestInitializeCategoryInvariant: def test_empty_config_every_advertised_category_resolves(self, tmp_path): report = _run_init(tmp_path, configured=()) + assert report["offline"] is True assert report["initialize"] is True assert report["available"] is True assert len(report["advertised"]) == 10 # memU's defaults @@ -1528,6 +1562,7 @@ def test_configured_cold_start_prompt_lists_each_category_once(self, tmp_path): configured = [("task_domain", "Domain"), ("patterns", "Recurring")] report = _run_init(tmp_path, configured=configured) + assert report["offline"] is True assert report["advertised"] == ["task_domain", "patterns"] assert len(report["prompt_lines"]) == 2 assert len(report["resolved"]) == 2 @@ -1537,6 +1572,7 @@ def test_availability_is_not_published_when_init_fails(self, tmp_path): """_available is the only failure signal the agent sees: engine.py drops the return.""" report = _run_init(tmp_path, configured=(), mode="fail-load") + assert report["offline"] is True assert report["initialize"] is False assert report["available"] is False assert report["service_available"] is False From 78d7a441078f12028f3aae3aa3fc8683884af9d0 Mon Sep 17 00:00:00 2001 From: Oranje AI <293843428+oranjeai@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:58:27 +1200 Subject: [PATCH 4/5] Store seeded category names the way memU reads them back _seed_categories persisted, embedded and mapped the configured name verbatim, and _ensure_categories compared raw names for its already-exists skip. memU does not: _format_categories_for_prompt advertises name.strip() or "Untitled" (memu/app/memorize.py:930-938), its own initializer persists that same form (:663-665), and all three copies of the reverse lookup key on name.strip().lower() (crud.py, patch.py, memorize.py). So a configured name that is not already normalized produced a row under a key no consumer computes. Measured with a full initialize() against an empty store and categories = [{name: " alpha "}, {name: " "}]: the prompt advertises alpha and Untitled, the rows and the name-to-id map hold ' alpha ' and ' ', and mapping the advertised names returns [] while initialize() reports success. That is the failure mode this branch exists to remove -- 0 of the advertised categories can receive anything -- reached by a padded name instead of an empty config, so the invariant the branch states does not hold on its own tree. Route the four sites through one _memu_cat_name helper matching memU exactly: the existing-row comparison, the missing-config test, the embedding text, and the persisted name and description. The embedding text now matches _category_embedding_text byte for byte, including choosing the desc-less form on the stripped description, so seeded vectors stay in the space cosine_topk ranks in. The log line and the audit record name the stored row rather than the raw config text, so the audit target_id identifies what was written. The name-to-id rebuild in _initialize_impl and _create_category_impl are deliberately untouched: once the row is stored normalized, the rebuild's cat.name.lower() already yields memU's lookup key, and both sites are contested with the sibling branch below. A row already stored under a raw name is RENAMED to memU's form, unless another row already owns the name memU would look it up by, so one row ends up carrying the key every consumer computes. Merely recognising such a row is not enough: normalizing only the comparison suppresses the seed AND leaves the raw row unresolvable, which is worse than not normalizing at all. nerve's own update_category wrapper forwards summary and description only, but the repo layer does rename in all three backends, and the row id survives it, so existing category_items stay linked. Two key domains are in play, and they are not the same. memU's own resolver computes name.strip().lower() (memu/app/memorize.py:682), while the name-to-id rebuild in _initialize_impl keys on raw cat.name.lower(), without stripping. The repair guard compares in the resolver's domain, because that is what decides whether two rows are one category: a padded ' Alpha ' normalizes to a display name no row holds while sharing the resolver key of a stored 'alpha', so renaming it would leave two live rows behind ONE category_name_to_id entry, with the winner decided by repo.categories order (list_categories applies no ORDER BY). A key owned by more than one row is therefore left entirely alone: base's rows survive, and no items are discarded. Occupancy, though, is judged in the REBUILD's domain, and over the names the rows carry AFTER those repairs. A key that exists only as some padded row's stripped form is not one any consumer computes, so treating it as taken suppresses the seed that would supply it and leaves the advertised category unresolvable. That is reachable whenever two or more stored rows share one resolver key and none of them is already normalized, for instance an upgrade from a config that once carried a trailing-space and a leading-space spelling of the same name. base seeds a third row in that shape and does resolve, so judging occupancy in the wrong domain is a regression against base rather than an unfixed gap. The same post-repair comparison drives the already-exists test, so a padded configured name still does not seed a second row beside a case-differing one. Repairs and seeds are planned first and written together, after ONE batched embedding call. A renamed row is re-embedded from its normalized text for the same reason its name is normalized: both rankers read the stored vector, so a name-only rename would leave that row ranked in the space its raw text occupied. Writing the rename before the batch also made an embedding outage leave a half-migrated store -- renamed row, unseeded category -- so the write now happens only once every vector is in hand. The rename is audited as category_updated against the row id, matching _update_category_impl. A repaired row keeps its own description: it may have been edited through the API, and resolution does not depend on it. tests/test_memu_bridge.py: 118 collected, 6 failed / 112 passed; the same 6 fail on unmodified origin/main. Against origin/main source with these tests, 39 fail at file scope -- 33 of the 42 tests this branch adds, plus those 6. Against the previous commit's source, 11 fail: the same 6, plus the 5 arms this round adds for the multi-owner unaddressable-key defect. Those 5 also fail at origin/main, but for a different reason: base seeds the third row and does resolve, so there they fail on the row set rather than on resolution. Whole suite: 7 failed / 2975 passed, against 7 failed / 2933 passed on origin/main -- an identical 7-name failure set (pre-existing timezone artifacts), and +42 passing, matching the 42 collected names added exactly (0 removed). 17 of 18 mutants killed, the eighteenth a no-op control that passes; the mutant restoring the previous commit's occupancy set is killed by the new arms in both insertion orders. ## Coordination The sibling branch oranjeai/memu-category-name-normalization (PR #251, now de6932f) rewrites the same function and still conflicts with this one: git merge-tree --write-tree reports CONFLICT (content) on both nerve/memory/memu_bridge.py and tests/test_memu_bridge.py. That branch fixes normalization too, by a different and incompatible mechanism: a _norm_category_name that strips and lowercases, applied to the rebuild, to _create_category_impl and to the runtime lookup, while keeping the per-category create loop this branch replaces -- at de6932f it drops the pre-filter this branch normalizes. Whoever lands second must keep exactly one of the two rather than auto-resolve, because a careless resolution can reinstate the double-advertising or drop one of the fixes. Re-derived at handoff time against origin/main 94406ea: nine open PRs touch these two files, and six of them conflict. #251 conflicts semantically -- it rewrites the same function. #249 edits _ensure_categories and the runtime map write, so read its resolution rather than auto-merging. #254, #255, #248 and #70 conflict mechanically only, on test-file layout or nerve/bootstrap.py, without touching the seeding path; #254 and #255 conflict in tests/test_memu_bridge.py alone. #256, #252 and #247 merge cleanly. This table moves hourly; re-derive it before resolving anything. --- nerve/memory/memu_bridge.py | 144 +++++++-- tests/test_memu_bridge.py | 630 +++++++++++++++++++++++++++++++++++- 2 files changed, 738 insertions(+), 36 deletions(-) diff --git a/nerve/memory/memu_bridge.py b/nerve/memory/memu_bridge.py index fecf20c3..858ac3d2 100644 --- a/nerve/memory/memu_bridge.py +++ b/nerve/memory/memu_bridge.py @@ -123,6 +123,34 @@ def strip_references(t: str | None) -> str | None: return text +def _memu_cat_name(name: str) -> str: + """The display name memU advertises and stores: ``name.strip() or "Untitled"``. + + ``memu/app/memorize.py:930-938`` (prompt) and ``:663-665`` (persist). Not + lowercased: lowercasing happens only at lookup. + """ + return name.strip() or "Untitled" + + +def _memu_cat_key(name: str) -> str: + """The key memU's own resolver computes: ``name.strip().lower()``. + + ``memu/app/memorize.py:682``. The name-to-id rebuild in ``_initialize_impl`` + does NOT strip, so occupancy is judged in the rebuild's domain, not here. + """ + return _memu_cat_name(name).lower() + + +def _memu_cat_embed_text(name: str, description: str) -> str: + """memU's ``_category_embedding_text`` (``memu/app/memorize.py:670-673``). + + One copy, so seeds and repairs share the space ``cosine_topk`` ranks in. + """ + desc = (description or "").strip() + name = _memu_cat_name(name) + return f"{name}: {desc}" if desc else name + + class _VectorIndex: """Incremental brute-force vector index over memU item embeddings. @@ -1881,53 +1909,111 @@ async def _ensure_categories(self) -> None: # Unconditional, and read failures propagate: this call also warms the # repo cache that _initialize_impl's name-to-ID rebuild reads, and an # ``existing`` left empty by a swallowed error would re-seed every name. - existing = {getattr(cat, "name", "") for cat in repo.list_categories().values()} - + rows = list(repo.list_categories().values()) + + # Group by the LOOKUP key, not the display name: the name-to-id rebuild in + # _initialize_impl and memU's own resolver agree on it up to stripping, so + # a rename that is free among display names can still collapse two live + # rows onto one map entry. + key_owners: dict[str, list[Any]] = {} + for cat in rows: + key_owners.setdefault(_memu_cat_key(getattr(cat, "name", "")), []).append(cat) + + # Repair rows persisted under a non-normalized name so their rebuild key + # becomes the one memU computes. Two live sources: an upgrade from the + # pre-fix code, which stored the raw configured name, and + # ``_create_category_impl``, the runtime path this change leaves alone. + # The row id survives the rename, so its category_items stay linked. + # A key owned by more than one row is left entirely alone -- renaming + # either would make them indistinguishable to every lookup, and merging + # would have to discard one row's items. Read from the whole set, not + # the rows walked so far: list_categories() applies no ORDER BY. + repairs: list[tuple[Any, str]] = [] + for cat in rows: + raw = getattr(cat, "name", "") + norm = _memu_cat_name(raw) + if norm != raw and len(key_owners[_memu_cat_key(raw)]) == 1: + repairs.append((cat, norm)) + + # Occupancy must be judged in the domain the REBUILD keys on -- cat.name.lower() + # at the name-to-id rebuild in _initialize_impl, which does NOT strip -- and + # over the names the rows will carry AFTER the repairs above. A key that only + # exists as a _memu_cat_key of some padded row is not one any consumer computes, + # so treating it as taken suppresses the seed that would supply it. + renamed = {id(row): new for row, new in repairs} + existing = {renamed.get(id(cat), getattr(cat, "name", "")).lower() for cat in rows} # Snapshot: never iterate the advertised list while seeding from it. - missing = [c for c in list(self._service.category_configs) if c.name not in existing] - await self._seed_categories(missing) + missing = [ + c for c in list(self._service.category_configs) + if _memu_cat_name(c.name).lower() not in existing + ] + await self._seed_categories(missing, repairs) - async def _seed_categories(self, cat_cfgs: list[Any]) -> None: - """Persist startup seed categories WITHOUT touching the advertised set. + async def _seed_categories( + self, cat_cfgs: list[Any], repairs: list[tuple[Any, str]] | None = None, + ) -> None: + """Write the planned repairs and seeds, embedding once before any write. - memU built ``category_configs`` / ``category_config_map`` / - ``_category_prompt_str`` from these same entries before this runs, so - appending to them here (what ``_create_category_impl`` does for runtime - creation of a category memU does *not* yet advertise) would list every - category twice in the memorize prompt. + Seeds go through the repository rather than ``_create_category_impl``, + which also appends a ``CategoryConfig``: memU built ``category_configs`` / + ``category_config_map`` / ``_category_prompt_str`` from those entries + before this runs, so appending here would advertise every category twice. """ - if not self._service or not cat_cfgs: + repairs = list(repairs or ()) + if not self._service or (not cat_cfgs and not repairs): return - # Embed before any write. A row persisted with a null embedding is never - # repaired by a later boot (get_or_create_category returns an existing row - # untouched) and both category rankers skip null vectors, so an embed - # failure must propagate instead of falling back to None. One batched - # call, matching memU's own category initializer. - embeddings: list[Any] = [None] * len(cat_cfgs) + # Embed before any write, in ONE batched call covering repairs and seeds + # alike. A row persisted with a null embedding is never repaired by a + # later boot (get_or_create_category returns an existing row untouched) + # and both rankers skip null vectors, so a failure must propagate rather + # than fall back to None -- and it must not leave a partial migration behind. + # A repaired row is re-embedded from its NORMALIZED text for the same + # reason its name is normalized: the stored vector is what cosine_topk + # ranks, so seeds and repairs have to share one space. + plan: list[tuple[str, Any]] = ( + [("repair", r) for r in repairs] + [("seed", c) for c in cat_cfgs] + ) + texts: list[str] = [] + for kind, item in plan: + src = item[0] if kind == "repair" else item + texts.append(_memu_cat_embed_text( + getattr(src, "name", ""), getattr(src, "description", "") or "", + )) + embeddings: list[Any] = [None] * len(plan) if self._has_embeddings: - texts = [ - f"{c.name}: {c.description}" if c.description else c.name - for c in cat_cfgs - ] embeddings = list(await self._service._get_llm_client("embedding").embed(texts)) # Pair up front, so a provider returning the wrong number of vectors fails # before the first write rather than part-way through the loop. - pairs = list(zip(cat_cfgs, embeddings, strict=True)) + pairs = list(zip(plan, embeddings, strict=True)) repo = self._service.database.memory_category_repo - for cat_cfg, embedding in pairs: + for (kind, item), embedding in pairs: + if kind == "repair": + row, name = item + repo.update_category(category_id=row.id, name=name, embedding=embedding) + logger.info("Repaired category name: %r -> %s", getattr(row, "name", ""), name) + await self._audit( + "category_updated", "category", row.id, "bridge", + {"name_before": getattr(row, "name", ""), "name_after": name}, + ) + continue + # Store memU's normalized form: it is the name the memorize prompt + # shows the LLM and the only one its reverse lookups can resolve. + name = _memu_cat_name(item.name) + description = item.description.strip() repo.get_or_create_category( - name=cat_cfg.name, - description=cat_cfg.description, + name=name, + description=description, embedding=embedding, user_data={}, ) - logger.info("Seeded category: %s", cat_cfg.name) + # Audit the STORED name, so target_id identifies what was written. + logger.info("Seeded category: %s", name) await self._audit( - "category_created", "category", cat_cfg.name, "bridge", - {"description": cat_cfg.description}, + "category_created", "category", name, "bridge", + {"description": description}, ) # Maximum time (seconds) for a single memorize operation before cancellation. diff --git a/tests/test_memu_bridge.py b/tests/test_memu_bridge.py index d78d8a30..dea02cd9 100644 --- a/tests/test_memu_bridge.py +++ b/tests/test_memu_bridge.py @@ -1198,7 +1198,15 @@ def __init__(self, store, advertised: list[tuple[str, str]]): @staticmethod def _format_categories_for_prompt(cfgs) -> str: - return "\n".join(f"- {c.name}: {c.description}" for c in cfgs) + # Verbatim memU (memu/app/memorize.py:930-938): the advertised name is + # normalized even though the CategoryConfig keeps the raw one, which is + # exactly the asymmetry the seeding path has to match. + lines = [] + for c in cfgs: + name = c.name.strip() or "Untitled" + desc = c.description.strip() + lines.append(f"- {name}: {desc}" if desc else f"- {name}") + return "\n".join(lines) def _get_context(self): return self._context @@ -1249,6 +1257,9 @@ async def main(): tmp = Path(sys.argv[1]) configured = json.loads(sys.argv[2]) fail_load = sys.argv[3] == "fail-load" + # Rows a PREVIOUS nerve left on disk, written by _PRE_STORE_PROBE in its own + # interpreter (memU allows one store per process) and passed in as name->id. + pre_ids = json.loads(sys.argv[4]) if len(sys.argv) > 4 else {} cfg = NerveConfig() cfg.memory = MemoryConfig( sqlite_dsn=f"sqlite:///{tmp / 'memu.sqlite'}", @@ -1291,27 +1302,94 @@ def _no_warmup(self, profile=None): ctx = svc._get_context() advertised = [c.name for c in svc.category_configs] out["advertised"] = advertised - out["prompt_lines"] = [ln for ln in svc._category_prompt_str.splitlines() if ln.strip()] + lines = [ln for ln in svc._category_prompt_str.splitlines() if ln.strip()] + out["prompt_lines"] = lines + # The names the LLM is actually TOLD, read back out of memU's own prompt + # string ("- : " / "- ", memu/app/memorize.py:930-938). + # Those, not the raw config names, are what it emits and what must resolve. + out["prompt_names"] = [ln[2:].split(": ", 1)[0] for ln in lines] out["map"] = dict(ctx.category_name_to_id) out["resolved"] = svc._map_category_names_to_ids(advertised, ctx) - out["rows"] = sorted(c.name for c in - svc.database.memory_category_repo.list_categories().values()) + out["resolved_prompt"] = svc._map_category_names_to_ids(out["prompt_names"], ctx) + rows = svc.database.memory_category_repo.list_categories().values() + out["rows"] = sorted(c.name for c in rows) + # name -> id, so an upgrade arm can prove a repaired row kept its id + # (category_items link on the id, and a fresh row would orphan them). + out["row_ids"] = {c.name: c.id for c in rows} + out["pre_ids"] = pre_ids print("PROBE_JSON " + json.dumps(out)) asyncio.run(main()) """ -def _run_init(tmp_path, configured=(), mode="normal"): +_PRE_STORE_PROBE = """ +import json, sys +from pathlib import Path +import memu.app.service # imported first: avoids a circular import in the patcher +from nerve.memory.memu_bridge import MemUBridge + +# Rows a PREVIOUS nerve left behind: the pre-fix seed path persisted the raw +# configured name, so an upgrade finds rows whose lookup key is not the one memU +# computes. Written through memU's own repo, so the rows are genuine. +# +# Its own process because memU permits one store per interpreter: the patched +# model factory clears the model cache and rebuilds the tables, so a second +# build in the initialize() probe raises "Column object 'url' already assigned". +# +# Patch FIRST, then read the factory off the MODULE -- _patch_sqlite_bugs rebinds +# get_sqlite_sqlalchemy_models (memu-py names its tables ``sqlite_*``, a prefix +# SQLite reserves), so a ``from ... import`` above the call captures the +# unpatched factory and cannot create tables. +MemUBridge._patch_sqlite_bugs() +import memu.database.sqlite.schema as schema +from memu.database.sqlite.sqlite import SQLiteStore + +# The SAME scope model MemoryService uses (memu/app/settings.py: UserConfig +# defaults to DefaultUserModel), so the tables written here carry the scope +# columns initialize() will later select -- a bare BaseModel scope omits +# ``user_id`` and the bridge's own read then fails "no such column". +from memu.app.settings import DefaultUserModel as Scope + +store = SQLiteStore(dsn=f"sqlite:///{Path(sys.argv[1]) / 'memu.sqlite'}", scope_model=Scope, + sqla_models=schema.get_sqlite_sqlalchemy_models(scope_model=Scope)) +ids = {n: store.memory_category_repo.get_or_create_category( + name=n, description="A", embedding=None, user_data={}).id + for n in json.loads(sys.argv[2])} +print("PRE_JSON " + json.dumps(ids)) +""" + + +def _run_init(tmp_path, configured=(), mode="normal", pre_store=()): """Run a full MemUBridge.initialize() in a subprocess and return its report. Out of process because memU allows exactly one MemoryService per interpreter. + ``pre_store`` names rows to persist before initialize() runs, modelling a store + written by an earlier nerve; it needs its OWN process for the same reason. """ import subprocess + pre_ids: dict[str, str] = {} + if pre_store: + pre = subprocess.run( + [sys.executable, "-c", _PRE_STORE_PROBE, str(tmp_path), + json.dumps(list(pre_store))], + capture_output=True, text=True, timeout=300, + cwd=str(Path(__file__).resolve().parent.parent), + ) + pre_line = next((ln for ln in pre.stdout.splitlines() + if ln.startswith("PRE_JSON ")), None) + assert pre_line is not None, ( + f"pre-store probe produced no report\nstdout:\n{pre.stdout}\nstderr:\n{pre.stderr}" + ) + pre_ids = json.loads(pre_line[len("PRE_JSON "):]) + # The rows must really be there, or the upgrade arm would silently + # degenerate into the ordinary cold-start case it is meant to contrast. + assert sorted(pre_ids) == sorted(pre_store), pre_ids + proc = subprocess.run( [sys.executable, "-c", _INIT_PROBE, str(tmp_path), - json.dumps([list(c) for c in configured]), mode], + json.dumps([list(c) for c in configured]), mode, json.dumps(pre_ids)], capture_output=True, text=True, timeout=300, cwd=str(Path(__file__).resolve().parent.parent), ) @@ -1343,7 +1421,11 @@ async def test_empty_config_seeds_the_advertised_defaults(self, tmp_path): @pytest.mark.asyncio async def test_configured_path_rows_and_map_unchanged(self, tmp_path): - """Seeding from the effective set does not change what a configured install gets.""" + """Seeding from the effective set does not change what a configured install gets. + + A no-regression guard that must hold on BOTH trees, not a defect reproducer: + it passes at base by design. + """ configured = [("task_domain", "Domain knowledge"), ("patterns", "Recurring patterns")] bridge = _seed_bridge(tmp_path, configured, configured=configured) @@ -1468,6 +1550,11 @@ async def test_seeds_are_audited_as_category_created(self, tmp_path): @pytest.mark.asyncio async def test_seeds_are_audited_on_the_configured_path_too(self, tmp_path): + """A no-regression guard that must hold on BOTH trees, not a defect reproducer. + + The configured path already audited its seeds at base; this pins that the + rewrite kept it. + """ configured = [("task_domain", "Domain")] bridge = _seed_bridge(tmp_path, configured, configured=configured) bridge._audit = AsyncMock() @@ -1543,6 +1630,474 @@ async def test_wrong_embedding_count_writes_nothing(self, tmp_path, returned): assert bridge._service.database.memory_category_repo.list_categories() == {} + @pytest.mark.parametrize("returned", [1, 3], ids=["too-few", "too-many"]) + @pytest.mark.asyncio + async def test_wrong_embedding_count_writes_nothing_with_a_repair_too( + self, tmp_path, returned, + ): + """The strict pairing covers the COMBINED plan: one repair plus one seed = 2. + + The batch now spans repairs as well as seeds, so a wrong vector count must + still raise before the first write -- including before the rename. + """ + store = _memu_store(tmp_path / "memu.sqlite") + legacy = store.memory_category_repo.get_or_create_category( + name=" alpha ", description="A", embedding=None, user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A"), ("beta", "B")], + configured=(), has_embeddings=True) + embed = AsyncMock(return_value=[[0.1]] * returned) + bridge._service._embed_client = MagicMock(embed=embed) + + with pytest.raises(ValueError, match="zip"): + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.name for c in rows.values()] == [" alpha "] + assert rows[legacy.id].name == " alpha " + + @pytest.mark.asyncio + async def test_padded_name_is_not_seeded_twice(self, tmp_path): + """The already-exists skip compares memU's normalized name, not the raw one. + + A row stored as ``alpha`` and a config entry ``" alpha "`` are the same + category, so a second boot must add nothing. + """ + store = _memu_store(tmp_path / "memu.sqlite") + store.memory_category_repo.get_or_create_category( + name="alpha", description="A", embedding=None, user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A")], configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.name for c in rows.values()] == ["alpha"] + assert sorted(_rebuild_map(bridge)) == ["alpha"] + + @pytest.mark.asyncio + async def test_a_legacy_raw_row_is_repaired_not_duplicated(self, tmp_path): + """A row stored under a raw name is RENAMED to memU's form, not duplicated. + + Reachable two ways, both live: an upgrade from the pre-fix code, which + persisted the raw configured name, and ``_create_category_impl``, the runtime + creation path this change deliberately leaves alone. Seeding a second row + instead would give two rows for one logical category; recognising the row but + leaving it raw would keep its rebuild key raw, so the advertised name still + would not resolve. ``nerve``'s own ``update_category`` wrapper cannot rename + (it forwards summary/description only), but the repo layer can, so the seed + path renames -- keeping ONE row, with its id, and therefore its items. + """ + store = _memu_store(tmp_path / "memu.sqlite") + before = store.memory_category_repo.get_or_create_category( + name=" alpha ", description="A", embedding=None, user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A")], configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.name for c in rows.values()] == ["alpha"] + assert sorted(_rebuild_map(bridge)) == ["alpha"] + # ONE row, and it is the SAME row: renaming must not orphan its + # category_items, which link on the id. + assert len(rows) == 1 + assert [c.id for c in rows.values()] == [before.id] + + @pytest.mark.asyncio + async def test_a_legacy_row_is_not_repaired_onto_a_taken_name(self, tmp_path): + """The repair must not produce two rows with the SAME name. + + A store holding BOTH the raw and the normalized row is reachable from base + (base seeds the normalized configured name beside an existing raw row), and + ``list_categories()`` applies no ORDER BY -- so a guard that only remembered + the rows walked so far would rename the raw one onto the taken name whenever + it came first. Every lookup keys on the name, so the two rows would then be + indistinguishable. Asserted in BOTH insertion orders. + """ + for tag, order in (("raw-first", [" alpha ", "alpha"]), + ("norm-first", ["alpha", " alpha "])): + store = _memu_store(tmp_path / f"memu-{tag}.sqlite") + ids = { + n: store.memory_category_repo.get_or_create_category( + name=n, description="A", embedding=None, user_data={}, + ).id + for n in order + } + bridge = _seed_bridge(tmp_path, [(" alpha ", "A")], configured=(), + db_name=f"memu-{tag}.sqlite") + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + names = sorted(c.name for c in rows.values()) + assert names == [" alpha ", "alpha"], tag + assert len(names) == len(set(names)), tag + # Both pre-existing rows survive untouched, so no items are orphaned. + assert {c.id for c in rows.values()} == set(ids.values()), tag + # The advertised name still resolves, via the already-normalized row. + assert "alpha" in _rebuild_map(bridge), tag + + @pytest.mark.asyncio + async def test_a_blank_legacy_row_is_repaired_to_untitled(self, tmp_path): + """The repair uses memU's normalization, so a blank name becomes ``Untitled``. + + memU advertises a nameless category as ``Untitled``; a row stored as ``' '`` + resolves under no advertised key until it carries that name. + """ + store = _memu_store(tmp_path / "memu.sqlite") + before = store.memory_category_repo.get_or_create_category( + name=" ", description="B", embedding=None, user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" ", "B")], configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.name for c in rows.values()] == ["Untitled"] + assert [c.id for c in rows.values()] == [before.id] + assert sorted(_rebuild_map(bridge)) == ["untitled"] + + @pytest.mark.asyncio + async def test_case_only_duplicate_rows_are_left_alone(self, tmp_path): + """Case-only pairs are deliberately OUT of scope, and must stay untouched. + + ``Alpha`` and ``alpha`` are both already normalized, so neither is a legacy + raw row; base produces the same two rows, and merging them would have to + discard one row's items. Pinned so a later change does not quietly widen + the repair into a destructive merge. + """ + store = _memu_store(tmp_path / "memu.sqlite") + ids = { + n: store.memory_category_repo.get_or_create_category( + name=n, description="A", embedding=None, user_data={}, + ).id + for n in ("Alpha", "alpha") + } + bridge = _seed_bridge(tmp_path, [("alpha", "A")], configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert sorted(c.name for c in rows.values()) == ["Alpha", "alpha"] + assert {c.id for c in rows.values()} == set(ids.values()) + + @pytest.mark.parametrize( + "order", + [[" Alpha ", "alpha"], ["alpha", " Alpha "]], + ids=["padded-first", "normalized-first"], + ) + @pytest.mark.asyncio + async def test_a_padded_row_is_not_repaired_onto_another_rows_lookup_key( + self, tmp_path, order, + ): + """Occupancy is judged on the LOOKUP key, so a rename cannot collapse two rows. + + ``' Alpha '`` normalizes to ``Alpha``, which is free among display names but + shares the rebuild key ``alpha`` with the second row -- so renaming it would + leave two live rows sharing ONE ``category_name_to_id`` entry, and which one + wins depends on ``repo.categories`` order (``list_categories()`` applies no + ORDER BY). Both rows must therefore be left exactly as base leaves them. + Asserted on the map-key COUNT, not just membership: membership alone cannot + see a collapse. Both insertion orders, for the same missing-ORDER-BY reason. + """ + db = f"memu-{'-'.join(order)}.sqlite".replace(" ", "_") + store = _memu_store(tmp_path / db) + ids = { + n: store.memory_category_repo.get_or_create_category( + name=n, description="A", embedding=None, user_data={}, + ).id + for n in order + } + bridge = _seed_bridge(tmp_path, [("alpha", "A")], configured=(), db_name=db) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert sorted(c.name for c in rows.values()) == [" Alpha ", "alpha"] + assert {c.id for c in rows.values()} == set(ids.values()) + # Two live rows, so two addressable keys. One key here is the regression. + assert len(_rebuild_map(bridge)) == 2 + assert "alpha" in _rebuild_map(bridge) + + @pytest.mark.parametrize( + "order", + [[" alpha ", "alpha "], ["alpha ", " alpha "]], + ids=["wider-first", "narrower-first"], + ) + @pytest.mark.asyncio + async def test_two_raw_rows_sharing_a_lookup_key_still_get_an_addressable_row( + self, tmp_path, order, + ): + """When NO row already owns the advertised key, one must still be seeded. + + Both stored rows are raw, so the multi-owner guard correctly declines to rename + either -- but neither is addressable, because the name-to-id rebuild keys on + ``cat.name.lower()`` without stripping. Judging occupancy on ``_memu_cat_key`` + instead marks ``alpha`` taken by a key no consumer computes and suppresses the + seed, leaving the advertised name unresolvable -- exactly what base avoids, by + seeding a third row. Both insertion orders: ``list_categories()`` applies no + ORDER BY. + """ + db = f"memu-raw-{'-'.join(order)}.sqlite".replace(" ", "_") + store = _memu_store(tmp_path / db) + ids = { + n: store.memory_category_repo.get_or_create_category( + name=n, description="A", embedding=None, user_data={}, + ).id + for n in order + } + bridge = _seed_bridge(tmp_path, [("alpha", "A")], configured=(), db_name=db) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + # Three rows: both raw rows untouched, plus the addressable seed. + assert sorted(c.name for c in rows.values()) == [" alpha ", "alpha", "alpha "], order + assert set(ids.values()) <= {c.id for c in rows.values()}, order + assert {c.name for c in rows.values() if c.id in set(ids.values())} == set(order), order + mapping = _rebuild_map(bridge) + assert "alpha" in mapping, order + # A key COUNT assertion: membership alone cannot see a collapse. + assert len(mapping) == 3, order + + @pytest.mark.parametrize( + "order", + [[" Alpha ", " alpha"], [" alpha", " Alpha "]], + ids=["padded-first", "narrower-first"], + ) + @pytest.mark.asyncio + async def test_case_differing_raw_rows_sharing_a_key_still_get_an_addressable_row( + self, tmp_path, order, + ): + """The same gap reached through a case difference, where no rename is free. + + ``' Alpha '`` and ``' alpha'`` share the ``_memu_cat_key`` ``alpha`` and + neither is already normalized, so both are correctly left alone -- and neither + answers to the advertised ``alpha`` under the rebuild's own key. + """ + db = f"memu-case-{'-'.join(order)}.sqlite".replace(" ", "_") + store = _memu_store(tmp_path / db) + ids = { + n: store.memory_category_repo.get_or_create_category( + name=n, description="A", embedding=None, user_data={}, + ).id + for n in order + } + bridge = _seed_bridge(tmp_path, [("alpha", "A")], configured=(), db_name=db) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert sorted(c.name for c in rows.values()) == [" Alpha ", " alpha", "alpha"], order + assert set(ids.values()) <= {c.id for c in rows.values()}, order + assert {c.name for c in rows.values() if c.id in set(ids.values())} == set(order), order + mapping = _rebuild_map(bridge) + assert "alpha" in mapping, order + assert len(mapping) == 3, order + + @pytest.mark.asyncio + async def test_a_padded_config_entry_matches_a_case_differing_row(self, tmp_path): + """The already-exists test compares lookup keys, so no second row is seeded. + + A row stored as ``Alpha`` and an advertised `` alpha `` are one category to + every memU lookup; seeding a second row would put both behind one rebuild key. + """ + store = _memu_store(tmp_path / "memu.sqlite") + before = store.memory_category_repo.get_or_create_category( + name="Alpha", description="A", embedding=None, user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A")], configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.name for c in rows.values()] == ["Alpha"] + assert [c.id for c in rows.values()] == [before.id] + assert sorted(_rebuild_map(bridge)) == ["alpha"] + + @pytest.mark.asyncio + async def test_seeded_name_and_description_are_normalized(self, tmp_path): + """The stored row carries the name the prompt advertises, so lookups resolve.""" + advertised = [(" alpha ", " A "), (" ", "B")] + bridge = _seed_bridge(tmp_path, advertised, configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert {c.name: c.description for c in rows.values()} == {"alpha": "A", "Untitled": "B"} + # The map is keyed on what memU looks up: name.strip().lower(). + assert sorted(_rebuild_map(bridge)) == ["alpha", "untitled"] + # A second pass finds both and adds nothing. + await bridge._ensure_categories() + assert len(bridge._service.database.memory_category_repo.list_categories()) == 2 + + @pytest.mark.asyncio + async def test_embed_text_is_normalized_like_memu(self, tmp_path): + """Seed vectors must be embedded from memU's own _category_embedding_text. + + A vector built from the padded text lands elsewhere in the space cosine_topk + ranks in, so category ranking would degrade silently. + """ + advertised = [(" alpha ", " A "), (" beta ", " ")] + bridge = _seed_bridge(tmp_path, advertised, configured=(), has_embeddings=True) + embed = AsyncMock(return_value=[[0.1, 0.2], [0.3, 0.4]]) + bridge._service._embed_client = MagicMock(embed=embed) + + await bridge._ensure_categories() + + # "beta" has a whitespace-only description, so it takes the desc-less form. + assert embed.await_args.args[0] == ["alpha: A", "beta"] + + @pytest.mark.asyncio + async def test_a_repaired_row_is_re_embedded_from_its_normalized_text(self, tmp_path): + """A renamed row must not keep the vector embedded from its raw name. + + Both category rankers read the stored vector directly, and seeds are + deliberately embedded from the normalized text -- so a repair that updated + only ``name`` would leave that one row ranked in a different space. One + batched call covers the repair and the seed together. + """ + store = _memu_store(tmp_path / "memu.sqlite") + legacy = store.memory_category_repo.get_or_create_category( + name=" alpha ", description="A", embedding=[9.0, 9.0], user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A"), ("beta", "B")], + configured=(), has_embeddings=True) + embed = AsyncMock(return_value=[[0.1, 0.2], [0.3, 0.4]]) + bridge._service._embed_client = MagicMock(embed=embed) + + await bridge._ensure_categories() + + # ONE call, carrying the repair's NORMALIZED text alongside the seed's. + assert embed.await_count == 1 + assert embed.await_args.args[0] == ["alpha: A", "beta: B"] + rows = bridge._service.database.memory_category_repo.list_categories() + stored = {c.name: list(c.embedding) for c in rows.values()} + # approx, not ==: a re-read vector comes back through _patch_sqlite_bugs' + # numpy float32 embeddings (Fix 6), unlike a freshly created row's cached + # list. The point is WHICH vector is stored, not its dtype. + assert stored["alpha"] == pytest.approx([0.1, 0.2], abs=1e-6) + assert stored["beta"] == pytest.approx([0.3, 0.4], abs=1e-6) + # And emphatically not the vector embedded from the raw name. + assert stored["alpha"] != pytest.approx([9.0, 9.0], abs=1e-6) + assert rows[legacy.id].name == "alpha" + + @pytest.mark.asyncio + async def test_no_embed_call_leaves_a_repaired_rows_vector_alone(self, tmp_path): + """With no provider the rename passes ``embedding=None``, which is a no-op write. + + ``update_category`` skips ``embedding_json`` when the argument is None, so the + existing vector survives -- the correct outcome when nothing can be embedded. + A no-regression guard that must hold on BOTH trees, not a defect reproducer. + """ + store = _memu_store(tmp_path / "memu.sqlite") + legacy = store.memory_category_repo.get_or_create_category( + name=" alpha ", description="A", embedding=[9.0, 9.0], user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A")], configured=(), + has_embeddings=False) + embed = AsyncMock(return_value=[[0.1]]) + bridge._service._embed_client = MagicMock(embed=embed) + + await bridge._ensure_categories() + + assert embed.await_count == 0 + rows = bridge._service.database.memory_category_repo.list_categories() + assert rows[legacy.id].name == "alpha" + assert list(rows[legacy.id].embedding) == [9.0, 9.0] + + @pytest.mark.asyncio + async def test_embed_failure_commits_no_rename(self, tmp_path): + """Embed-before-write covers repairs too, so a failure migrates nothing. + + With the rename written before the embedding batch, an outage left the row + renamed and the missing category unseeded: a partially migrated store. + """ + store = _memu_store(tmp_path / "memu.sqlite") + legacy = store.memory_category_repo.get_or_create_category( + name=" alpha ", description="A", embedding=[9.0], user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A"), ("beta", "B")], + configured=(), has_embeddings=True) + embed = AsyncMock(side_effect=RuntimeError("embedding provider down")) + bridge._service._embed_client = MagicMock(embed=embed) + + with pytest.raises(RuntimeError, match="embedding provider down"): + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.name for c in rows.values()] == [" alpha "] + assert rows[legacy.id].name == " alpha " + + @pytest.mark.asyncio + async def test_a_repair_is_audited_as_category_updated(self, tmp_path): + """Every other category mutation in this file is audited; the repair must be too. + + ``category_updated`` with the row id, matching ``_update_category_impl``. + """ + store = _memu_store(tmp_path / "memu.sqlite") + legacy = store.memory_category_repo.get_or_create_category( + name=" alpha ", description="A", embedding=None, user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "A")], configured=()) + bridge._audit = AsyncMock() + + await bridge._ensure_categories() + + assert [c.args[:4] for c in bridge._audit.await_args_list] == [ + ("category_updated", "category", legacy.id, "bridge"), + ] + assert bridge._audit.await_args.args[4] == { + "name_before": " alpha ", "name_after": "alpha", + } + # A boot with nothing to repair emits no update. + bridge._audit.reset_mock() + await bridge._ensure_categories() + assert bridge._audit.await_count == 0 + + @pytest.mark.asyncio + async def test_a_repaired_row_keeps_its_own_description(self, tmp_path): + """The repair renames ONLY. Pinned so a later change cannot flip it silently. + + A stored description may have been edited through the API or the UI, and the + rename is not a reconciliation point: overwriting it with the config text + would discard that edit, and resolution does not depend on it. A BEHAVIOUR + PIN that holds on both trees, not a defect reproducer -- its value is that a + future widening of the repair has to change this arm deliberately. + """ + store = _memu_store(tmp_path / "memu.sqlite") + legacy = store.memory_category_repo.get_or_create_category( + name=" alpha ", description="edited by the user", embedding=None, user_data={}, + ) + bridge = _seed_bridge(tmp_path, [(" alpha ", "config text")], configured=()) + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert rows[legacy.id].name == "alpha" + assert rows[legacy.id].description == "edited by the user" + assert len(rows) == 1 + + @pytest.mark.asyncio + async def test_blank_name_is_seeded_once_as_untitled(self, tmp_path): + """memU shows a nameless category as ``Untitled``; the row must match.""" + bridge = _seed_bridge(tmp_path, [(" ", "B")], configured=()) + bridge._audit = AsyncMock() + + await bridge._ensure_categories() + + rows = bridge._service.database.memory_category_repo.list_categories() + assert [c.name for c in rows.values()] == ["Untitled"] + # The audit target_id names the row that was written, not the blank config. + assert [c.args[:4] for c in bridge._audit.await_args_list] == [ + ("category_created", "category", "Untitled", "bridge"), + ] + bridge._audit.reset_mock() + await bridge._ensure_categories() + assert len(bridge._service.database.memory_category_repo.list_categories()) == 1 + assert bridge._audit.await_count == 0 + class TestInitializeCategoryInvariant: """End-to-end: a full initialize() in its own process (one MemoryService each).""" @@ -1568,6 +2123,67 @@ def test_configured_cold_start_prompt_lists_each_category_once(self, tmp_path): assert len(report["resolved"]) == 2 assert sorted(report["rows"]) == ["patterns", "task_domain"] + def test_padded_and_blank_configured_names_still_all_resolve(self, tmp_path): + """End-to-end: every name the PROMPT advertises resolves, however it was written. + + Unfixed, initialize() reports success with rows/map keyed on the raw + ``' alpha '`` / ``' '`` while the prompt says ``alpha`` / ``Untitled``, + so resolved_prompt is empty and every LLM assignment is dropped. + Asserting against report["advertised"] would NOT see this: the raw + ``' alpha '`` key happens to match itself, giving 1 of 2 even unfixed. + """ + report = _run_init(tmp_path, configured=[(" alpha ", "A"), (" ", "B")]) + + assert report["offline"] is True + assert report["initialize"] is True + assert report["prompt_names"] == ["alpha", "Untitled"] + assert len(report["resolved_prompt"]) == len(report["prompt_names"]) + assert sorted(report["rows"]) == ["Untitled", "alpha"] + assert sorted(report["map"]) == ["alpha", "untitled"] + + def test_upgrade_from_a_raw_stored_row_still_resolves_everything(self, tmp_path): + """End-to-end upgrade: rows left by the pre-fix seed path are repaired. + + The post-upgrade shape: the row on disk carries the raw configured name the + old code stored, while the config has since been cleaned up. Unfixed, the + row keys as ``' alpha '`` and the prompt says ``alpha``, so resolved_prompt + is short and every LLM assignment to it is dropped. Asserts the repair + keeps ONE row and the SAME row, so its category_items stay linked. + """ + report = _run_init(tmp_path, configured=[("alpha", "A")], + pre_store=[" alpha "]) + + assert report["offline"] is True + assert report["initialize"] is True + assert report["prompt_names"] == ["alpha"] + assert len(report["resolved_prompt"]) == len(report["prompt_names"]) + assert report["rows"] == ["alpha"] + assert sorted(report["map"]) == ["alpha"] + assert report["row_ids"]["alpha"] == report["pre_ids"][" alpha "] + + def test_upgrade_from_two_raw_rows_sharing_a_key_still_resolves(self, tmp_path): + """End-to-end: two raw rows share the advertised key, so neither can be renamed. + + Reachable as an upgrade from a config that once carried both ``' alpha '`` + and ``'alpha '``, or a pre-fix boot plus one ``_create_category_impl`` call. + Judging occupancy on the strip-and-lower key marks ``alpha`` present and skips + the seed, so ``resolved_prompt`` is empty while ``initialize()`` reports + success -- the very "advertised but unresolvable" state this branch removes. + Both pre-existing rows must survive, and one row must answer to ``alpha``. + """ + pre = [" alpha ", "alpha "] + report = _run_init(tmp_path, configured=[("alpha", "A")], pre_store=pre) + + assert report["offline"] is True + assert report["initialize"] is True + assert report["available"] is True + assert report["prompt_names"] == ["alpha"] + assert len(report["resolved_prompt"]) == len(report["prompt_names"]) + assert sorted(report["rows"]) == [" alpha ", "alpha", "alpha "] + # Both raw rows keep their ids, so their category_items stay linked. + assert set(report["pre_ids"].values()) <= set(report["row_ids"].values()) + assert sorted(report["map"]) == [" alpha ", "alpha", "alpha "] + def test_availability_is_not_published_when_init_fails(self, tmp_path): """_available is the only failure signal the agent sees: engine.py drops the return.""" report = _run_init(tmp_path, configured=(), mode="fail-load") From e8f76b98c93e0e58ca347d41c91eb87b5cfe6e72 Mon Sep 17 00:00:00 2001 From: Oranje AI <293843428+oranjeai@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:22:50 +1200 Subject: [PATCH 5/5] Pin the availability move and the restart remap through a full initialize() Two properties the branch publishes had no arm that could observe them. _available moves to the end of _initialize_impl, 273 lines after seeding, because engine.py discards initialize()'s return value so _available is the only failure signal the running agent sees. The one failure arm injected at _ensure_categories, which raises before either the old or the new publication point, so it passed identically at both positions. A new "fail-late" probe mode raises at the interceptor registration instead: that site runs after seeding and after _instrument_llm_timeouts, and is not inside the swallowing try that opens below it, so the raise reaches the outer except and initialize() returns False. The new arm asserts initialize/available/service_available are all False AND that the 10 advertised defaults were already seeded, which is what distinguishes it from the pre-existing arm rather than duplicating it. Verified by moving _available/service_available/initialized_at back to immediately after the _ensure_categories call: the new arm fails while the pre-existing one still passes, and that asymmetry is the proof. The restart property -- a row created at runtime is resolvable again after a restart -- was covered only through a test-local helper that re-implements the name-to-ID rebuild instead of executing it, so no arm observed the production rebuild that actually makes the row addressable. Every full-initialize arm that pre-stored a row also configured the same name, so none exercised a persisted row the config does not advertise. A new full-initialize arm pre-stores "work" with no configured categories and asserts through the real ctx.category_name_to_id: "work" is mapped, keeps its pre-store id so its category_items stay linked, is still absent from the advertised set and the prompt, and does not disturb the seeding of the 10 defaults. On a clean git archive export of origin/main it fails on exactly that map assertion (assert 'work' in {}), because the list_categories() warming the rebuild's cache sits behind the empty-config early return. Two further mutants pin it: deleting the rebuild loop body, and restoring the base shape (the early return plus the preload list_categories the branch removed), which is what shows the arm pins the unconditional load and not merely the rebuild. Test-only. nerve/memory/memu_bridge.py is byte-identical to the previous commit. The inherited 18-mutant matrix was re-run against the amended tree alongside the three new mutants; controls green at both ends. The file-scope FAILED-name set is unchanged (the same 6 pre-existing timezone artifacts), and ruff reports the same two pre-existing findings in the test file. --- tests/test_memu_bridge.py | 66 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/tests/test_memu_bridge.py b/tests/test_memu_bridge.py index dea02cd9..dc17720f 100644 --- a/tests/test_memu_bridge.py +++ b/tests/test_memu_bridge.py @@ -1257,6 +1257,9 @@ async def main(): tmp = Path(sys.argv[1]) configured = json.loads(sys.argv[2]) fail_load = sys.argv[3] == "fail-load" + # "fail-late": a step AFTER seeding raises, so the report shows seeding + # already done while availability must still be withheld. + fail_late = sys.argv[3] == "fail-late" # Rows a PREVIOUS nerve left on disk, written by _PRE_STORE_PROBE in its own # interpreter (memU allows one store per process) and passed in as name->id. pre_ids = json.loads(sys.argv[4]) if len(sys.argv) > 4 else {} @@ -1280,15 +1283,26 @@ async def _boom(): async def _init_without_warmup(): from memu.app.service import MemoryService orig = MemoryService._get_llm_base_client + orig_step = MemoryService.intercept_before_workflow_step def _no_warmup(self, profile=None): raise RuntimeError("LLM warmup disabled: this probe must stay offline") + def _late_boom(self, fn, *, name=None): + raise RuntimeError("late init step exploded") + MemoryService._get_llm_base_client = _no_warmup + if fail_late: + # Interceptor registration (memu_bridge.py:1815) runs AFTER seeding + # (:1610) and after _instrument_llm_timeouts() (:1795), and is not + # inside the swallowing try that opens at :1820, so the raise reaches + # _initialize_impl's outer except and initialize() returns False. + MemoryService.intercept_before_workflow_step = _late_boom try: return await real_init() finally: MemoryService._get_llm_base_client = orig + MemoryService.intercept_before_workflow_step = orig_step bridge._initialize_impl = _init_without_warmup @@ -1495,7 +1509,9 @@ async def test_persisted_row_is_remapped_when_no_categories_configured(self, tmp Only the map half: the advertised set is rebuilt by memU from config at construction, so the LLM is still not told this category exists. That - remaining half is out of scope here. + remaining half is out of scope here. Asserts through the test-local + _rebuild_map; test_a_persisted_unadvertised_row_is_mapped_by_a_full_init + is the arm that exercises the production rebuild. """ store = _memu_store(tmp_path / "memu.sqlite") store.memory_category_repo.get_or_create_category( @@ -2185,10 +2201,56 @@ def test_upgrade_from_two_raw_rows_sharing_a_key_still_resolves(self, tmp_path): assert sorted(report["map"]) == [" alpha ", "alpha", "alpha "] def test_availability_is_not_published_when_init_fails(self, tmp_path): - """_available is the only failure signal the agent sees: engine.py drops the return.""" + """_available is the only failure signal the agent sees: engine.py drops the return. + + Fails inside _ensure_categories, so it passes at either _available position; + the fail-late arm below is what pins the move. + """ report = _run_init(tmp_path, configured=(), mode="fail-load") assert report["offline"] is True assert report["initialize"] is False assert report["available"] is False assert report["service_available"] is False + + def test_availability_is_not_published_when_a_late_step_fails(self, tmp_path): + """This arm, not fail-load, pins _available's position at the END of init. + + Injects at the interceptor registration (memu_bridge.py:1815): after seeding + succeeds and before _available is published, so seeding rows in the report is + what distinguishes it from the fail-load arm. + """ + report = _run_init(tmp_path, configured=(), mode="fail-late") + + assert report["offline"] is True + assert report["initialize"] is False + assert report["available"] is False + assert report["service_available"] is False + # Discriminating: seeding had already SUCCEEDED when the failure hit, so this + # is genuinely a post-seed failure and not another fail-load in disguise. + assert sorted(report["rows"]) == sorted(report["advertised"]) + assert len(report["rows"]) == 10 # memU's defaults, advertised for configured=() + + def test_a_persisted_unadvertised_row_is_mapped_by_a_full_init(self, tmp_path): + """End-to-end restart: a runtime-created row is remapped by the REAL rebuild. + + Unlike the stub-level arm, this drives _initialize_impl's own name-to-ID + rebuild, the half that makes the persisted row addressable again. Unfixed, + the list_categories() warming that rebuild's cache sits behind the empty-config + early return, so the map has no ``work``. + """ + report = _run_init(tmp_path, configured=(), pre_store=["work"]) + + assert report["offline"] is True + assert report["initialize"] is True + assert report["available"] is True + # Through the REAL ctx.category_name_to_id, which is the point of this arm. + assert "work" in report["map"] + # Pinned residual: resolvable, but memU still builds the advertised set from + # config, so the LLM is never told this category exists. + assert "work" not in report["advertised"] + assert "work" not in report["prompt_names"] + # Mapped, not re-created: category_items link on the id. + assert report["row_ids"]["work"] == report["pre_ids"]["work"] + # The extra unadvertised row does not disturb seeding. + assert len(report["resolved_prompt"]) == len(report["prompt_names"])