Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions nerve/gateway/routes/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,13 @@ def _read_memu_snapshot_sync(db_path: str) -> str:
"created_at": row["created_at"],
})

# Category-item links
# Category-item links. The link table has no foreign key, so join the
# item table to skip relations whose item is gone.
cat_items: dict[str, list[str]] = {}
for row in db.execute("SELECT category_id, item_id FROM memu_category_items"):
for row in db.execute(
"SELECT ci.category_id, ci.item_id FROM memu_category_items ci "
"JOIN memu_memory_items i ON i.id = ci.item_id"
):
cat_items.setdefault(row["category_id"], []).append(row["item_id"])
finally:
db.close()
Expand Down
6 changes: 5 additions & 1 deletion nerve/memory/memu_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -2887,8 +2887,12 @@ def _run() -> dict[str, Any]:
).fetchone()
if not crow:
return {"name": None, "total": 0, "items": []}
# Same join as the listing below: the link table has no foreign
# key, so relations to deleted items must not be counted.
total = db.execute(
"SELECT count(*) FROM memu_category_items WHERE category_id = ?",
"SELECT count(*) FROM memu_category_items ci "
"JOIN memu_memory_items i ON i.id = ci.item_id "
"WHERE ci.category_id = ?",
(cat_id,),
).fetchone()[0]
sql = (
Expand Down
68 changes: 67 additions & 1 deletion tests/test_recall_breadcrumbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import json
import sqlite3
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
Expand All @@ -21,6 +22,7 @@
)
from nerve.agent.tools.registry import ToolContext
from nerve.config import MemoryConfig, NerveConfig
from nerve.gateway.routes.memory import _read_memu_snapshot_sync
from nerve.memory.memu_bridge import MemUBridge, _category_breadcrumb


Expand Down Expand Up @@ -157,19 +159,26 @@ async def test_recall_caps_items_and_categories(tmp_path) -> None:


def _create_category_schema(db_path: str) -> None:
# Covers both consumers: the bridge reads items/categories/links, the gateway
# snapshot additionally reads resource_id, happened_at and memu_resources.
db = sqlite3.connect(db_path)
db.executescript(
"""
CREATE TABLE memu_memory_items (
id TEXT PRIMARY KEY, memory_type TEXT, summary TEXT,
created_at TEXT, updated_at TEXT
created_at TEXT, updated_at TEXT,
resource_id TEXT, happened_at TEXT
);
CREATE TABLE memu_memory_categories (
id TEXT PRIMARY KEY, name TEXT, description TEXT, summary TEXT
);
CREATE TABLE memu_category_items (
id TEXT PRIMARY KEY, item_id TEXT, category_id TEXT
);
CREATE TABLE memu_resources (
id TEXT PRIMARY KEY, url TEXT, modality TEXT, caption TEXT,
created_at TEXT
);
"""
)
db.commit()
Expand Down Expand Up @@ -201,6 +210,24 @@ def _seed_category(db_path: str) -> None:
db.close()


def _seed_dangling_relations(db_path: str) -> list[str]:
"""Add membership rows whose item_id has no memu_memory_items row.

The link table has no foreign key, so delete-side writers strand rows here.
Returns the dead item ids.
"""
dead = ["it-gone-1", "it-gone-2"]
db = sqlite3.connect(db_path)
for iid in dead:
db.execute(
"INSERT INTO memu_category_items (id, item_id, category_id) VALUES (?,?,?)",
(f"link-{iid}", iid, "cat-pref"),
)
db.commit()
db.close()
return dead


@pytest.mark.asyncio
async def test_expand_category_returns_recent_items(tmp_path) -> None:
config = _make_config(tmp_path)
Expand Down Expand Up @@ -230,6 +257,45 @@ async def test_expand_category_keyword_filter(tmp_path) -> None:
assert [i["id"] for i in result["items"]] == ["it-3"]


@pytest.mark.asyncio
async def test_expand_category_total_excludes_dangling_relations(tmp_path) -> None:
"""total must equal what the store can actually return, not raw link rows."""
config = _make_config(tmp_path)
db_path = config.memory.sqlite_dsn.replace("sqlite:///", "")
_create_category_schema(db_path)
_seed_category(db_path)
_seed_dangling_relations(db_path)

bridge = _stub_bridge(config)
result = await bridge.expand_category("cat:cat-pref", limit=10)

# 5 link rows exist, only 3 resolve to a live item.
assert result["total"] == 3
# The invariant: unpaged, the advertised total is exactly what is listed.
assert result["total"] == len(result["items"])


@pytest.mark.asyncio
async def test_memu_snapshot_category_items_excludes_dangling(tmp_path) -> None:
"""The gateway snapshot must not export links to deleted items."""
config = _make_config(tmp_path)
db_path = config.memory.sqlite_dsn.replace("sqlite:///", "")
_create_category_schema(db_path)
_seed_category(db_path)
dead = _seed_dangling_relations(db_path)

payload = json.loads(_read_memu_snapshot_sync(db_path))
ids = payload["category_items"]["cat-pref"]

assert len(ids) == 3
for dead_id in dead:
assert dead_id not in ids
# Cross-site agreement: the web badge and the agent's total must match.
bridge = _stub_bridge(config)
result = await bridge.expand_category("cat-pref", limit=10)
assert len(ids) == result["total"]


@pytest.mark.asyncio
async def test_expand_category_unknown_id(tmp_path) -> None:
config = _make_config(tmp_path)
Expand Down