compound-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ """评分:分词、词面相似度(BM25)、新近衰减、最终得分。
2
+
3
+ 检索得分 = 0.70·相似度 + 0.15·置信度 + 0.10·新近度(0.5+0.5·e^(-Δt/τ)) + 0.05·类型权重
4
+
5
+ 设计约束(2026-10-03 vec-spike 实测定型):sim 是主序,先验只做 tie-break——
6
+ conf/recency/type 三槽的**有效分差跨度**必须盖不过 sim 槽的单 token 命中差,
7
+ 否则高置信/新近的无关记忆会挤掉正确答案(recall-audit 失效模式②的马太效应)。
8
+ recency_score 因此带 0.5 底座(跨度 0.5),坏日期记中性值 0.5 而非 0。
9
+
10
+ rank 是排序管线的单一定义点:调用方传入原始 query 与候选记忆,
11
+ tokenize → BM25 → 归一化 → 新近 → 合分 → 排序 → 结果形状全部在实现内。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+ import math
18
+ import re
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ from .model import TYPE_SPEC, Memory
23
+
24
+ TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]")
25
+
26
+ # 由 TYPE_SPEC 派生(加类型只改一张表);.get 的兜底默认用于容错手工编辑出的未知类型
27
+ TYPE_WEIGHT = {t: s.weight for t, s in TYPE_SPEC.items()}
28
+ TAU_DAYS = {t: s.tau_days for t, s in TYPE_SPEC.items()}
29
+
30
+ W_SIM = 0.70
31
+ W_CONF = 0.15
32
+ W_RECENCY = 0.10
33
+ W_TYPE = 0.05
34
+ # 邻居召回(CONTEXT.md: 关联增值):hit 内嵌精简邻居的形状上限——
35
+ # 数据由调用方经 neighbor_lookup 提供(store 只供活动记忆),截断/上限/去环在此单点收口
36
+ MAX_NEIGHBORS = 3
37
+ NEIGHBOR_CONTENT_CHARS = 80
38
+ # 向量路 RRF 融合(vec-spike S5 形态,2026-10-03):两路 rank 融合为主序,
39
+ # 先验(conf/recency/type)整体压到 PRIOR_EPSILON 做 tie-break——
40
+ # RRF 相邻 rank 位差 = 1/(K+1) ≈ 0.016,ε=0.04 意味着先验最多抬 ~2 个 rank 位,
41
+ # 抬不动正确答案与高置信噪声之间的真实 rank 差(recall-audit 失效模式②的根治)。
42
+ RRF_K = 60
43
+ PRIOR_EPSILON = 0.04
44
+
45
+
46
+ def _cjk_bigrams(run: list[str]) -> list[str]:
47
+ if not run:
48
+ return []
49
+ if len(run) == 1:
50
+ return list(run)
51
+ return [run[i] + run[i + 1] for i in range(len(run) - 1)]
52
+
53
+
54
+ def tokenize(text: str) -> list[str]:
55
+ """小写分词器:拉丁字母/数字整词 + CJK 相邻双字(bigram)。"""
56
+ tokens: list[str] = []
57
+ cjk_run: list[str] = []
58
+ for piece in TOKEN_RE.findall(text.lower()):
59
+ if "\u4e00" <= piece <= "\u9fff":
60
+ cjk_run.append(piece)
61
+ else:
62
+ tokens.extend(_cjk_bigrams(cjk_run))
63
+ cjk_run = []
64
+ tokens.append(piece)
65
+ tokens.extend(_cjk_bigrams(cjk_run))
66
+ return tokens
67
+
68
+
69
+ def doc_text(mem: Memory) -> str:
70
+ """记忆的可检索文本——单一定义点(content + key)。"""
71
+ return mem.content + " " + (mem.key or "")
72
+
73
+
74
+ def age_days(date_str: str, today: dt.date) -> int | None:
75
+ """ISO 日期字符串 → 距 today 天数;坏/缺日期返回 None(消费方决定业务动作)。"""
76
+ try:
77
+ return (today - dt.date.fromisoformat(date_str)).days
78
+ except (ValueError, TypeError):
79
+ return None
80
+
81
+
82
+ def recency_age(mem: Memory, now: dt.date) -> int | None:
83
+ """新近基准(CONTEXT.md: recency reference):last_used 优先,无则 created。
84
+
85
+ 返回基准距 now 的天数(负数 = 基准在未来,交由消费方定夺);
86
+ 坏/缺日期返回 None。基准选择只在这一处,解析降级共用 age_days,
87
+ 消费方只决定 None 的业务动作(rank ⇒ 新近项记 0 分;decay ⇒ 跳过该条)。
88
+ """
89
+ return age_days(mem.last_used or mem.created, now)
90
+
91
+
92
+ def bm25_scores(
93
+ query_tokens: list[str],
94
+ docs_tokens: list[list[str]],
95
+ k1: float = 1.5,
96
+ b: float = 0.75,
97
+ ) -> list[float]:
98
+ """各文档对 query 的 BM25 相关度;无匹配时返回 0.0。"""
99
+ n_docs = len(docs_tokens)
100
+ if n_docs == 0 or not query_tokens:
101
+ return [0.0] * n_docs
102
+ avgdl = sum(len(d) for d in docs_tokens) / n_docs or 1.0
103
+ df: dict[str, int] = {}
104
+ for doc in docs_tokens:
105
+ for tok in set(doc):
106
+ df[tok] = df.get(tok, 0) + 1
107
+ scores: list[float] = []
108
+ for doc in docs_tokens:
109
+ dl = len(doc) or 1
110
+ tf = {t: doc.count(t) for t in set(doc) if t in query_tokens}
111
+ rel = 0.0
112
+ for tok, freq in tf.items():
113
+ idf = math.log((n_docs - df[tok] + 0.5) / (df[tok] + 0.5) + 1)
114
+ rel += idf * freq * (k1 + 1) / (freq + k1 * (1 - b + b * dl / avgdl))
115
+ scores.append(rel)
116
+ return scores
117
+
118
+
119
+ def recency_score(mem: Memory, now: dt.date) -> float:
120
+ """新近度 0.5 + 0.5·e^(-Δdays/τ),τ 取自记忆类型;底座把槽内跨度压到 0.5(先验只做 tie-break)。
121
+
122
+ 坏/缺日期返回中性值 0.5(不奖励也不惩罚,与底座语义一致)。
123
+ """
124
+ tau = TAU_DAYS.get(mem.type, 90.0)
125
+ age = recency_age(mem, now)
126
+ if age is None:
127
+ return 0.5
128
+ return 0.5 + 0.5 * math.exp(-max(0, age) / tau)
129
+
130
+
131
+ def normalized_similarity(bm25: float, n_query_tokens: int) -> float:
132
+ """BM25 除以 query token 数,截断到 [0, 1]。"""
133
+ if n_query_tokens <= 0:
134
+ return 0.0
135
+ return min(1.0, bm25 / n_query_tokens)
136
+
137
+
138
+ def dup_similarity_matrix(docs: list[str]) -> list[list[float]]:
139
+ """蒸馏疑似重复信号的相似度矩阵:每条候选文本当 query 在候选集上打分。
140
+
141
+ matrix[i][j] = normalized_similarity(以 docs[i] 的 tokens 为 query 对 docs[j] 的 BM25),
142
+ 与 rank 同一套分词/归一——语料语义一致,互标才可比。BM25 的 query/doc 角色不对称,
143
+ 矩阵因此非对称;对角线(自查自)恒 > 0,由消费方跳过自身。
144
+ 阈值标注(possible_dup_of)是 distill-plan 的策略,不在这里。
145
+ """
146
+ docs_tokens = [tokenize(d) for d in docs]
147
+ return [
148
+ [normalized_similarity(rel, len(qt)) for rel in bm25_scores(qt, docs_tokens)]
149
+ for qt in docs_tokens
150
+ ]
151
+
152
+
153
+ def final_score(sim: float, confidence: float, recency: float, mtype: str) -> float:
154
+ return W_SIM * sim + W_CONF * confidence + W_RECENCY * recency + W_TYPE * TYPE_WEIGHT.get(mtype, 0.5)
155
+
156
+
157
+ def rank(
158
+ query: str,
159
+ candidates: list[Memory],
160
+ now: dt.date,
161
+ top_k: int = 5,
162
+ neighbor_lookup: Callable[[str], list[Memory]] | None = None,
163
+ vec_sims: dict[str, float] | None = None,
164
+ ) -> list[dict[str, Any]]:
165
+ """排序管线:query 与候选记忆进,最终搜索结果出。
166
+
167
+ 结果 dict 的形状在这里一处定义(id / score / similarity / confidence /
168
+ uses / type / ns / source / content;提供 neighbor_lookup 时每 hit 内嵌
169
+ neighbors)。邻居只"带出"不"提分"——公式与排序不受影响(#7)。
170
+
171
+ vec_sims(mem_id → 余弦相似度)为 None 时走纯词面单路:rel≤0 的候选缺席、
172
+ BM25 归一分进 0.70 槽——与历史行为逐位一致。提供时走双路 RRF 融合:
173
+ 词面路(rel>0 才参与)与向量路各出一列 rank,RRF norm 作主序、先验压到
174
+ PRIOR_EPSILON 做 tie-break;词面零命中但向量召回的候选由此进入结果。
175
+ """
176
+ q_tokens = tokenize(query)
177
+ if not q_tokens:
178
+ return []
179
+ docs = [tokenize(doc_text(m)) for m in candidates]
180
+ rels = bm25_scores(q_tokens, docs)
181
+ by_id = {m.id: (m, rel) for m, rel in zip(candidates, rels)}
182
+ hits: list[dict[str, Any]] = []
183
+
184
+ def emit(mem: Memory, sim: float, score: float) -> None:
185
+ hits.append(
186
+ {
187
+ "id": mem.id,
188
+ "score": round(score, 4),
189
+ "similarity": round(sim, 4),
190
+ "confidence": mem.confidence,
191
+ "uses": mem.uses,
192
+ "type": mem.type,
193
+ "ns": mem.ns,
194
+ "source": mem.source,
195
+ "content": mem.content,
196
+ }
197
+ )
198
+
199
+ if vec_sims is None:
200
+ for mem, rel in zip(candidates, rels):
201
+ if rel <= 0:
202
+ continue
203
+ sim = normalized_similarity(rel, len(q_tokens))
204
+ score = final_score(sim, mem.confidence, recency_score(mem, now), mem.type)
205
+ emit(mem, sim, score)
206
+ else:
207
+ lexical = sorted(
208
+ ((m.id, rel) for m, rel in zip(candidates, rels) if rel > 0),
209
+ key=lambda t: -t[1],
210
+ )
211
+ lexical_rank = {mid: r for r, (mid, _) in enumerate(lexical, 1)}
212
+ vec_rank = {
213
+ mid: r
214
+ for r, (mid, _) in enumerate(
215
+ sorted(((mid, s) for mid, s in vec_sims.items() if mid in by_id), key=lambda t: -t[1]), 1
216
+ )
217
+ }
218
+ rrf_max = 2.0 / (RRF_K + 1) # 双路都拿 rank1 的理论上限;满命中归一到 1.0
219
+ for mem, _ in by_id.values():
220
+ fused = 0.0
221
+ if mem.id in lexical_rank:
222
+ fused += 1.0 / (RRF_K + lexical_rank[mem.id])
223
+ if mem.id in vec_rank:
224
+ fused += 1.0 / (RRF_K + vec_rank[mem.id])
225
+ if fused <= 0:
226
+ continue # 两路都不在场:不该出现在结果里(调用方候选并集含兜底)
227
+ rec_n = (recency_score(mem, now) - 0.5) / 0.5 # 底座归一回 [0,1]
228
+ prior = 0.5 * mem.confidence + 0.3 * rec_n + 0.2 * TYPE_WEIGHT.get(mem.type, 0.5)
229
+ sim = fused / rrf_max
230
+ emit(mem, sim, sim + PRIOR_EPSILON * prior)
231
+ hits.sort(key=lambda h: -h["score"])
232
+ top = hits[:top_k]
233
+ if neighbor_lookup is not None:
234
+ for hit in top:
235
+ hit["neighbors"] = [
236
+ {
237
+ "id": n.id,
238
+ "content": n.content[:NEIGHBOR_CONTENT_CHARS] + ("…" if len(n.content) > NEIGHBOR_CONTENT_CHARS else ""),
239
+ "type": n.type,
240
+ "ns": n.ns,
241
+ }
242
+ for n in neighbor_lookup(hit["id"])
243
+ if n.id != hit["id"] # 去环:双向 link 不把 hit 自己带回来
244
+ ][:MAX_NEIGHBORS]
245
+ return top
@@ -0,0 +1,88 @@
1
+ """MCP server:所有 Agent 的唯一读写边界。
2
+
3
+ 恰好 5 个 tool:memory_write / memory_search / memory_get / memory_link / memory_feedback。
4
+ memory_feedback 是一等公民——复利闭环依赖它。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Callable
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+
14
+ from .embedding import auto_encoder
15
+ from .storage import MEMORY_TYPES, MemoryStore, default_root
16
+
17
+ mcp = MCPServer("compound-memory")
18
+
19
+ _store: MemoryStore | None = None
20
+
21
+
22
+ def configure(
23
+ root: Path | str | None = None,
24
+ git: bool = True,
25
+ git_probe: Callable[[], bool] | None = None,
26
+ embedder: Callable[[list[str]], list[list[float]]] | None = None,
27
+ ) -> MemoryStore:
28
+ global _store
29
+ _store = MemoryStore(
30
+ Path(root) if root is not None else default_root(), git=git, git_probe=git_probe, embedder=embedder
31
+ )
32
+ return _store
33
+
34
+
35
+ def _store_or_configure() -> MemoryStore:
36
+ if _store is None:
37
+ configure()
38
+ assert _store is not None
39
+ return _store
40
+
41
+
42
+ @mcp.tool()
43
+ def memory_write(
44
+ content: str,
45
+ type: str,
46
+ source: str,
47
+ ns: str = "_shared",
48
+ key: str | None = None,
49
+ links: list[str] | None = None,
50
+ ) -> dict[str, Any]:
51
+ """Write a memory. type: episode|fact|insight|skill; source: writing agent id; ns: '_shared' or 'agent-<name>'. key: stable id for fact/insight (enables conflict review). Write only stable facts (preferences, conventions, environment constraints, pitfalls), not session-temporary details; prefer reusing an existing key over a new entry. Returns the stored memory; `conflict: true` means a different version with the same key exists and a review entry was queued."""
52
+ return _store_or_configure().write(content=content, type=type, source=source, ns=ns, key=key, links=links)
53
+
54
+
55
+ @mcp.tool()
56
+ def memory_search(query: str, ns: str = "_shared", top_k: int = 5, include_neighbors: bool = True) -> dict[str, Any]:
57
+ """Search memories. Fuses lexical (BM25) and, when the vec extra + model are installed, vector (BGE) recall via RRF; otherwise falls back to lexical only. Confidence/recency/type act only as a small tie-break. Default namespace is _shared. Each hit embeds up to 3 trimmed one-hop neighbors (active only) unless include_neighbors=False. Returns {'hits': [...]} sorted by score. Compounding rule: after actually adopting a hit, call memory_feedback (agent = your source id) — skipped feedbacks leave the store static."""
58
+ hits = _store_or_configure().search(query=query, ns=ns, top_k=top_k, include_neighbors=include_neighbors)
59
+ return {"hits": hits, "count": len(hits)}
60
+
61
+
62
+ @mcp.tool()
63
+ def memory_get(mem_id: str, include_neighbors: bool = True) -> dict[str, Any]:
64
+ """Fetch a memory by id; one-hop link neighbors are included by default. After adopting it, call memory_feedback (agent = your source id)."""
65
+ return _store_or_configure().get(mem_id, include_neighbors=include_neighbors)
66
+
67
+
68
+ @mcp.tool()
69
+ def memory_link(id_a: str, id_b: str) -> dict[str, Any]:
70
+ """Create a bidirectional link between two memories (compounding source #2: association)."""
71
+ return _store_or_configure().link(id_a, id_b)
72
+
73
+
74
+ @mcp.tool()
75
+ def memory_feedback(mem_id: str, agent: str) -> dict[str, Any]:
76
+ """Report that a memory was actually used. Increments uses, raises confidence (+0.1; extra +0.15 when a different agent validates). agent must be your own source agent id. Archiving is reversed on feedback. MUST be called after a memory is adopted — this closes the compounding loop."""
77
+ return _store_or_configure().feedback(mem_id, agent)
78
+
79
+
80
+ def main() -> None:
81
+ if _store is None:
82
+ # 生产入口自动挂向量路(vec extra + 模型就绪才生效,否则静默降级纯词面)
83
+ configure(embedder=auto_encoder())
84
+ mcp.run()
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()