diff --git a/nerve/memory/memu_bridge.py b/nerve/memory/memu_bridge.py index 9642175b..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. @@ -1564,10 +1592,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 +1819,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 + # 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 @@ -1851,6 +1880,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 +1895,126 @@ 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. + 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 _memu_cat_name(c.name).lower() not in existing + ] + await self._seed_categories(missing, repairs) + + 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. + + 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. + """ + repairs = list(repairs or ()) + if not self._service or (not cat_cfgs and not repairs): + return - for cat_cfg in self.config.memory.categories: - if cat_cfg.name in existing: + # 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: + 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(plan, embeddings, strict=True)) + + repo = self._service.database.memory_category_repo + 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 - 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) + # 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=name, + description=description, + embedding=embedding, + user_data={}, + ) + # Audit the STORED name, so target_id identifies what was written. + logger.info("Seeded category: %s", name) + await self._audit( + "category_created", "category", name, "bridge", + {"description": 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..dc17720f 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,1122 @@ 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: + # 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 + + 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, 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]) + 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 {} + 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 + # _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 + 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 + + ok = await bridge.initialize() + out = {"initialize": ok, "available": bridge._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() + advertised = [c.name for c in svc.category_configs] + out["advertised"] = advertised + 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["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()) +""" + + +_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(pre_ids)], + 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. + + 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) + + 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. 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( + 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): + """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() + + 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() == {} + + @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() == {} + + @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).""" + + 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 + 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["offline"] is True + 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_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. + + 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"])