Files
Hy-Memory/tests/test_graph_edge_idempotency.py
T
2026-10-16 16:26:27 +08:00

246 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""图写入的幂等性,以及重建前的清理。
`add_edge` 此前用 `CREATE`,而同一对节点之间会**反复**建同类型边:
- `UPDATE` 是原地更新(`node_id` 不变),一条记忆被合并多次就多次参与投影;
两条老节点若再次同批出现,`co_occurs` 就重复建。线上实测一条记忆被 UPDATE 了 7 次。
- System 2 的 agent 循环可能对同一对反复调用 `add_edge`。
线上实测(eval 库):**20.4 万对重复、最严重一对 32 份**,其中 99% 是 System 2 写的
无类型 `related`。重复边不放大打分(`build_adjacency` 折叠成最强的一条),但会吃掉
`get_related_edges` 的 `max_edges` 预算 —— Cypher 的 `LIMIT` 先截断、去重在 Python 侧,
于是重复边挤掉了本该返回的真实边。
"""
import asyncio
import tempfile
import pytest
from hy_memory.config import MemoryConfig
from hy_memory.models.memory import MemoryLayer, MemoryNode
def _cfg(path: str) -> MemoryConfig:
cfg = MemoryConfig()
cfg.graph_store.provider = "kuzu"
cfg.graph_store.db_path = path
return cfg
async def _store():
from hy_memory.data.graph_store_kuzu import KuzuGraphStore
gs = KuzuGraphStore(_cfg(tempfile.mkdtemp() + "/g"))
await gs.initialize()
return gs
def _node(content: str, *, session="s1", layer=MemoryLayer.L2_FACT, tags=None):
return MemoryNode(
user_id="u", agent_id="a", session_id=session,
layer=layer, content=content, tags=tags or [],
)
class TestAddEdgeIdempotent:
@pytest.mark.asyncio
async def test_repeated_add_edge_yields_one_edge(self):
gs = await _store()
try:
a, b = _node("m0"), _node("m1")
await gs.upsert_memory_node(a)
await gs.upsert_memory_node(b)
for w in (0.5, 0.9, 0.7):
await gs.add_edge(
a.node_id, b.node_id, "RELATED_TO",
{"relation_type": "co_occurs", "weight": w},
)
rows = await gs._run(
"MATCH (x:Memory)-[r:RELATED_TO]->(y:Memory) RETURN count(*);"
)
# 双向各一条 = 2;用 CREATE 时会是 6
assert int(rows[0][0]) == 2
finally:
await gs.close()
@pytest.mark.asyncio
async def test_on_match_keeps_max_weight(self):
"""ON MATCH 取较大权重 —— 与读侧 build_adjacency"折叠成最强"的语义一致。"""
gs = await _store()
try:
a, b = _node("m0"), _node("m1")
await gs.upsert_memory_node(a)
await gs.upsert_memory_node(b)
for w in (0.5, 0.9, 0.7):
await gs.add_edge(
a.node_id, b.node_id, "RELATED_TO",
{"relation_type": "co_occurs", "weight": w},
)
edges = await gs.get_related_edges([a.node_id])
co = [e for e in edges if e["relation_type"] == "co_occurs"]
assert len(co) == 1
assert co[0]["weight"] == pytest.approx(0.9)
finally:
await gs.close()
@pytest.mark.asyncio
async def test_different_relation_types_coexist(self):
"""同一对节点之间**可以**有多种关系 —— 去重不能把它们合并掉。"""
gs = await _store()
try:
a, b = _node("m0"), _node("m1")
await gs.upsert_memory_node(a)
await gs.upsert_memory_node(b)
for rt in ("co_occurs", "causes", "elaborates"):
await gs.add_edge(
a.node_id, b.node_id, "RELATED_TO",
{"relation_type": rt, "weight": 0.8},
)
edges = await gs.get_related_edges([a.node_id])
assert {e["relation_type"] for e in edges} == {
"co_occurs", "causes", "elaborates",
}
finally:
await gs.close()
@pytest.mark.asyncio
async def test_reprojecting_same_batch_is_stable(self):
"""回归线上现场:同一批节点被投影两次(UPDATE 让老节点再次入批)。"""
from hy_memory.pipelines._graph.projector import project_memories_to_graph
gs = await _store()
try:
nodes = [_node(f"m{i}", tags=["health"]) for i in range(3)]
for _ in range(2):
await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=nodes,
)
rows = await gs._run(
"MATCH (x:Memory)-[r:RELATED_TO]->(y:Memory) RETURN count(*);"
)
# 3 个节点两两相连 = 3 对,双向 = 6 条。投影两次仍应是 6。
assert int(rows[0][0]) == 6
finally:
await gs.close()
class TestClearProjection:
@pytest.mark.asyncio
async def test_clears_l2_and_orphan_topics(self):
gs = await _store()
try:
for i in range(3):
n = _node(f"m{i}", session=f"s{i}", tags=["health"])
await gs.upsert_memory_node(n)
# 故意用旧的(带 session 的)键建 Topic,模拟碎片化历史数据
await gs.add_topic_tag(n.node_id, "health", n.get_isolation_key())
res = await gs.clear_projection("u", "a", layer="l2_fact")
assert res["memories"] == 3
assert res["topics"] == 3
rows = await gs._run("MATCH (t:Topic) RETURN count(*);")
assert int(rows[0][0]) == 0
finally:
await gs.close()
@pytest.mark.asyncio
async def test_does_not_touch_l6_schema(self):
"""关键护栏:一个用户的 Memory 里还有 System 2 写的 l6_schema,
无条件删会毁掉 ultra 的数据。"""
gs = await _store()
try:
await gs.upsert_memory_node(_node("fact"))
await gs.upsert_memory_node(_node("schema", layer=MemoryLayer.L6_SCHEMA))
await gs.clear_projection("u", "a", layer="l2_fact")
rows = await gs._run(
"MATCH (m:Memory {layer:'l6_schema'}) RETURN count(*);"
)
assert int(rows[0][0]) == 1
rows = await gs._run("MATCH (m:Memory) RETURN count(*);")
assert int(rows[0][0]) == 1, "只该剩 l6_schema 那一个"
finally:
await gs.close()
@pytest.mark.asyncio
async def test_other_users_untouched(self):
gs = await _store()
try:
await gs.upsert_memory_node(_node("mine"))
other = MemoryNode(
user_id="u2", agent_id="a", session_id="s1",
layer=MemoryLayer.L2_FACT, content="theirs",
)
await gs.upsert_memory_node(other)
await gs.clear_projection("u", "a", layer="l2_fact")
rows = await gs._run(
"MATCH (m:Memory {user_id:'u2'}) RETURN count(*);"
)
assert int(rows[0][0]) == 1
finally:
await gs.close()
def test_base_default_is_noop(self):
"""未实现该能力的后端不做任何事,不能抛。"""
import inspect
from hy_memory.data.graph_store_base import GraphStoreBase
src = inspect.getsource(GraphStoreBase.clear_projection)
assert "l2_fact" in src and "l6_schema" in src
def test_all_backends_implement_it(self):
from hy_memory.data.graph_store_base import GraphStoreBase
from hy_memory.data.graph_store_kuzu import KuzuGraphStore
from hy_memory.data.graph_store_neo4j import Neo4jGraphStore
for cls in (KuzuGraphStore, Neo4jGraphStore):
assert cls.clear_projection is not GraphStoreBase.clear_projection, (
f"{cls.__name__} 未自己实现 clear_projection,会静默不清理"
)
class TestNoCreateLeftInAddEdge:
"""静态守卫:退回 CREATE 就会重新引入重复边。"""
@pytest.mark.parametrize("module_path", [
"hy_memory.data.graph_store_kuzu",
"hy_memory.data.graph_store_neo4j",
])
def test_add_edge_uses_merge(self, module_path):
import importlib
import inspect
mod = importlib.import_module(module_path)
cls = next(
v for k, v in vars(mod).items()
if isinstance(v, type) and hasattr(v, "add_edge")
and v.__module__ == module_path
)
src = inspect.getsource(cls.add_edge)
assert "MERGE" in src, f"{module_path}.add_edge 没用 MERGE"
assert "CREATE (a)-[:RELATED_TO" not in src, (
f"{module_path}.add_edge 仍在用 CREATE 建边"
)
assert "ON MATCH" in src, "缺 ON MATCH,重复调用不会更新权重"
class TestBuildMemoryGraphClearFirst:
def test_client_exposes_clear_first(self):
import inspect
from hy_memory.client import HyMemoryClient
for fn in ("build_memory_graph", "async_build_memory_graph"):
params = inspect.signature(getattr(HyMemoryClient, fn)).parameters
assert "clear_first" in params, f"{fn} 缺 clear_first"
assert params["clear_first"].default is False, "默认必须为 False"
def test_clear_scoped_to_l2_fact(self):
import inspect
from hy_memory.client import HyMemoryClient
src = inspect.getsource(HyMemoryClient.async_build_memory_graph)
assert "clear_projection(" in src
assert "L2_FACT" in src, "清理必须按 layer 收窄,否则会删掉 l6_schema"