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

1626 lines
64 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.
"""记忆关系图(pro 模式)测试。
覆盖三层:
1. 读侧数学:用法 A 传播 / 用法 B 阈值松弛 / 用法 C 路径(纯函数,无 I/O)
2. 写侧建边:projector 的四种边来源 + 同步契约
3. Kuzu 端到端:投影 → get_related_edges → Topic 归一化
设计文档:docs/pro_graph_design.md
"""
from typing import Any, Dict
import pytest
from unittest.mock import AsyncMock
from hy_memory.pipelines._graph.rerank import (
TYPE_PRIOR,
build_adjacency,
collect_directed_edges,
edge_prior,
find_relation_paths,
propagate_support,
relax_admission,
)
from hy_memory.pipelines._graph.rerank import (
apply_temporal_affinity,
apply_type_quota,
edge_group,
normalize_by_degree,
reachable_within,
)
from hy_memory.pipelines._graph.topic_summary import (
SUMMARY_KIND_TOPIC,
covered_count_of,
maybe_summarize_topics,
)
from hy_memory.pipelines._graph.projector import (
LLM_RELATION_TYPES,
RELATION_CO_OCCURS,
RELATION_SEMANTIC_NEIGHBOR,
project_memories_to_graph,
sync_graph_on_shadow,
sync_graph_on_supersede,
)
from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus
def _edge(src, tgt, rtype="causes", weight=1.0, status="active", directed=False):
return {
"source_id": src, "target_id": tgt, "relation_type": rtype,
"weight": weight, "target_status": status, "target_layer": "l2_fact",
"directed": directed,
}
# ================================================================
# 邻接表 / 类型先验
# ================================================================
class TestAdjacency:
def test_edges_are_undirected(self):
adj = build_adjacency([_edge("a", "b")])
assert [n for n, _t, _w in adj["a"]] == ["b"]
assert [n for n, _t, _w in adj["b"]] == ["a"]
def test_effective_weight_is_weight_times_prior(self):
adj = build_adjacency([_edge("a", "b", "causes", weight=0.5)])
_nb, _rt, eff = adj["a"][0]
assert eff == pytest.approx(0.5 * TYPE_PRIOR["causes"])
def test_shadow_targets_are_dropped(self):
"""被判重逻辑删除的记忆不能从图里绕回来。"""
adj = build_adjacency([_edge("a", "b", status="shadow")])
assert adj == {}
def test_self_loops_and_incomplete_edges_ignored(self):
assert build_adjacency([_edge("a", "a")]) == {}
assert build_adjacency([{"source_id": "a"}]) == {}
def test_parallel_edges_collapse_to_strongest(self):
"""同一对节点的多种关系折叠成一条,取先验最高的那种。
度数要数"不同邻居"而不是"平行边数",否则 hub 降权会被平行边稀释、
支持度会重复计。
"""
adj = build_adjacency([
_edge("a", "b", "semantic_neighbor", weight=1.0),
_edge("a", "b", "causes", weight=1.0),
])
assert len(adj["a"]) == 1
nb, rtype, eff = adj["a"][0]
assert nb == "b" and rtype == "causes"
assert eff == pytest.approx(TYPE_PRIOR["causes"])
def test_unknown_relation_gets_default_prior(self):
assert edge_prior("no_such_relation") == 0.5
assert edge_prior(None) == 0.5
def test_semantic_neighbor_prior_is_lowest(self):
"""semantic_neighbor 与向量检索高度冗余,先验必须最低。"""
assert TYPE_PRIOR["semantic_neighbor"] < TYPE_PRIOR["co_occurs"]
assert TYPE_PRIOR["co_occurs"] < TYPE_PRIOR["causes"]
# ================================================================
# 用法 A:池内支持度传播
# ================================================================
class TestPropagateSupport:
def test_linked_pair_both_rise_above_unlinked(self):
"""文档 §3.1 的例子:互相佐证的一对应该浮到无边候选之上。"""
scores = {"m2": 0.55, "m7": 0.53, "m3": 0.52, "m8": 0.51}
adj = build_adjacency([_edge("m2", "m3", "causes")])
out, support = propagate_support(scores, adj, beta=0.15)
assert out["m2"] > out["m3"] > out["m7"] > out["m8"]
assert out["m7"] == 0.53 and out["m8"] == 0.51 # 无边支持 → 分数不变
assert support["m2"] > 0 and support["m7"] == 0
def test_does_not_introduce_new_candidates(self):
"""A 的核心保证:召回集合不变,recall 恒定。"""
scores = {"a": 0.6, "b": 0.5}
adj = build_adjacency([_edge("a", "outside")])
out, _ = propagate_support(scores, adj, beta=0.15)
assert set(out) == {"a", "b"}
def test_only_propagates_within_pool(self):
"""池外的强节点不该给池内节点加分。"""
adj = build_adjacency([_edge("a", "outside", weight=1.0)])
out, support = propagate_support({"a": 0.5, "b": 0.4}, adj, beta=0.15)
assert out["a"] == 0.5
assert support["a"] == 0.0
def test_hub_penalty_via_degree_normalization(self):
"""度数归一化:高度数节点单条边的贡献被摊薄(tag hub 问题的解药)。"""
scores = {"hub": 0.5, "x1": 0.5, "x2": 0.5, "x3": 0.5, "x4": 0.5,
"lo": 0.5, "y1": 0.5}
hub_adj = build_adjacency([
_edge("hub", f"x{i}", "co_occurs") for i in range(1, 5)
])
lo_adj = build_adjacency([_edge("lo", "y1", "co_occurs")])
_o1, sup_hub = propagate_support(scores, hub_adj, beta=0.15)
_o2, sup_lo = propagate_support(scores, lo_adj, beta=0.15)
# 4 条边 / sqrt(4) = 2× 单边;而不是 4× —— 归一化生效
assert sup_hub["hub"] == pytest.approx(sup_lo["lo"] * 2.0)
def test_beta_zero_is_noop(self):
scores = {"a": 0.6, "b": 0.5}
adj = build_adjacency([_edge("a", "b")])
out, _ = propagate_support(scores, adj, beta=0.0)
assert out == scores
def test_support_cap_bounds_the_nudge(self):
scores = {"a": 0.5, "b": 1.0, "c": 1.0, "d": 1.0}
adj = build_adjacency([
_edge("a", "b"), _edge("a", "c"), _edge("a", "d"),
])
capped, _ = propagate_support(scores, adj, beta=0.2, support_cap=0.1)
assert capped["a"] == pytest.approx(0.5 + 0.2 * 0.1)
def test_empty_inputs(self):
assert propagate_support({}, {}, beta=0.1) == ({}, {})
# ================================================================
# 用法 B:阈值松弛
# ================================================================
class TestRelaxAdmission:
def test_hard_gate_admits_near_seed_sim(self):
"""硬闸:sim ≥ seed_sim − δ。seed=0.72 → 门槛 0.52。"""
adj = build_adjacency([_edge("knee", "running", "causes", weight=1.0)])
out = relax_admission(
seed_scores={"knee": 0.72},
adjacency=adj,
candidate_sims={"running": 0.55},
min_score=0.4, gamma=0.3, seed_floor=0.6, seed_sim_delta=0.2,
)
assert len(out) == 1
assert out[0]["node_id"] == "running"
assert out[0]["threshold"] == pytest.approx(0.52)
assert out[0]["sim"] == 0.55
def test_hard_gate_rejects_far_below_seed(self):
"""旧 gamma 路径会捞回 0.35;硬闸 δ=0.2 时相对 seed=0.72 进不来。"""
adj = build_adjacency([_edge("knee", "running", "causes", weight=1.0)])
out = relax_admission(
seed_scores={"knee": 0.72},
adjacency=adj,
candidate_sims={"running": 0.35},
min_score=0.4, gamma=0.3, seed_floor=0.6, seed_sim_delta=0.2,
)
assert out == []
def test_hard_gate_rejects_junk(self):
adj = build_adjacency([_edge("seed", "junk", "causes", weight=1.0)])
out = relax_admission(
seed_scores={"seed": 1.0}, adjacency=adj,
candidate_sims={"junk": 0.10},
min_score=0.4, gamma=0.3, seed_floor=0.6, seed_sim_delta=0.2,
)
assert out == []
def test_weak_seed_does_not_expand(self):
adj = build_adjacency([_edge("weak", "cand", "causes")])
out = relax_admission(
seed_scores={"weak": 0.3}, adjacency=adj,
candidate_sims={"cand": 0.39},
min_score=0.4, gamma=0.3, seed_floor=0.6, seed_sim_delta=0.2,
)
assert out == []
def test_pool_members_are_not_readmitted(self):
adj = build_adjacency([_edge("a", "b", "causes")])
out = relax_admission(
seed_scores={"a": 0.9, "b": 0.5}, adjacency=adj,
candidate_sims={"b": 0.5}, min_score=0.4, gamma=0.3,
seed_sim_delta=0.2,
)
assert out == []
def test_gamma_zero_disables_use_b(self):
adj = build_adjacency([_edge("a", "c", "causes")])
assert relax_admission(
seed_scores={"a": 0.9}, adjacency=adj,
candidate_sims={"c": 0.75}, min_score=0.4, gamma=0.0,
seed_sim_delta=0.2,
) == []
def test_max_admit_caps_output(self):
adj = build_adjacency([
_edge("seed", f"c{i}", "causes") for i in range(10)
])
out = relax_admission(
seed_scores={"seed": 1.0}, adjacency=adj,
candidate_sims={f"c{i}": 0.85 for i in range(10)},
min_score=0.4, gamma=0.3, max_admit=3, seed_sim_delta=0.2,
)
assert len(out) == 3
def test_strongest_supporting_seed_wins(self):
adj = build_adjacency([
_edge("s1", "c", "semantic_neighbor", weight=1.0),
_edge("s2", "c", "causes", weight=1.0),
])
out = relax_admission(
seed_scores={"s1": 0.9, "s2": 0.9}, adjacency=adj,
candidate_sims={"c": 0.75}, min_score=0.4, gamma=0.3,
seed_sim_delta=0.2,
)
assert out[0]["relation_type"] == "causes" # 先验更高
def test_channel_score_prefers_tag_over_co_occur(self):
adj = build_adjacency([_edge("s", "c", "co_occurs")])
out = relax_admission(
seed_scores={"s": 0.8}, adjacency=adj,
candidate_sims={"c": 0.65},
gamma=0.3, seed_floor=0.5, seed_sim_delta=0.2,
candidate_bm25={"c": 0.0},
candidate_tag={"c": 1.0},
candidate_temporal={"c": 1.0},
channel_weights={
"sem": 1.0, "bm25": 0.3, "tag": 0.5,
"temporal": 0.2, "co_occur": 0.15, "struct": 0.25,
},
)
assert len(out) == 1
# sem*0.65 + tag*0.5*1 + temporal*0.2 + co_occur*0.15*support
assert out[0]["score"] > out[0]["sim"]
assert out[0]["tag_overlap"] == 1.0
class TestMergeExpansionsIntoSeeds:
def test_merges_by_time_with_blank_line(self):
from datetime import datetime, timezone
from hy_memory.pipelines._graph.rerank import merge_expansions_into_seeds
from hy_memory.models.memory import MemoryNode, MemoryLayer
def _node(nid, text, ts):
return MemoryNode(
node_id=nid, user_id="u", agent_id="a",
layer=MemoryLayer.L2_FACT, content=text,
memory_at=datetime.fromtimestamp(ts, tz=timezone.utc),
)
seed = {
"node_id": "s1", "score": 0.8,
"node": _node("s1", "seed later", 200),
}
exp = {
"node_id": "e1", "score": 0.7, "graph_via": "s1",
"node": _node("e1", "exp earlier", 100),
}
out = merge_expansions_into_seeds([seed], [exp])
assert len(out) == 1
assert out[0]["content"] == "exp earlier\n\nseed later"
assert out[0]["graph_merged_ids"] == ["e1"]
assert out[0]["score"] == 0.8
# ================================================================
# 用法 C:关系路径
# ================================================================
class TestFindRelationPaths:
def test_one_hop_path_between_two_anchors(self):
adj = build_adjacency([_edge("a", "b", "causes")])
paths = find_relation_paths(
hit_scores={"a": 0.8, "b": 0.7}, adjacency=adj, high_floor=0.6,
)
assert len(paths) == 1
assert paths[0]["hops"] == 1
assert set(paths[0]["nodes"]) == {"a", "b"}
def test_two_hop_path_carries_low_scoring_middle_node(self):
"""中间节点自己分数很低也要带上 —— 它是路径的一部分。"""
adj = build_adjacency([
_edge("ramen", "dana_rec", "elaborates"),
_edge("dana_rec", "dana_chef", "co_occurs"),
])
paths = find_relation_paths(
hit_scores={"ramen": 0.85, "dana_chef": 0.65}, adjacency=adj,
high_floor=0.6,
)
assert len(paths) == 1
assert paths[0]["hops"] == 2
assert paths[0]["nodes"][1] == "dana_rec" # 中间节点不在 hit_scores 里
def test_low_scoring_anchors_are_skipped(self):
adj = build_adjacency([_edge("a", "b", "causes")])
assert find_relation_paths(
hit_scores={"a": 0.5, "b": 0.4}, adjacency=adj, high_floor=0.6,
) == []
def test_semantic_neighbor_excluded_by_default(self):
"""两条语义相近的记忆之间连一条路径对答案没有解释价值。"""
adj = build_adjacency([_edge("a", "b", "semantic_neighbor")])
assert find_relation_paths(
hit_scores={"a": 0.8, "b": 0.8}, adjacency=adj,
) == []
assert len(find_relation_paths(
hit_scores={"a": 0.8, "b": 0.8}, adjacency=adj, typed_only=False,
)) == 1
def test_needs_two_anchors(self):
adj = build_adjacency([_edge("a", "b", "causes")])
assert find_relation_paths(hit_scores={"a": 0.9}, adjacency=adj) == []
def test_duplicate_paths_deduped(self):
adj = build_adjacency([_edge("a", "b", "causes")])
paths = find_relation_paths(hit_scores={"a": 0.9, "b": 0.9}, adjacency=adj)
assert len(paths) == 1
def test_relations_are_dicts_with_type(self):
adj = build_adjacency([_edge("a", "b", "causes")])
paths = find_relation_paths(hit_scores={"a": 0.9, "b": 0.9}, adjacency=adj)
assert paths[0]["relations"][0]["type"] == "causes"
assert "direction" not in paths[0]["relations"][0] # 无向边不标方向
def test_temporal_direction_is_labelled(self):
"""有向边要标方向,否则"X 之后"和"X 之前"在输出里分不开。"""
edges = [_edge("early", "late", "temporal_next", directed=True)]
adj = build_adjacency(edges)
fwd = collect_directed_edges(edges)
paths = find_relation_paths(
hit_scores={"early": 0.9, "late": 0.8}, adjacency=adj, forward_dirs=fwd,
)
# 同一 anchor 对去重后只产出一条路径;方向随路径起点而定
assert len(paths) == 1
nodes, rel = paths[0]["nodes"], paths[0]["relations"][0]
assert rel["type"] == "temporal_next"
expected = "forward" if nodes == ["early", "late"] else "backward"
assert rel["direction"] == expected
class TestCollectDirectedEdges:
def test_only_directed_edges_collected(self):
out = collect_directed_edges([
_edge("a", "b", "causes", directed=False),
_edge("x", "y", "temporal_next", directed=True),
])
assert out == {("x", "y")}
def test_type_prior_ordering(self):
"""先验排序按"能否提供向量检索拿不到的信息"来定。
`co_occurs` 排在很高位:共同写入是**跨话题联系的主要来源**
("去公园/骑车/吃饭"一起写下来,三者向量互不相似但关联真实)。
它的噪声由写侧的批大小归一化 + 读侧的度数归一化控制,而不是靠压低先验 ——
压低先验会连带把"一批只有 2 条"这种几乎必然相关的情况也一起贬值。
"""
p = TYPE_PRIOR
assert p["causes"] > p["same_series"] > p["contrasts"]
assert p["contrasts"] == p["temporal_next"] > p["co_occurs"]
assert p["co_occurs"] > p["semantic_neighbor"]
# ================================================================
# 写侧:projector
# ================================================================
class _FakeGraphStore:
def __init__(self, topic_memories=None, topics=None):
self.nodes = []
self.edges = []
self.tags = []
self.updates = {}
self.temporal = []
self._topic_memories = topic_memories or {}
self._topics = topics or []
async def upsert_memory_node(self, node):
self.nodes.append(node)
return node.node_id
async def add_topic_tag(self, node_id, topic, isolation_key, embed_service=None,
similarity_threshold=0.85):
self.tags.append((node_id, topic, isolation_key, embed_service is not None))
return f"topic_{topic}"
async def add_edge(self, src, tgt, edge_type, properties=None):
self.edges.append((src, tgt, (properties or {}).get("relation_type"),
(properties or {}).get("weight")))
return True
async def update_node(self, node_id, updates):
self.updates[node_id] = updates
return True
async def add_temporal_edge(self, prev_id, next_id, topic_id=""):
self.temporal.append((prev_id, next_id, topic_id))
return True
async def get_topic_memories(self, topic_id, *, limit=200):
return self._topic_memories.get(topic_id, [])
async def get_all_topics(self, isolation_key):
return self._topics
def _node(nid, content="c", tags=None):
return MemoryNode(
node_id=nid, user_id="u1", agent_id="a1", session_id="s1",
layer=MemoryLayer.L2_FACT, content=content, tags=list(tags or []),
)
@pytest.mark.asyncio
class TestProjector:
async def test_native_graph_batches_cover_nodes_entities_and_edges(
self, monkeypatch,
):
from datetime import datetime
import hy_memory.pipelines._retrieval.ner as ner
monkeypatch.setattr(
ner, "extract_entities",
lambda _text, for_boost=True: [
("Paris", "GPE"), ("Paris", "GPE"), ("OpenAI", "ORG"),
],
)
class BatchGraph(_FakeGraphStore):
_supports_batch_memory_nodes = True
_supports_batch_entity_mentions = True
_supports_batch_related_edges = True
_supports_batch_temporal_edges = True
def __init__(self):
super().__init__()
self.memory_batches = []
self.entity_batches = []
self.edge_batches = []
self.temporal_batches = []
async def upsert_memory_node(self, _node):
raise AssertionError("per-node path must not be used")
async def upsert_memory_nodes_batch(self, nodes):
self.memory_batches.append(list(nodes))
return [node.node_id for node in nodes]
async def clear_entity_mentions(self, _node_id):
raise AssertionError("per-memory entity clear must not be used")
async def add_entity_mention(self, *_args, **_kwargs):
raise AssertionError("per-entity path must not be used")
async def replace_entity_mentions_batch(self, assignments):
self.entity_batches.append(list(assignments))
return [
(memory_id, f"entity-{name}-{label}")
for memory_id, entities, _scope in assignments
for name, label in entities
]
async def add_edge(self, *_args, **_kwargs):
raise AssertionError("per-edge path must not be used")
async def add_edges_batch(self, edges):
self.edge_batches.append(list(edges))
return [True] * len(edges)
async def add_temporal_edge(self, *_args, **_kwargs):
raise AssertionError("per-temporal-edge path must not be used")
async def add_temporal_edges_batch(self, edges):
self.temporal_batches.append(list(edges))
return [True] * len(edges)
n1 = _node("n1", "first", tags=["work"])
n2 = _node("n2", "second", tags=["work"])
n1.memory_at = datetime(2026, 1, 1)
n2.memory_at = datetime(2026, 2, 1)
gs = BatchGraph()
stats = await project_memories_to_graph(
graph_store=gs,
embed_service=None,
nodes=[n1, n2],
neighbors_by_node={"n1": [("old-sem", 0.8)]},
related_by_node={
"n2": [{"target_id": "old-cause", "relation_type": "causes"}],
},
temporal_spine_bulk=True,
)
assert len(gs.memory_batches) == 1
assert len(gs.entity_batches) == 1
assert len(gs.edge_batches) == 1
assert len(gs.edge_batches[0]) == 3
assert gs.temporal_batches == [[("n1", "n2", "topic_work")]]
assert stats == {
"nodes": 2, "tags": 2, "entities": 4,
"co_occurs": 1, "semantic": 1, "typed": 1, "temporal": 1,
"errors": 0, "hierarchy_paths": 0, "hierarchy_nodes": 0,
}
async def test_batch_failure_falls_back_to_per_item(self):
class FailingBatchGraph(_FakeGraphStore):
_supports_batch_memory_nodes = True
_supports_batch_related_edges = True
async def upsert_memory_nodes_batch(self, _nodes):
raise RuntimeError("transient node batch failure")
async def add_edges_batch(self, _edges):
raise RuntimeError("transient edge batch failure")
gs = FailingBatchGraph()
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None,
nodes=[_node("n1"), _node("n2")],
)
assert [node.node_id for node in gs.nodes] == ["n1", "n2"]
assert len(gs.edges) == 1
assert stats["nodes"] == 2
assert stats["co_occurs"] == 1
assert stats["errors"] == 0
async def test_batch_topic_capability_avoids_per_tag_calls(self):
class BatchGraph(_FakeGraphStore):
_supports_batch_topic_tags = True
def __init__(self):
super().__init__()
self.batch_assignments = []
async def add_topic_tag(self, *args, **kwargs):
raise AssertionError("per-tag path must not be used")
async def add_topic_tags_batch(self, assignments, **kwargs):
self.batch_assignments = list(assignments)
return [
(memory_id, f"topic-{tag}")
for memory_id, tag, _scope in assignments
]
gs = BatchGraph()
node = _node("n1", tags=["work", "travel"])
stats = await project_memories_to_graph(
graph_store=gs,
embed_service=None,
nodes=[node],
)
assert len(gs.batch_assignments) == 2
assert stats["tags"] == 2
async def test_projects_nodes_and_tags(self):
gs = _FakeGraphStore()
stats = await project_memories_to_graph(
graph_store=gs, embed_service=object(),
nodes=[_node("n1", tags=["work", "health"])],
)
assert stats["nodes"] == 1 and stats["tags"] == 2
# embed_service 必须透传下去,否则 Topic 不做语义归一化
assert all(has_embed for *_x, has_embed in gs.tags)
async def test_co_occurs_pairwise_within_batch(self):
gs = _FakeGraphStore()
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None,
nodes=[_node("n1"), _node("n2"), _node("n3")],
)
assert stats["co_occurs"] == 3 # C(3,2)
assert all(rt == RELATION_CO_OCCURS for _s, _t, rt, _w in gs.edges)
async def test_co_occurs_skipped_for_oversized_batch(self):
gs = _FakeGraphStore()
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None,
nodes=[_node(f"n{i}") for i in range(5)],
co_occurs_max_batch=3,
)
assert stats["co_occurs"] == 0
async def test_semantic_neighbor_respects_threshold(self):
gs = _FakeGraphStore()
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=[_node("n1")],
neighbors_by_node={"n1": [("old1", 0.80), ("old2", 0.40)]},
edge_min_score=0.55,
)
assert stats["semantic"] == 1
assert ("n1", "old1", RELATION_SEMANTIC_NEIGHBOR, 0.80) in gs.edges
async def test_typed_relations_whitelisted(self):
gs = _FakeGraphStore()
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=[_node("n1")],
related_by_node={"n1": [
{"target_id": "old1", "relation_type": "causes"},
{"target_id": "old2", "relation_type": "made_up_relation"},
]},
)
assert stats["typed"] == 1
assert ("n1", "old1", "causes", 1.0) in gs.edges
async def test_no_graph_store_is_noop(self):
stats = await project_memories_to_graph(
graph_store=None, embed_service=None, nodes=[_node("n1")],
)
assert stats["nodes"] == 0
async def test_sync_contract(self):
gs = _FakeGraphStore()
assert await sync_graph_on_supersede(gs, ["old1"]) == 1
assert gs.updates["old1"]["status"] == "superseded"
assert await sync_graph_on_shadow(gs, ["dup1"]) == 1
assert gs.updates["dup1"]["status"] == "shadow"
async def test_llm_relation_vocabulary_is_small(self):
"""弱模型 + 大本体 = 垃圾;same_event 已按冗余删除。"""
assert LLM_RELATION_TYPES == (
"causes", "elaborates", "contrasts", "same_series",
)
@staticmethod
def _spine_setup(member_times):
"""图给同 Topic 成员,VDB 给事件时间 —— 图里没有 memory_at。"""
from datetime import datetime
gs = _FakeGraphStore(topic_memories={
"topic_work": [{"node_id": mid, "content": "c"} for mid in member_times]
})
class _VS:
async def get_by_ids(self, ids):
out = []
for mid in ids:
if mid not in member_times:
continue
n = _node(mid)
n.memory_at = datetime.fromtimestamp(member_times[mid])
out.append(n)
return out
return gs, _VS()
async def test_temporal_spine_picks_nearest_earlier_member(self):
from datetime import datetime
target_ts = datetime(2026, 5, 1).timestamp()
gs, vs = self._spine_setup({
"too_old": datetime(2020, 1, 1).timestamp(),
"nearest": datetime(2026, 4, 1).timestamp(), # 应选它
"later": datetime(2026, 9, 1).timestamp(), # 比新节点还晚,不能选
})
node = _node("new1", tags=["work"])
node.memory_at = datetime.fromtimestamp(target_ts)
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=[node],
vector_store=vs, temporal_spine=True,
)
assert stats["temporal"] == 1
assert gs.temporal == [("nearest", "new1", "topic_work")]
async def test_temporal_spine_no_earlier_member(self):
from datetime import datetime
gs, vs = self._spine_setup({
"later": datetime(2026, 9, 1).timestamp(),
})
node = _node("new1", tags=["work"])
node.memory_at = datetime(2026, 5, 1)
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=[node],
vector_store=vs, temporal_spine=True,
)
assert stats["temporal"] == 0 and gs.temporal == []
async def test_temporal_spine_off_by_default(self):
from datetime import datetime
gs, vs = self._spine_setup({"older": datetime(2020, 1, 1).timestamp()})
node = _node("new1", tags=["work"])
node.memory_at = datetime(2026, 5, 1)
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=[node], vector_store=vs,
)
assert stats["temporal"] == 0 and gs.temporal == []
async def test_temporal_spine_needs_topic_and_memory_at(self):
gs, vs = self._spine_setup({"older": 1.0})
# 无 tag → 无 Topic → 无从串脊
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=[_node("n1")],
vector_store=vs, temporal_spine=True,
)
assert stats["temporal"] == 0
async def test_bulk_spine_chains_whole_batch_in_time_order(self):
"""回填用的 bulk 版:整批按 Topic 分组、按事件时间逐对相连。"""
from datetime import datetime
gs = _FakeGraphStore()
nodes = []
# 故意乱序传入,验证是按 memory_at 排序而不是按入参顺序
for nid, month in (("b", 5), ("c", 9), ("a", 1)):
n = _node(nid, tags=["work"])
n.memory_at = datetime(2026, month, 1)
nodes.append(n)
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=nodes,
temporal_spine_bulk=True,
)
assert stats["temporal"] == 2
assert gs.temporal == [
("a", "b", "topic_work"), ("b", "c", "topic_work"),
]
async def test_incremental_spine_would_build_nothing_for_backfill(self):
"""回填若误用增量版,会因"排除批内节点"而一条边都建不出来。
这正是 bulk 版存在的理由 —— 两者的前驱来源不同。
"""
from datetime import datetime
gs = _FakeGraphStore(topic_memories={"topic_work": []})
nodes = []
for nid, month in (("a", 1), ("b", 5)):
n = _node(nid, tags=["work"])
n.memory_at = datetime(2026, month, 1)
nodes.append(n)
class _VS:
async def get_by_ids(self, ids):
return []
stats = await project_memories_to_graph(
graph_store=gs, embed_service=None, nodes=nodes,
vector_store=_VS(), temporal_spine=True,
)
assert stats["temporal"] == 0 and gs.temporal == []
# ================================================================
# Topic-scoped summary
# ================================================================
class _FakeVectorStore:
def __init__(self, l3_nodes=None):
self._l3 = l3_nodes or []
self.upserted = []
self.payload_updates = {}
async def list_by_user(self, **kw):
return list(self._l3)
async def get_by_ids(self, ids):
wanted = set(ids)
return [node for node in self._l3 if node.node_id in wanted]
async def upsert(self, node):
self.upserted.append(node)
return node.node_id
async def update_payload(self, node_id, updates):
self.payload_updates[node_id] = updates
return True
class _FakeEmbedService:
async def embed_queued(self, text):
return [0.1, 0.2, 0.3]
class _FakeLLM:
def __init__(self, text="The user works as a nurse in Berlin on night shifts."):
self.text = text
self.calls = []
async def complete(self, prompt, max_tokens=500, temperature=0.7, **kw):
self.calls.append(prompt)
class _R:
content = self.text
return _R()
def _l3(node_id, topic_id, covered):
from datetime import datetime
return MemoryNode(
node_id=node_id, user_id="u1", agent_id="a1", session_id="s1",
layer=MemoryLayer.L3_SUMMARY, content="old summary",
gmt_created=datetime(2026, 1, 1),
custom={
"summary_kind": SUMMARY_KIND_TOPIC,
"topic_id": topic_id, "covered_count": covered,
},
)
@pytest.mark.asyncio
class TestTopicSummary:
async def _run(self, *, mems, l3=None, min_memories=5, step=5, llm=None):
gs = _FakeGraphStore(
topic_memories={"t_work": mems},
topics=[{"topic_id": "t_work", "name": "work"}],
)
vs = _FakeVectorStore(l3_nodes=l3 or [])
llm = llm or _FakeLLM()
stats = await maybe_summarize_topics(
graph_store=gs, vector_store=vs, embed_service=_FakeEmbedService(),
llm_provider=llm, user_id="u1", agent_id="a1", session_id="s1",
isolation_key="u1::a1", touched_topic_ids=["t_work"],
min_memories=min_memories, step=step,
)
return stats, vs, llm
@staticmethod
def _mems(n):
return [{"node_id": f"m{i}", "content": f"fact {i}"} for i in range(n)]
async def test_summarizes_when_threshold_reached(self):
stats, vs, llm = await self._run(mems=self._mems(5))
assert stats["summarized"] == 1
node = vs.upserted[0]
assert node.layer == MemoryLayer.L3_SUMMARY
assert node.custom["summary_kind"] == SUMMARY_KIND_TOPIC
assert node.custom["topic_id"] == "t_work"
assert node.custom["covered_count"] == 5
assert len(node.custom["source_memory_ids"]) == 5
assert len(llm.calls) == 1
async def test_skips_below_min_memories(self):
stats, vs, _ = await self._run(mems=self._mems(3))
assert stats["summarized"] == 0 and stats["skipped"] == 1
assert vs.upserted == []
async def test_skips_when_increment_below_step(self):
"""已总结过 5 条,现在 7 条 → 增量 2 < step 5,不重算(省 LLM)。"""
stats, vs, llm = await self._run(
mems=self._mems(7), l3=[_l3("s1", "t_work", 5)],
)
assert stats["summarized"] == 0 and stats["skipped"] == 1
assert llm.calls == []
async def test_resummarizes_and_shadows_previous(self):
prev = _l3("s1", "t_work", 5)
stats, vs, _ = await self._run(mems=self._mems(10), l3=[prev])
assert stats["summarized"] == 1
# 旧摘要置 SHADOW,避免同一 Topic 多条摘要重复召回
assert vs.payload_updates["s1"]["status"] == MemoryStatus.SHADOW.value
assert vs.upserted[0].custom["supersedes_summary_id"] == "s1"
async def test_other_topics_summary_not_confused(self):
"""别的 Topic 的摘要不能被当成本 Topic 的上一版。"""
stats, vs, llm = await self._run(
mems=self._mems(6), l3=[_l3("s_other", "t_food", 100)],
)
assert stats["summarized"] == 1
assert vs.upserted[0].custom["covered_count"] == 6
async def test_strips_code_fence_from_llm_output(self):
stats, vs, _ = await self._run(
mems=self._mems(5), llm=_FakeLLM('```\nThe user is a nurse.\n```'),
)
assert vs.upserted[0].content == "The user is a nurse."
async def test_empty_llm_output_counts_as_error(self):
stats, vs, _ = await self._run(mems=self._mems(5), llm=_FakeLLM(" "))
assert stats["summarized"] == 0 and stats["errors"] == 1
assert vs.upserted == []
async def test_no_llm_provider_is_noop(self):
gs = _FakeGraphStore(topic_memories={"t": self._mems(10)})
stats = await maybe_summarize_topics(
graph_store=gs, vector_store=_FakeVectorStore(),
embed_service=_FakeEmbedService(), llm_provider=None,
user_id="u1", agent_id="a1", session_id="s1",
isolation_key="u1::a1", touched_topic_ids=["t"],
)
assert stats["summarized"] == 0
async def test_covered_count_handles_json_custom(self):
node = _l3("s1", "t", 7)
node.custom = '{"summary_kind": "topic", "topic_id": "t", "covered_count": 7}'
assert covered_count_of(node) == 7
assert covered_count_of(_node("x")) == 0
# ================================================================
# 架构不变量:light search / heavy write
# ================================================================
class TestReadPathHasNoLLM:
"""**检索链路不得有 LLM 调用** —— 架构约定是 light search / heavy write。
推理放在写入侧(reconcile 标 typed 边、Topic 摘要),读侧只做确定性的图遍历
和范围过滤。曾经在 reader 里加过 LLM 驱动的"推理多跳",已移除:
跨话题时序问题改用「派生时间锚点 + 二次纯向量检索」(用法 D),零 LLM。
这条不变量必须由**结构**保证而不是靠开关 —— 靠 flag 守着的话,
一旦全局 env 被打开,所有 search(含延迟敏感的 chat 路径)都会多一次 LLM。
"""
@pytest.mark.parametrize("mod_name", [
"reader", "reader_base", "reader_legacy", "reader_hybrid",
])
def test_readers_do_not_import_or_call_llm(self, mod_name):
import importlib
import inspect
mod = importlib.import_module(f"hy_memory.pipelines.{mod_name}")
src = inspect.getsource(mod)
for banned in ("LLMProvider", "llm_provider", "complete_messages"):
assert banned not in src, (
f"{mod_name} 出现 {banned!r} —— 检索链路不得引入 LLM"
)
def test_graph_read_helpers_are_llm_free(self):
import inspect
import hy_memory.pipelines._graph.rerank as rr
for mod in (rr,):
src = inspect.getsource(mod)
for banned in ("LLMProvider", "llm_provider", ".complete("):
assert banned not in src, f"{mod.__name__} 不得调用 LLM"
def test_multihop_module_is_gone(self):
"""LLM 驱动的推理多跳已从 SDK 移除,不留死代码。"""
with pytest.raises(ModuleNotFoundError):
import hy_memory.pipelines._graph.multihop # noqa: F401
# ================================================================
# 扩展候选的筛选漏斗
# ================================================================
#
# 扩展会带进很多新候选,筛选靠一个漏斗而不是一道关。顺序决定成本:
# 算候选与 query 的相似度是唯一有 I/O 的一步,必须先用免费的图信号砍到几十条。
class TestNormalizeByDegree:
"""度数归一化 —— 时间边能安全参与扩展的前提。"""
def test_high_degree_neighbor_edges_are_devalued(self):
# hub 有 4 个邻居,lonely 只有 1 个
adj = build_adjacency([
_edge("s", "hub", "co_occurs"), _edge("hub", "x1", "co_occurs"),
_edge("hub", "x2", "co_occurs"), _edge("hub", "x3", "co_occurs"),
_edge("s2", "lonely", "co_occurs"),
])
norm = normalize_by_degree(adj)
w_hub = next(w for nb, _t, w in norm["s"] if nb == "hub")
w_lonely = next(w for nb, _t, w in norm["s2"] if nb == "lonely")
assert w_hub < w_lonely, "高连接节点的每条边都该贬值"
def test_empty(self):
assert normalize_by_degree({}) == {}
class TestApplyTemporalAffinity:
"""时间维度:与锚点写入时间越近,联系越可能真实。"""
def test_closer_in_time_keeps_more_support(self):
day = 86400.0
reach = {"near": (1.0, "seed", "co_occurs", 1),
"far": (1.0, "seed", "co_occurs", 1)}
times = {"seed": 0.0, "near": day, "far": 90 * day}
out = apply_temporal_affinity(reach, times=times, tau_days=7.0, floor=0.3)
assert out["near"][0] > out["far"][0]
def test_same_write_point_keeps_full_support(self):
"""同批次写入(Δt≈0)→ 因子 = 1,这正是"公园/骑车/吃饭"那个场景。"""
reach = {"park": (0.8, "meal", "co_occurs", 1)}
out = apply_temporal_affinity(
reach, times={"meal": 1000.0, "park": 1000.0}, tau_days=7.0,
)
assert out["park"][0] == pytest.approx(0.8)
def test_floor_prevents_time_from_erasing_strong_relations(self):
"""时间远但关系强(如 LLM 标的 causes)的边不该被时间抹平。"""
reach = {"c": (1.0, "s", "causes", 1)}
out = apply_temporal_affinity(
reach, times={"s": 0.0, "c": 3650 * 86400.0}, tau_days=7.0, floor=0.3,
)
assert out["c"][0] == pytest.approx(0.3)
def test_missing_times_are_not_penalized(self):
reach = {"c": (0.7, "s", "causes", 1)}
out = apply_temporal_affinity(reach, times={"s": 0.0}, tau_days=7.0)
assert out["c"][0] == pytest.approx(0.7)
class TestApplyTypeQuota:
"""类型配额 —— 防止数量最多、信息量最低的时间边挤掉关系边。"""
def test_relational_survives_flood_of_temporal(self):
reach = {f"t{i}": (0.9, "s", "temporal_next", 1) for i in range(100)}
reach["rel"] = (0.1, "s", "causes", 1) # 分数最低,但类型信息量最高
out = apply_type_quota(reach, top_n=10)
assert "rel" in out, "关系边不该被时间边淹没"
assert len(out) <= 12 # 配额取整可能略超,但量级受控
def test_edge_group_mapping(self):
assert edge_group("causes") == "relational"
assert edge_group("co_occurs") == "relational"
assert edge_group("temporal_next") == "temporal"
assert edge_group("semantic_neighbor") == "semantic"
def test_empty_and_zero_budget(self):
assert apply_type_quota({}, top_n=10) == {}
assert apply_type_quota({"a": (1.0, "s", "causes", 1)}, top_n=0) == {}
class TestMultiSeedCorroboration:
"""多 seed 佐证:两个不同 seed 同时指向同一候选,才是真正的佐证。"""
def test_two_seeds_beat_one(self):
"""query 同时命中「吃饭」「骑车」,两者都指向「公园」→ 公园显著上浮。"""
adj = build_adjacency([
_edge("meal", "park", "co_occurs"),
_edge("bike", "park", "co_occurs"),
_edge("meal", "solo", "co_occurs"),
])
got = reachable_within(
{"meal": 0.8, "bike": 0.8}, adj, hops=1, seed_floor=0.6,
multi_seed_bonus=0.5,
)
assert got["park"][0] > got["solo"][0], "被两个 seed 指向的候选该更高"
def test_bonus_zero_falls_back_to_max_path(self):
adj = build_adjacency([
_edge("s1", "c", "co_occurs"), _edge("s2", "c", "co_occurs"),
])
got = reachable_within(
{"s1": 0.8, "s2": 0.8}, adj, hops=1, seed_floor=0.6,
multi_seed_bonus=0.0,
)
one = reachable_within({"s1": 0.8}, adj, hops=1, seed_floor=0.6)
assert got["c"][0] == pytest.approx(one["c"][0])
class TestExpandScoring:
"""打分:硬闸后多通道 / 或退回 sim + 有界结构加权。"""
def test_structural_boost_without_channels(self):
adj = build_adjacency([_edge("s", "c", "causes")])
out = relax_admission(
seed_scores={"s": 0.9}, adjacency=adj,
candidate_sims={"c": 0.75}, min_score=0.4, gamma=0.3,
boost_beta=0.1, boost_cap=1.0, seed_sim_delta=0.2,
)
assert len(out) == 1
o = out[0]
assert o["sim"] == 0.75
assert o["score"] > o["sim"]
assert o["score"] <= o["sim"] + 0.1
def test_beta_zero_keeps_pure_similarity_without_channels(self):
adj = build_adjacency([_edge("s", "c", "causes")])
out = relax_admission(
seed_scores={"s": 0.9}, adjacency=adj,
candidate_sims={"c": 0.75}, min_score=0.4, gamma=0.3,
boost_beta=0.0, seed_sim_delta=0.2,
)
assert out[0]["score"] == pytest.approx(0.75)
def test_graph_still_cannot_invent_relevance(self):
"""无论结构支持多强,远低于 seed_sim−δ 就进不来。"""
adj = build_adjacency([_edge("s", "junk", "causes")])
out = relax_admission(
seed_scores={"s": 1.0}, adjacency=adj,
candidate_sims={"junk": 0.05}, min_score=0.4, gamma=0.3,
boost_beta=0.5, seed_sim_delta=0.2,
)
assert out == []
class TestReachableWithin:
"""关系型结构多跳:k 跳可达 + 衰减 + 预算。"""
def test_one_hop(self):
adj = build_adjacency([_edge("s", "a", "causes")])
got = reachable_within({"s": 0.9}, adj, hops=1, seed_floor=0.6)
assert set(got) == {"a"} and got["a"][3] == 1
def test_two_hops_reaches_through_middle_node(self):
""""朋友推荐的餐厅"这类问题两端常隔一个中间节点 —— 1 跳到不了。"""
adj = build_adjacency([
_edge("s", "mid", "co_occurs"), _edge("mid", "far", "co_occurs"),
])
one = reachable_within({"s": 0.9}, adj, hops=1, seed_floor=0.6)
two = reachable_within({"s": 0.9}, adj, hops=2, seed_floor=0.6)
assert "far" not in one
assert "far" in two and two["far"][3] == 2
def test_support_decays_with_hops(self):
adj = build_adjacency([
_edge("s", "mid", "co_occurs"), _edge("mid", "far", "co_occurs"),
])
got = reachable_within({"s": 0.9}, adj, hops=2, seed_floor=0.6, hop_decay=0.5)
assert got["far"][0] < got["mid"][0], "越远支持度必须越低"
def test_pool_members_excluded(self):
adj = build_adjacency([_edge("s", "inpool", "causes")])
got = reachable_within({"s": 0.9, "inpool": 0.5}, adj, hops=2, seed_floor=0.6)
assert "inpool" not in got
def test_weak_seeds_do_not_expand(self):
adj = build_adjacency([_edge("s", "a", "causes")])
assert reachable_within({"s": 0.3}, adj, hops=2, seed_floor=0.6) == {}
def test_first_edge_relation_is_reported(self):
adj = build_adjacency([
_edge("s", "mid", "causes"), _edge("mid", "far", "co_occurs"),
])
got = reachable_within({"s": 0.9}, adj, hops=2, seed_floor=0.6)
assert got["far"][2] == "causes", "应报首跳关系,便于解释路径来源"
class TestRelaxAdmissionMultiHop:
def test_two_hop_candidate_can_be_admitted(self):
adj = build_adjacency([
_edge("seed", "mid", "causes"), _edge("mid", "far", "causes"),
])
out = relax_admission(
seed_scores={"seed": 0.9}, adjacency=adj,
candidate_sims={"mid": 0.75, "far": 0.75},
min_score=0.4, gamma=0.3, seed_floor=0.6, hops=2,
seed_sim_delta=0.2,
)
got = {o["node_id"]: o["hop"] for o in out}
assert got.get("mid") == 1
assert "far" in got and got["far"] == 2
def test_hops_one_keeps_old_behavior(self):
adj = build_adjacency([
_edge("seed", "mid", "causes"), _edge("mid", "far", "causes"),
])
out = relax_admission(
seed_scores={"seed": 0.9}, adjacency=adj,
candidate_sims={"mid": 0.75, "far": 0.75},
min_score=0.4, gamma=0.3, seed_floor=0.6, hops=1,
seed_sim_delta=0.2,
)
got = {o["node_id"] for o in out}
assert "mid" in got and "far" not in got
assert [o["node_id"] for o in out] == ["mid"]
# ================================================================
# Cypher 静态守卫(Neo4j 路 CI 里跑不到,只能静态兜)
# ================================================================
class TestNeo4jCypherGuards:
@pytest.mark.asyncio
async def test_native_clone_snapshot_import_batches_and_remaps_scope(self):
from datetime import datetime
from hy_memory.data.graph_store_neo4j import Neo4jGraphStore
gs = Neo4jGraphStore.__new__(Neo4jGraphStore)
source = _node("old", "source", tags=["work"])
source.user_id = "src"
source.session_id = "session"
source.memory_at = datetime(2026, 1, 1)
source._graph_embedding = [1.0, 0.0]
gs._record_to_memory_node = lambda record: source
memory_batches = []
edge_batches = []
temporal_batches = []
entity_batches = []
writes = []
async def memory_batch(nodes):
memory_batches.append(list(nodes))
return [node.node_id for node in nodes]
async def edge_batch(edges):
edge_batches.append(list(edges))
return [True] * len(edges)
async def temporal_batch(edges):
temporal_batches.append(list(edges))
return [True] * len(edges)
async def entity_batch(assignments):
entity_batches.append(list(assignments))
return []
async def capture(query, params=None):
writes.append((query, params or {}))
gs.upsert_memory_nodes_batch = memory_batch
gs.add_edges_batch = edge_batch
gs.add_temporal_edges_batch = temporal_batch
gs.replace_entity_mentions_batch = entity_batch
gs._run_write = capture
gs.add_topic_path = AsyncMock(return_value=[])
snapshot = {
"user_id": "src",
"nodes": [{"m": {"node_id": "old"}, "embedding": [1.0, 0.0], "beh_embedding": None}],
"evidence": [{"source_id": "old", "target_id": "v1", "layer": "l1_raw"}],
"related": [
{"source_id": "old", "target_id": "old", "relation_type": "causes", "weight": 0.7},
],
"temporal": [{"source_id": "old", "target_id": "old", "topic_id": "old-topic"}],
"cross": [{"source_id": "old", "target_id": "old"}],
"tags": [{
"source_id": "old", "topic_id": "old-topic", "name": "work",
"embedding": [0.5, 0.5], "isolation_key": "src::a",
}],
"entities": [{
"source_id": "old", "name": "Paris", "label": "GPE",
"isolation_key": "src::a",
}],
}
id_map = {"old": "new"}
count = await gs.import_clone_snapshot(
snapshot, dst_user_id="dst", id_map=id_map, embed_service=None,
)
assert count == 1
assert memory_batches[0][0].node_id == "new"
assert memory_batches[0][0].user_id == "dst"
assert memory_batches[0][0]._graph_embedding == [1.0, 0.0]
assert edge_batches and edge_batches[0][0][0:2] == ("new", "new")
expected_topic = Neo4jGraphStore._clone_scope("src::a", "src", "dst")
assert expected_topic == "dst::a"
tag_write = next(params for query, params in writes if "TAGGED_WITH" in query)
new_topic_id = tag_write["rows"][0]["tid"]
assert tag_write["rows"][0]["ik"] == "dst::a"
assert temporal_batches == [[("new", "new", new_topic_id)]]
assert entity_batches[0][0][0] == "new"
assert entity_batches[0][0][2] == "dst::a"
@pytest.mark.asyncio
async def test_native_batch_primitives_each_use_one_round_trip(self):
from datetime import datetime
from hy_memory.data.graph_store_neo4j import Neo4jGraphStore
gs = Neo4jGraphStore.__new__(Neo4jGraphStore)
writes = []
async def capture(query, params=None):
writes.append((query, params or {}))
gs._run_write = capture
node = _node("batch-node", "latest", tags=["work"])
node.memory_at = datetime(2026, 1, 1)
assert await gs.upsert_memory_nodes_batch([node]) == ["batch-node"]
assert len(writes) == 1
assert "UNWIND $rows" in writes[-1][0]
assert "MERGE (u:User" in writes[-1][0]
assert "MERGE (u)-[:HAS_MEMORY]->(m)" in writes[-1][0]
assert "WHEN row.emb IS NULL THEN m.embedding" in writes[-1][0]
assert writes[-1][1]["rows"][0]["content"] == "latest"
mentions = await gs.replace_entity_mentions_batch([
("batch-node", [("Paris", "gpe"), ("OpenAI", "ORG")], "u::a"),
])
assert len(writes) == 2
assert len(mentions) == 2
assert "DELETE old" in writes[-1][0]
assert "MERGE (e:Entity" in writes[-1][0]
assert "MERGE (m)-[:MENTIONS]->(e)" in writes[-1][0]
assert writes[-1][1]["rows"][0]["entities"][0]["lab"] == "GPE"
related = await gs.add_edges_batch([
("batch-node", "other", "RELATED_TO", {
"relation_type": "causes", "weight": 0.7,
}),
])
assert related == [True]
assert len(writes) == 3
assert writes[-1][0].count("MERGE") == 2
assert "CASE WHEN row.w > r1.weight" in writes[-1][0]
assert writes[-1][1]["rows"][0]["rtype"] == "causes"
temporal = await gs.add_temporal_edges_batch([
("batch-node", "later", "topic_work"),
])
assert temporal == [True]
assert len(writes) == 4
assert "MERGE (a)-[r:TEMPORAL_NEXT]->(b)" in writes[-1][0]
assert writes[-1][1]["rows"][0]["tid"] == "topic_work"
"""起因是一个真实 bug:`get_topic_memories` 写成
RETURN DISTINCT m.node_id AS node_id, m.content AS content
ORDER BY m.created_at
Neo4j 在 `DISTINCT` 之后只认投影里的变量,`m.created_at` 未投影 → SyntaxError。
而该方法的异常是 `logger.debug` 吞掉 + `return []`,**表面无声**:
`MEMORY_TOPIC_SUMMARY_ENABLED=true` 时永远拿不到成员记忆,摘要一条都不生成。
CI 里没有 Neo4j 实例(端到端测试跑的是 Kuzu),所以只能静态兜住这一类问题。
"""
def test_order_by_after_distinct_uses_projected_alias(self):
import inspect
import re
import hy_memory.data.graph_store_neo4j as m
src = inspect.getsource(m)
offenders = []
for match in re.finditer(r"RETURN\s+DISTINCT\b", src):
tail = src[match.end(): match.end() + 800]
ob = re.search(r"\bORDER\s+BY\s+([^\n]+)", tail)
if not ob:
continue
expr = ob.group(1).strip().rstrip(",")
# DISTINCT 之后只能按别名排序;出现 `var.prop` 就是本 bug
if re.search(r"\b\w+\.\w+", expr):
offenders.append(expr)
assert not offenders, (
"RETURN DISTINCT 之后的 ORDER BY 必须用投影别名,不能用 var.property: "
f"{offenders}"
)
def test_topic_memories_projects_its_sort_key(self):
import inspect
import hy_memory.data.graph_store_neo4j as m
src = inspect.getsource(m.Neo4jGraphStore.get_topic_memories)
assert "m.created_at AS created_at" in src, "排序键必须投影出来"
assert "ORDER BY created_at" in src, "ORDER BY 必须用别名"
def test_both_backends_return_same_topic_memory_shape(self):
"""两个后端 get_topic_memories 的返回形状要一致(只 node_id / content)。"""
import inspect
import hy_memory.data.graph_store_kuzu as k
import hy_memory.data.graph_store_neo4j as n
for mod, cls in ((k, "KuzuGraphStore"), (n, "Neo4jGraphStore")):
src = inspect.getsource(getattr(mod, cls).get_topic_memories)
assert '"node_id"' in src and '"content"' in src, cls
# created_at 只用于排序,不该进返回体
assert '"created_at":' not in src, f"{cls} 不该把 created_at 放进返回体"
# ================================================================
# 端到端:Kuzu + Neo4j 两个后端都跑
# ================================================================
#
# Neo4j 路此前零覆盖,直接导致 dev6 那个 bug 漏出去:
# `get_topic_memories` 的 `RETURN DISTINCT ... ORDER BY m.created_at` 在 Neo4j 上是
# SyntaxError,而异常被 logger.debug 吞掉 + return [],表面无声。
# Kuzu 版语法合法,所以单跑 Kuzu 永远测不出来 —— 两个后端必须都跑。
#
# Neo4j 连接信息从 env 取,取不到就 skip(CI 无实例时不阻塞):
# HY_TEST_NEO4J_URL / HY_TEST_NEO4J_USER / HY_TEST_NEO4J_PASSWORD
_BACKENDS = ["kuzu", "neo4j"]
def _neo4j_conn():
import os
return (
os.getenv("HY_TEST_NEO4J_URL", ""),
os.getenv("HY_TEST_NEO4J_USER", "neo4j"),
os.getenv("HY_TEST_NEO4J_PASSWORD", ""),
)
async def _make_graph_store(backend: str, tmp_path, suffix: str):
"""按 backend 建一个干净的 GraphStore;不可用则 pytest.skip。"""
from hy_memory.config import MemoryConfig, GraphStoreConfig
cfg = MemoryConfig()
if backend == "kuzu":
from hy_memory.data.graph_store_kuzu import KuzuGraphStore
cfg.graph_store = GraphStoreConfig(
provider="kuzu", db_path=str(tmp_path / f"kuzu_{suffix}"),
)
gs = KuzuGraphStore(cfg)
await gs.initialize()
if not getattr(gs, "_available", False):
pytest.skip("Kuzu unavailable in this environment")
return gs
url, user, pwd = _neo4j_conn()
if not url or not pwd:
pytest.skip(
"Neo4j 未配置:设 HY_TEST_NEO4J_URL / HY_TEST_NEO4J_PASSWORD 后才跑"
)
from hy_memory.data.graph_store_neo4j import Neo4jGraphStore
cfg.graph_store = GraphStoreConfig(
provider="neo4j", url=url, username=user, password=pwd, database="neo4j",
)
gs = Neo4jGraphStore(cfg)
try:
await gs.initialize()
except Exception as e:
pytest.skip(f"Neo4j unreachable: {e}")
# Community edition 只有一个 database,测试间必须自己清干净,否则互相污染
await _wipe_neo4j(gs)
return gs
async def _wipe_neo4j(gs) -> None:
for label in ("Memory", "Topic", "Entity", "VdbRef", "User"):
try:
await gs._run_write(f"MATCH (n:{label}) DETACH DELETE n")
except Exception:
pass
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", _BACKENDS)
@pytest.mark.parametrize("with_embed", [True, False], ids=["normalized", "fallback"])
async def test_tagged_with_edge_created_in_both_paths(backend, with_embed, tmp_path):
"""`add_topic_tag` 的两条路径都必须真的建出 Memory→Topic 边。
回归一个真实 bug:Neo4j 的 fallback 分支(不传 embed_service,或归一化抛异常时)
给 `_run_write` 多传了一个 params dict → TypeError 被 `except` 静默吞掉,
**TAGGED_WITH 边从来没建出来**。后果是 tag 全变成孤立 Topic:
`get_topic_memories` 返回空 → Topic 摘要不触发、时序脊找不到同 Topic 成员。
单跑归一化路径测不出来(那条分支自己会建边),所以两条路径都要覆盖。
"""
gs = await _make_graph_store(backend, tmp_path, f"tagpath_{int(with_embed)}")
class _Embed:
async def embed_queued(self, text):
return [1.0, 0.0, 0.0]
async def embed(self, text): # 真实 EmbedService 两个都有
return [1.0, 0.0, 0.0]
try:
node = _node("tp1", "some fact", tags=["work"])
await gs.upsert_memory_node(node)
tid = await gs.add_topic_tag(
"tp1", "work", node.get_isolation_key(),
embed_service=_Embed() if with_embed else None,
)
assert tid, "add_topic_tag 应返回 topic_id"
mems = await gs.get_topic_memories(tid)
assert [m["node_id"] for m in mems] == ["tp1"], (
f"{backend}/{'normalized' if with_embed else 'fallback'} 路径没建出 "
f"TAGGED_WITH 边 —— Topic 成了孤岛"
)
finally:
await gs.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", _BACKENDS)
async def test_graph_end_to_end(backend, tmp_path):
"""投影 → get_related_edges → Topic 归一化,走真实图库。"""
gs = await _make_graph_store(backend, tmp_path, "db")
class _Embed:
"""fake 必须同时提供 embed_queued / embed —— 真实 EmbedService 两个都有。
只实现一个会让某个后端的 tag 归一化静默退化成字符串精确匹配。"""
_TABLE = {
# work / job 近似同义 → 应被归一化成同一个 Topic
"work": [1.0, 0.0, 0.0], "job": [0.99, 0.14, 0.0],
"food": [0.0, 1.0, 0.0],
}
async def embed_queued(self, text):
return self._TABLE.get(text, [0.0, 0.0, 1.0])
async def embed(self, text):
return self._TABLE.get(text, [0.0, 0.0, 1.0])
try:
n1 = _node("g1", "started running daily", tags=["fitness"])
n2 = _node("g2", "knee started hurting", tags=["health"])
stats = await project_memories_to_graph(
graph_store=gs, embed_service=_Embed(), nodes=[n1, n2],
related_by_node={"g1": [{"target_id": "g2", "relation_type": "causes"}]},
)
assert stats["nodes"] == 2
assert stats["co_occurs"] == 1 and stats["typed"] == 1
edges = await gs.get_related_edges(["g1"])
# 无向去重:g1↔g2 之间两种关系各一条(co_occurs + causes)
rtypes = sorted(e["relation_type"] for e in edges)
assert rtypes == ["causes", "co_occurs"]
assert all(e["target_status"] == "active" for e in edges)
# 关系类型过滤
only_causes = await gs.get_related_edges(["g1"], relation_types=["causes"])
assert [e["relation_type"] for e in only_causes] == ["causes"]
# shadow 后读侧必须能过滤掉
await sync_graph_on_shadow(gs, ["g2"])
adj = build_adjacency(await gs.get_related_edges(["g1"]))
assert adj == {}
# Topic 语义归一化:work / job 复用同一个 Topic,food 独立
ik = n1.get_isolation_key()
t_work = await gs.add_topic_tag("g1", "work", ik, embed_service=_Embed())
t_job = await gs.add_topic_tag("g1", "job", ik, embed_service=_Embed())
t_food = await gs.add_topic_tag("g1", "food", ik, embed_service=_Embed())
assert t_job == t_work, "同义 tag 应复用同一 Topic,否则 tag 图会碎成同义孤岛"
assert t_food != t_work
topics = await gs.get_all_topics(ik)
assert {t["name"] for t in topics} >= {"work", "food"}
# 两个后端的返回键必须一致 —— degree 缺失会让画像聚合(§7.1)静默失效
assert all({"topic_id", "name", "embedding", "degree"} <= set(t) for t in topics)
assert all(t["degree"] >= 1 for t in topics if t["name"] == "work")
# get_topic_memories:给 Topic-scoped summary 取素材
mems = await gs.get_topic_memories(t_work)
assert [m["node_id"] for m in mems] == ["g1"]
# 图里没有 memory_at(Memory 表只有 created_at),事件时间以 VDB 为准
assert "memory_at" not in mems[0]
finally:
await gs.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", _BACKENDS)
async def test_temporal_spine_end_to_end(backend, tmp_path):
"""时序脊:同 Topic 内按 memory_at 串有向边,两个方向都能查到。"""
from datetime import datetime
gs = await _make_graph_store(backend, tmp_path, "spine")
class _Embed:
async def embed_queued(self, text):
return [1.0, 0.0, 0.0]
async def embed(self, text):
return [1.0, 0.0, 0.0]
# 事件时间以 VDB 为准(图的 Memory 表没有 memory_at)
written: Dict[str, Any] = {}
class _VS:
async def get_by_ids(self, ids):
return [written[i] for i in ids if i in written]
try:
# 同一个 Topic(fitness)下三条按时间递进的记忆,分三批写入
specs = [
("t1", "started jogging", datetime(2026, 1, 1)),
("t2", "ran a 10k", datetime(2026, 3, 1)),
("t3", "ran a half marathon", datetime(2026, 6, 1)),
]
for nid, content, ts in specs:
node = _node(nid, content, tags=["fitness"])
node.memory_at = ts
st = await project_memories_to_graph(
graph_store=gs, embed_service=_Embed(), nodes=[node],
vector_store=_VS(), temporal_spine=True,
)
written[nid] = node
# 第一条没有前驱;后两条各接一条时序边
assert st["temporal"] == (0 if nid == "t1" else 1), nid
edges = await gs.get_related_edges(["t2"])
temporal = [e for e in edges if e["relation_type"] == "temporal_next"]
# t2 既有前驱 t1 也有后继 t3 —— 两个方向都要能查到,
# 否则 "X 之前发生了什么" 走不通
assert {(e["source_id"], e["target_id"]) for e in temporal} == {
("t1", "t2"), ("t2", "t3"),
}
assert all(e["directed"] for e in temporal)
# 方向要能落到路径输出上
fwd = collect_directed_edges(edges)
assert ("t1", "t2") in fwd and ("t2", "t3") in fwd
paths = find_relation_paths(
hit_scores={"t1": 0.9, "t3": 0.85},
adjacency=build_adjacency(edges),
forward_dirs=fwd,
)
two_hop = [p for p in paths if p["hops"] == 2 and p["nodes"][0] == "t1"]
assert two_hop, "t1 →(t2)→ t3 的时序路径应被找到"
assert all(r.get("direction") == "forward" for r in two_hop[0]["relations"])
# include_temporal=False 时不返回时序边
assert not [
e for e in await gs.get_related_edges(["t2"], include_temporal=False)
if e["relation_type"] == "temporal_next"
]
finally:
await gs.close()