1626 lines
64 KiB
Python
1626 lines
64 KiB
Python
"""记忆关系图(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()
|