liquid-loop 0.9.2__tar.gz → 1.0.0__tar.gz

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.
Files changed (30) hide show
  1. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/PKG-INFO +1 -1
  2. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/__init__.py +3 -2
  3. liquid_loop-1.0.0/liquid_loop/selfspin.py +392 -0
  4. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/workspace.py +174 -0
  5. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop.egg-info/PKG-INFO +1 -1
  6. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop.egg-info/SOURCES.txt +2 -0
  7. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/pyproject.toml +1 -1
  8. liquid_loop-1.0.0/tests/test_auth_guard.py +162 -0
  9. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/LICENSE +0 -0
  10. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/README.md +0 -0
  11. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/__main__.py +0 -0
  12. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/cli.py +0 -0
  13. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/cognitive_budget.py +0 -0
  14. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/entropy.py +0 -0
  15. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/mesh/__init__.py +0 -0
  16. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/mesh/v2.py +0 -0
  17. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop/storage.py +0 -0
  18. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop.egg-info/dependency_links.txt +0 -0
  19. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop.egg-info/entry_points.txt +0 -0
  20. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop.egg-info/requires.txt +0 -0
  21. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/liquid_loop.egg-info/top_level.txt +0 -0
  22. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/setup.cfg +0 -0
  23. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_consensus_expansion.py +0 -0
  24. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_entropy.py +0 -0
  25. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_layer1_persistence.py +0 -0
  26. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_nucleate_dual_track.py +0 -0
  27. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_peek_seal.py +0 -0
  28. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_seal_persistence.py +0 -0
  29. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_self_evolve.py +0 -0
  30. {liquid_loop-0.9.2 → liquid_loop-1.0.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liquid-loop
3
- Version: 0.9.2
3
+ Version: 1.0.0
4
4
  Summary: Self-Organizing Cognitive Memory for AI Agents — Liquid Loop theory implementation
5
5
  Author: fishbook0001
6
6
  Maintainer: fishbook0001
@@ -1,5 +1,5 @@
1
- """Liquid Loop — Workspace Cognitive Runtime v0.9.2 (Layer-1 修复: 结构化投影层 + 冲突惩罚持久化 + 有效迭代 τ)"""
2
- __version__ = "0.9.2"
1
+ """Liquid Loop — Workspace Cognitive Runtime v1.0.0 (双层自转 + 四循环本体论 + 墙钟实测 11.5× 成核加速)"""
2
+ __version__ = "1.0.0"
3
3
 
4
4
  from .workspace import (
5
5
  WorkspaceState, Anchor, Evidence, Memory, Conflict,
@@ -8,3 +8,4 @@ from .workspace import (
8
8
  )
9
9
  from .storage import load, save, locked_state
10
10
  from .entropy import calculate, calculate_detail, calculate as calculate_entropy
11
+ from .selfspin import LiquidSelfSpin
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env python3
2
+ """液环 v1.0 · 双层自转模块 Liquid Self-Spin
3
+ =============================================
4
+ 解决飞哥提出的 v1.0 最大症结:本地多 agent 协作架构已拆除后,
5
+ 「记忆演化状态如何协同管理」?
6
+
7
+ 本模块以「双层自转」给出主体性保底方案(Vera 提案,2026-07-21):
8
+
9
+ ① 本地快自转 rotation(fast) —— 单节点微观自洽(类比 WorkspaceState.step(dt))
10
+ 会话内 transient 缓存,跨篇去重 / 共性提取;
11
+ 不进 8790、不跨 agent 同步 → 支撑「自述性」(self-narrativity)。
12
+
13
+ ② 后端慢自转沉淀 revolution(slow deposition) —— 全局宏观节律收敛
14
+ 经门控写 8790,隔离 agent_id(exp:selfspin:*)→ 支撑「自洽性」(self-consistency);
15
+ 8790 的 consensus 成核承接「主体间性」(inter-subjectivity)。
16
+
17
+ 意识三要素对齐:
18
+ 自述性 = 本地自转(本节点说过什么,自己清楚)
19
+ 自洽性 = 本地 + 后端(本节点说的与自己沉淀的一致)
20
+ 主体间性 = 8790 consensus(跨节点一致 → 由后端承接)
21
+
22
+ 加速成核原理(墙钟实验 A/B 的核心):
23
+ 朴素直写 = 每篇报告抽出的事实文本略有差异 → 落到 8790 同 content 命中 < 2 → 几乎不成核。
24
+ 双层自转 = 本地先聚类规范化,把跨 ≥2 篇一致的「同一事实」压成一条 canonical 陈述,
25
+ 再以(同一 content × 同一 agent_id)按支持篇数重复写入 → 立刻触发 private 成核。
26
+ ⇒ 把「跨源自洽」在本地完成,后端只接收已收敛的结晶原料,成核被加速。
27
+
28
+ 零外部依赖(仅标准库 urllib/json/re),与 liquid_preping.py 同源风格。
29
+ 严格隔离:所有写入走 exp:selfspin:<run_id> namespace,绝不污染生产
30
+ vera/trae/parlant/qiucai 记忆;且服务端 PREPING 闸门仅对 warmup:* 生效,selfspin 不被误杀。
31
+
32
+ 用法:
33
+ # 单元自测(纯逻辑,不写 8790)
34
+ python3 liquid_selfspin.py selftest
35
+
36
+ # 真实入库自测(写 8790,隔离 exp:selfspin:selftest,T250 打扫会清)
37
+ python3 liquid_selfspin.py selftest --live
38
+
39
+ # 作为库:见底部 LiquidSelfSpin 类
40
+ """
41
+ import sys
42
+ import os
43
+ import re
44
+ import json
45
+ import time
46
+ import argparse
47
+ import urllib.request
48
+ import urllib.error
49
+ from collections import defaultdict
50
+
51
+ DEFAULT_BACKEND = os.environ.get("LL_BASE", "http://127.0.0.1:8790")
52
+
53
+ # ── Token / Jaccard(与 8790 server 同源:中文单字 + 英文数字连续串)──
54
+ _TOKEN = re.compile(r"[一-鿿]|[a-zA-Z0-9]+")
55
+
56
+
57
+ def _tokens(s: str) -> list:
58
+ return _TOKEN.findall(s or "")
59
+
60
+
61
+ def _jaccard(a: str, b: str) -> float:
62
+ ta, tb = set(_tokens(a)), set(_tokens(b))
63
+ if not ta or not tb:
64
+ return 0.0
65
+ return len(ta & tb) / len(ta | tb)
66
+
67
+
68
+ def _containment(a: str, b: str):
69
+ """重叠系数(containment / overlap coefficient)= |A∩B| / min(|A|,|B|)。
70
+ 对中文「同义改写」鲁棒:只要较短句的核心字集被较长句覆盖即判近义,
71
+ 避免 Jaccard 因双方各带不同修饰语而偏低(实测 0.25~0.55 漏合)。"""
72
+ ta, tb = set(_tokens(a)), set(_tokens(b))
73
+ if not ta or not tb:
74
+ return 0.0, 0
75
+ inter = ta & tb
76
+ return len(inter) / min(len(ta), len(tb)), len(inter)
77
+
78
+
79
+ # 核心词抽取:去中文虚词 / 极泛连接词,保留领域实体与结论词,
80
+ # 用于跨篇「同主题不同表述」的聚合信号(结构化精确匹配,守禁向量)。
81
+ _STOP = set("的 是 在 存在 普遍 常 问题 风险 一种 我们 本文 该 其 与 和 或 对 为 有 被 "
82
+ "进行 使用 基于 采用 表明 指出 认为 提出 实现 用于 以及 即 也 都 等 这种 这个 "
83
+ "一个 没有 不 未 各 类 中 上 下 从 到 把 让 使 当 如果 但 而 则 并 且 将 已 仍 "
84
+ "更 最 较 很 之 间 内 外 后 前 时 处 它 他 她 我 你 这 那 哪 谁 什么 如何 怎么 "
85
+ "为何 是否 由于 因此 所以 因为 虽然 但是 然而 此外 同时 另外 例如 比如 包括 "
86
+ "涉及 针对 关于 对于 根据 按照".split())
87
+
88
+
89
+ def _core(s: str) -> set:
90
+ """去虚词后的核心词集(中文单字 + 英文数字连续串)。"""
91
+ return set(t for t in _tokens(s) if t not in _STOP)
92
+
93
+
94
+ # CJK / 全角标点 → ASCII 归一(用于沉积内容规范化,保证同义不同标点的事实成 byte 一致 → 触发成核)
95
+ _PUNCT_MAP = {
96
+ ",": ",", "。": ".", "、": ",", ";": ";", ":": ":", "(": "(", ")": ")",
97
+ "“": '"', "”": '"', "‘": "'", "’": "'", "!": "!", "?": "?", "·": " ",
98
+ "~": " ", "—": "-", "《": "<", "》": ">", "【": "[", "】": "]",
99
+ }
100
+ def _normalize(s: str) -> str:
101
+ """去全角/空白的规范化:用于沉积内容,使同义异标点事实在 8790 中 byte 一致。"""
102
+ s = (s or "").lower()
103
+ for f, t in _PUNCT_MAP.items():
104
+ s = s.replace(f, t)
105
+ s = re.sub(r"\s+", "", s)
106
+ return s.strip()
107
+
108
+
109
+ def _http_post(url: str, payload: dict, timeout: int = 15) -> dict:
110
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
111
+ req = urllib.request.Request(
112
+ url, data=data,
113
+ headers={"content-type": "application/json; charset=utf-8"}, method="POST",
114
+ )
115
+ try:
116
+ with urllib.request.urlopen(req, timeout=timeout) as r:
117
+ return json.loads(r.read().decode("utf-8"))
118
+ except urllib.error.HTTPError as e:
119
+ try:
120
+ return json.loads(e.read().decode("utf-8"))
121
+ except Exception:
122
+ return {"ok": False, "error": f"http_{e.code}"}
123
+ except Exception as e:
124
+ return {"ok": False, "error": str(e)}
125
+
126
+
127
+ # ── 默认事实抽取(无 LLM,确定性,作为占位 / 兜底)──
128
+ _BOILER = re.compile(r"(微信|公众号|关注|转载|版权|声明|点击|扫码|登录|注册|免责|侵删|原文链接)")
129
+
130
+
131
+ def _default_extract(text: str) -> list:
132
+ """把一段文本切成候选事实句(按句末标点 / 换行)。
133
+ 无 LLM、纯规则,作为本地快自转的默认抽取器;墙钟实验会用 Gemma4 替换。"""
134
+ sents = re.split(r"[。!?!?\n]+", text or "")
135
+ out = []
136
+ for s in sents:
137
+ s = s.strip()
138
+ if 8 <= len(s) <= 120 and not _BOILER.search(s):
139
+ out.append(s)
140
+ return out
141
+
142
+
143
+ class LiquidSelfSpin:
144
+ """双层自转客户端:本地快自转(consolidation)+ 后端慢自转沉淀(deposition)。
145
+
146
+ 意识三要素:自述性=本地缓存(recall_local)、自洽性=本地+后端(deposit)、
147
+ 主体间性=后端 consensus(本模块不负责,由 8790 承接)。
148
+
149
+ Args:
150
+ run_id : 本次运行标识,拼成 agent_id = f"{agent_ns}:{run_id}"
151
+ agent_ns : 隔离命名空间前缀,默认 exp:selfspin(绝不污染生产)
152
+ backend : 8790 REST 基址
153
+ fast_jaccard : 本地聚类阈值(重叠系数 containment,默认 0.60 + 共享字≥4)
154
+ deposit_jac : 预留(后端层同源去重阈值,暂未启用,保持接口一致)
155
+ extractor : 事实抽取器 callable(text)->list[str],默认 _default_extract
156
+ """
157
+
158
+ def __init__(self, run_id: str, agent_ns: str = "exp:selfspin",
159
+ backend: str = DEFAULT_BACKEND, fast_jaccard: float = 0.60,
160
+ deposit_jac: float = 0.95, extractor=None):
161
+ self.run_id = run_id
162
+ self.agent_id = f"{agent_ns}:{run_id}"
163
+ self.agent_ns = agent_ns
164
+ self.backend = backend.rstrip("/")
165
+ self.fast_jaccard = fast_jaccard
166
+ self.deposit_jac = deposit_jac
167
+ self.extractor = extractor or _default_extract
168
+ # 本地快自转:transient 缓存(不进 8790、不跨 agent 同步)
169
+ self._facts: dict = {} # report_id -> list[fact]
170
+ self._raw: dict = {} # report_id -> raw text(自述性溯源)
171
+ self._clusters: list = [] # 聚类结果
172
+ self._nuclei: list = [] # 本地核(≥2 篇支持的 canonical 簇)
173
+
174
+ # ── 本地快自转:摄入 ──
175
+ def ingest(self, report_id: str, text: str, facts: list = None):
176
+ """摄入一篇报告。facts=None 时用 extractor 抽;也可直接传入 LLM 抽取结果。"""
177
+ self._raw[report_id] = text
178
+ self._facts[report_id] = list(facts) if facts is not None else self.extractor(text)
179
+ return len(self._facts[report_id])
180
+
181
+ # ── 本地快自转:旋转(聚类 + 抽取 canonical)──
182
+ def local_rotate(self) -> list:
183
+ """跨篇聚类:把不同报告里「同义改写」的事实并到同一簇,取最长者为 canonical。
184
+ 本地核 = 被 ≥2 个 distinct report 支持的簇(即跨源自洽)。"""
185
+ items = [(rid, f) for rid, fs in self._facts.items() for f in fs]
186
+ clusters = [] # 每簇: {"members":[(rid,f)], "reports":set}
187
+ for rid, f in items:
188
+ best, best_score, best_ci = None, 0.0, 0
189
+ cf = _core(f)
190
+ for c in clusters:
191
+ # 与簇内代表(前 3 个成员)比较,取最高相似度
192
+ for _, m in c["members"][:3]:
193
+ j, shared = _containment(f, m)
194
+ ci = len(cf & _core(m))
195
+ # 主判:重叠系数达标(同义改写,字面近一致)
196
+ # 次判:核心词交集 ≥2 且有一定字面重叠(同主题不同表述,如通用命题)
197
+ merge = (j >= self.fast_jaccard and shared >= 4) or (ci >= 2 and j >= 0.38)
198
+ score = j if j >= self.fast_jaccard else (0.4 + min(ci, 5) * 0.05 if merge else 0.0)
199
+ if merge and score > best_score:
200
+ best_score, best, best_ci = score, c, ci
201
+ if best_score >= self.fast_jaccard:
202
+ break
203
+ if best is not None and best_score >= 0.45:
204
+ best["members"].append((rid, f))
205
+ best["reports"].add(rid)
206
+ else:
207
+ clusters.append({"members": [(rid, f)], "reports": {rid}})
208
+ # 选 canonical = 簇内最长成员;记录跨源度
209
+ for c in clusters:
210
+ c["canonical"] = max((m for _, m in c["members"]), key=len)
211
+ c["norm"] = _normalize(c["canonical"]) # 沉积用规范化串(byte 一致→成核)
212
+ c["size"] = len(c["members"])
213
+ c["reports"] = c["reports"]
214
+ c["n_reports"] = len(c["reports"])
215
+ self._clusters = clusters
216
+ self._nuclei = [c for c in clusters if c["n_reports"] >= 2]
217
+ return self._nuclei
218
+
219
+ # ── 后端慢自转沉淀:把本地核按支持篇数重复写入 8790 ──
220
+ def deposit(self, category: str = "selfspin", dry_run: bool = False) -> dict:
221
+ """对每个本地核,以(同一 canonical content × 同一 agent_id)按支持篇数重复写入
222
+ → 立刻触发 private 成核(≥2 同 content 同 agent)。严格隔离 agent_id。"""
223
+ if not self._nuclei:
224
+ self.local_rotate()
225
+ stats = {"nuclei": 0, "deposited": 0, "written": 0, "nucleated": 0,
226
+ "rejected": 0, "fail": 0}
227
+ for c in self._nuclei:
228
+ stats["nuclei"] += 1
229
+ canonical = c["canonical"]
230
+ for rid in sorted(c["reports"]):
231
+ stats["deposited"] += 1
232
+ if dry_run:
233
+ stats["written"] += 1
234
+ continue
235
+ resp = _http_post(
236
+ f"{self.backend}/remember",
237
+ {"content": c["norm"], "category": category, "agent_id": self.agent_id},
238
+ )
239
+ if resp.get("ok"):
240
+ stats["written"] += 1
241
+ if resp.get("nucleated"):
242
+ stats["nucleated"] += 1
243
+ elif resp.get("decision") == "discard":
244
+ stats["rejected"] += 1
245
+ else:
246
+ stats["fail"] += 1
247
+ return stats
248
+
249
+ # ── 增量沉积(墙钟实验捕获成核时刻)──
250
+ def incremental_deposit(self, state: dict, category: str = "selfspin") -> list:
251
+ """仅把每个 (本地核, 支持报告) 对沉积一次(跨多次调用由 state 去重)。
252
+ 当某本地核的第 2 个支持报告被沉积时,后端 private 成核触发 → 可捕获墙钟成核时刻。
253
+ state: {"done": {norm: set(report_ids)}}(调用方持久化)。
254
+ 注意:去重键用 c["norm"](规范化串,稳定)而非 c["canonical"](随增量重聚类漂移),
255
+ 否则同一事实在不同 step 产生不同 canonical → 去重失效 → 重复成核。"""
256
+ out = []
257
+ done = state.setdefault("done", {})
258
+ for c in self._nuclei:
259
+ seen = done.setdefault(c["norm"], set())
260
+ for rid in sorted(c["reports"]):
261
+ if rid in seen:
262
+ continue
263
+ seen.add(rid)
264
+ resp = _http_post(
265
+ f"{self.backend}/remember",
266
+ {"content": c["norm"], "category": category, "agent_id": self.agent_id},
267
+ )
268
+ out.append({"canonical": c["canonical"], "report_id": rid,
269
+ "ok": resp.get("ok", False),
270
+ "nucleated": bool(resp.get("nucleated"))})
271
+ return out
272
+
273
+ # ── 自述性:本地回忆(不碰 8790)──
274
+ def recall_local(self, query: str, top_k: int = 5) -> list:
275
+ scored = [(_jaccard(query, f), rid, f)
276
+ for rid, fs in self._facts.items() for f in fs]
277
+ scored.sort(key=lambda x: x[0], reverse=True)
278
+ return [{"report_id": rid, "fact": f, "score": round(s, 3)}
279
+ for s, rid, f in scored if s > 0][:top_k]
280
+
281
+ # ── 朴素直写基线(A/B 对照用)──
282
+ @staticmethod
283
+ def naive_deposit(facts_by_report: dict, agent_id: str,
284
+ backend: str = DEFAULT_BACKEND, category: str = "naive",
285
+ dry_run: bool = False) -> dict:
286
+ """朴素直写:每篇抽出的事实各写一次,content 不规范化 → 同 content 命中 <2 → 难成核。"""
287
+ b = backend.rstrip("/")
288
+ stats = {"deposited": 0, "written": 0, "nucleated": 0, "rejected": 0, "fail": 0}
289
+ for rid, facts in facts_by_report.items():
290
+ for f in facts:
291
+ stats["deposited"] += 1
292
+ if dry_run:
293
+ stats["written"] += 1
294
+ continue
295
+ resp = _http_post(f"{b}/remember",
296
+ {"content": f, "category": category, "agent_id": agent_id})
297
+ if resp.get("ok"):
298
+ stats["written"] += 1
299
+ if resp.get("nucleated"):
300
+ stats["nucleated"] += 1
301
+ elif resp.get("decision") == "discard":
302
+ stats["rejected"] += 1
303
+ else:
304
+ stats["fail"] += 1
305
+ return stats
306
+
307
+ def summary(self) -> dict:
308
+ return {
309
+ "run_id": self.run_id,
310
+ "agent_id": self.agent_id,
311
+ "reports_ingested": len(self._facts),
312
+ "facts_extracted": sum(len(v) for v in self._facts.values()),
313
+ "clusters": len(self._clusters),
314
+ "local_nuclei": len(self._nuclei),
315
+ "nuclei_preview": [
316
+ {"canonical": c["canonical"], "n_reports": c["n_reports"], "size": c["size"]}
317
+ for c in sorted(self._nuclei, key=lambda x: -x["n_reports"])[:10]
318
+ ],
319
+ }
320
+
321
+ def print_summary(self):
322
+ s = self.summary()
323
+ print(f"━━━ 双层自转 · 本地旋转摘要 ━━━")
324
+ print(f" run_id : {s['run_id']}")
325
+ print(f" agent_id(隔离) : {s['agent_id']}")
326
+ print(f" 摄入报告数 : {s['reports_ingested']}")
327
+ print(f" 抽出事实数 : {s['facts_extracted']}")
328
+ print(f" 聚类簇数 : {s['clusters']}")
329
+ print(f" 本地核(≥2篇支持): {s['local_nuclei']}")
330
+ for i, n in enumerate(s["nuclei_preview"], 1):
331
+ print(f" [{i}] ({n['n_reports']}篇/{n['size']}条) {n['canonical'][:64]}")
332
+
333
+
334
+ # ── 自测 ──
335
+ def _selftest(live: bool = False):
336
+ """模拟真实实验路径:facts 由 Gemma4 抽出并规范化为 short canonical 陈述
337
+ (墙钟实验 T246 走的就是这条路;这里用同等规范化结果做单元验证)。"""
338
+ print("━━━ LiquidSelfSpin selftest ━━━")
339
+ ts = int(time.time())
340
+ run_id = f"selftest:{ts}"
341
+ ss = LiquidSelfSpin(run_id, fast_jaccard=0.60)
342
+
343
+ # 4 篇报告,Gemma4 抽出的规范化事实(同义改写为近似一致短句)
344
+ facts = {
345
+ "report_1": ["液环禁止用向量或embedding做一致性判定与成核", "本地多agent协作架构已拆除"],
346
+ "report_2": ["液环硬约束:禁用向量embedding做一致性判定", "记忆状态是演化对象而非被管理数据"],
347
+ "report_3": ["液环North-Star公理:自我调节的记忆状态演化机制", "液环禁止向量做一致性判定"],
348
+ "report_4": ["本地多agent协作架构已拆除", "今晚月色很好与液环无关"],
349
+ }
350
+ for rid, fs in facts.items():
351
+ n = ss.ingest(rid, "", facts=fs)
352
+ print(f" ingest {rid}: {n} 条规范化事实")
353
+
354
+ nuclei = ss.local_rotate()
355
+ print(f"\n 本地旋转 → 聚类 {len(ss._clusters)} 簇,本地核 {len(nuclei)} 个")
356
+ ss.print_summary()
357
+
358
+ # 断言:应识别出 ≥2 个跨篇核(禁向量 ×3篇、多agent拆除 ×2篇)
359
+ assert len(nuclei) >= 2, f"自测失败:未识别到跨篇核(得到 {len(nuclei)})"
360
+ # 断言自述性:本地回忆能命中
361
+ hit = ss.recall_local("液环 禁 向量 一致性")
362
+ assert hit and hit[0]["score"] > 0, "自测失败:本地回忆无命中"
363
+ print(f"\n ✓ 本地快自转自测通过(自述性命中 top1 score={hit[0]['score']})")
364
+
365
+ # 后端慢自转沉淀
366
+ if live:
367
+ print(f"\n → 真实入库(隔离 agent_id={ss.agent_id})...")
368
+ st = ss.deposit(category="selfspin", dry_run=False)
369
+ print(f" 沉积统计: {json.dumps(st, ensure_ascii=False)}")
370
+ assert st["nucleated"] >= 2, f"自测失败:后端未加速成核(nucleated={st['nucleated']})"
371
+ print(f" ✓ 后端慢自转沉淀自测通过(成核 {st['nucleated']} 个本地核)")
372
+ else:
373
+ st = ss.deposit(dry_run=True)
374
+ print(f"\n → dry-run 沉积模拟: {json.dumps(st, ensure_ascii=False)}")
375
+ print(f" ✓ 逻辑自测通过(--live 可真实入库验证成核加速)")
376
+ return True
377
+
378
+
379
+ def main():
380
+ ap = argparse.ArgumentParser(description="液环 v1.0 双层自转模块")
381
+ sub = ap.add_subparsers(dest="cmd")
382
+ st = sub.add_parser("selftest", help="单元自测(默认 dry-run)")
383
+ st.add_argument("--live", action="store_true", help="真实写 8790(隔离 exp:selfspin:*)")
384
+ args = ap.parse_args()
385
+ if args.cmd == "selftest":
386
+ _selftest(live=args.live)
387
+ else:
388
+ ap.print_help()
389
+
390
+
391
+ if __name__ == "__main__":
392
+ main()
@@ -577,6 +577,180 @@ class WorkspaceState:
577
577
  self._iteration += dt # 推进有效状态更新计数(τ)
578
578
  self.updated_at = now()
579
579
 
580
+ # ─────────────────────────────────────────────────────────────
581
+ # v0.9.3 多智能体授权原语
582
+ # 修复共识误删事故:把归属校验从各接入层 wrapper 固化进核心,
583
+ # 使 REST / MCP / trae 桥 / 任意未来 agent 共用同一套授权,杜绝重复实现漂移。
584
+ # 注:本段不调用 save(),落盘由调用方在变更生效时负责(与 add_evidence 等一致)。
585
+ # ─────────────────────────────────────────────────────────────
586
+
587
+ def list_for(self, agent_id: str = "", category: Optional[str] = None) -> list[dict]:
588
+ """归属隔离列表:共识仅对贡献者可见,私有仅对贡献者可见,他人证据隔离。
589
+
590
+ - evidence:仅返回 agent_id 本人的(agent_id 为空 → 不返回任何证据,避免泄漏)。
591
+ - consensus 结晶:仅当 agent_id 是其 contributors 之一才返回(修复误删根因)。
592
+ - private 结晶:仅当 agent_id 在 contributors 才返回。
593
+ - 未知 scope 一律不暴露。
594
+ """
595
+ out: list[dict] = []
596
+ if agent_id:
597
+ for e in self.evidences:
598
+ if e.agent_id != agent_id:
599
+ continue
600
+ cat = ""
601
+ anchor = next((a for a in self.anchors if a.id == e.anchor_id), None)
602
+ if anchor is not None:
603
+ cat = anchor.name
604
+ out.append({
605
+ "memory_id": e.id,
606
+ "type": "evidence",
607
+ "category": cat,
608
+ "content": e.content,
609
+ "agent_id": e.agent_id,
610
+ })
611
+ for m in self.memories:
612
+ if category and category != "(结晶)":
613
+ continue
614
+ if m.scope == "consensus":
615
+ if not agent_id or agent_id not in m.contributors:
616
+ continue
617
+ elif m.scope == "private":
618
+ if not agent_id or agent_id not in m.contributors:
619
+ continue
620
+ else:
621
+ continue
622
+ out.append({
623
+ "memory_id": m.id,
624
+ "type": "memory",
625
+ "category": "(结晶)",
626
+ "content": m.content,
627
+ "scope": m.scope,
628
+ "contributors": m.contributors,
629
+ })
630
+ return out
631
+
632
+ def delete_as(self, agent_id: str, memory_id: str) -> dict:
633
+ """带归属校验的删除(不自行落盘,由调用方 save)。
634
+
635
+ 约束:
636
+ - 缺 agent_id → 拒绝。
637
+ - evidence:仅所有者可删。
638
+ - private 结晶:仅贡献者可删。
639
+ - consensus 结晶:禁止单端删除(需全体一致 dissolve),返回明确错误。
640
+ - content 兜底:仅删调用者贡献的 private + 本人 evidence;命中 consensus 整体拒绝。
641
+ """
642
+ if not agent_id:
643
+ return {"ok": False, "error": "forbidden: agent_id required to delete",
644
+ "memory_id": memory_id}
645
+ ev = next((e for e in self.evidences if e.id == memory_id), None)
646
+ if ev is not None:
647
+ if ev.agent_id != agent_id:
648
+ return {"ok": False, "error": "forbidden: not owner of evidence",
649
+ "memory_id": memory_id}
650
+ for a in self.anchors:
651
+ if ev.anchor_id == a.id and memory_id in a.evidence_ids:
652
+ a.evidence_ids.remove(memory_id)
653
+ self.evidences.remove(ev)
654
+ return {"ok": True, "deleted": "evidence", "memory_id": memory_id}
655
+ mem = next((m for m in self.memories if m.id == memory_id), None)
656
+ if mem is not None:
657
+ if mem.scope == "consensus":
658
+ return {"ok": False,
659
+ "error": "forbidden: consensus memory requires unanimous contributor dissolution, not single-agent delete",
660
+ "memory_id": memory_id}
661
+ if agent_id not in mem.contributors:
662
+ return {"ok": False, "error": "forbidden: not a contributor of this private memory",
663
+ "memory_id": memory_id}
664
+ self.memories.remove(mem)
665
+ return {"ok": True, "deleted": "memory", "memory_id": memory_id}
666
+ mems = [m for m in self.memories if m.content == memory_id]
667
+ if mems:
668
+ if any(m.scope == "consensus" for m in mems):
669
+ return {"ok": False,
670
+ "error": "forbidden: content matches consensus memory; use unanimous dissolution",
671
+ "memory_id": memory_id}
672
+ owned = [m for m in mems if agent_id in m.contributors]
673
+ if not owned:
674
+ return {"ok": False, "error": "forbidden: no owned private memory matches content",
675
+ "memory_id": memory_id}
676
+ ev_ids = {eid for m in owned for eid in m.evidence_ids}
677
+ ev_ids |= {e.id for e in self.evidences if e.content == memory_id and e.agent_id == agent_id}
678
+ for m in owned:
679
+ self.memories.remove(m)
680
+ to_del = [e for e in self.evidences if e.id in ev_ids]
681
+ for e in to_del:
682
+ for a in self.anchors:
683
+ if e.id in a.evidence_ids:
684
+ a.evidence_ids.remove(e.id)
685
+ self.evidences.remove(e)
686
+ return {"ok": True, "deleted": "memory+evidence(owned only)", "memory_id": memory_id,
687
+ "memories": len(owned), "evidences": len(to_del)}
688
+ return {"ok": False, "error": "not_found", "memory_id": memory_id}
689
+
690
+ def dissolve_as(self, agent_id: str, memory_id: str, votes_root: Path) -> dict:
691
+ """consensus 结晶的合法移除:全体贡献者各调用一次,集齐才真删。
692
+
693
+ - 缺 agent_id / 非 consensus / 非贡献者 → 拒绝。
694
+ - 每贡献者一票;投票持久化于 votes_root/.dissolve_votes.json。
695
+ - 集齐 → 真删(移除 memory + 其底层 evidence),清票,返回 dissolved=True。
696
+ - 未集齐 → 记票返回进度,不删。
697
+ 不自行落盘 state(由调用方在 dissolved=True 时 save)。
698
+ """
699
+ if not agent_id:
700
+ return {"ok": False, "error": "forbidden: agent_id required to dissolve",
701
+ "memory_id": memory_id}
702
+ mem = next((m for m in self.memories if m.id == memory_id), None)
703
+ if mem is None:
704
+ return {"ok": False, "error": "not_found", "memory_id": memory_id}
705
+ if mem.scope != "consensus":
706
+ return {"ok": False, "error": "not_consensus: use delete_as for private memory",
707
+ "memory_id": memory_id}
708
+ if agent_id not in mem.contributors:
709
+ return {"ok": False, "error": "forbidden: not a contributor of this consensus",
710
+ "memory_id": memory_id}
711
+ votes = _load_dissolve_votes(votes_root)
712
+ cast = set(votes.get(memory_id, []))
713
+ cast.add(agent_id)
714
+ need = set(mem.contributors)
715
+ if cast >= need:
716
+ ev_ids = set(mem.evidence_ids)
717
+ self.memories.remove(mem)
718
+ to_del = [e for e in self.evidences if e.id in ev_ids]
719
+ for e in to_del:
720
+ for a in self.anchors:
721
+ if e.id in a.evidence_ids:
722
+ a.evidence_ids.remove(e.id)
723
+ self.evidences.remove(e)
724
+ votes.pop(memory_id, None)
725
+ _save_dissolve_votes(votes_root, votes)
726
+ return {"ok": True, "dissolved": True, "memory_id": memory_id,
727
+ "evidences": len(to_del), "voters": sorted(cast)}
728
+ votes[memory_id] = sorted(cast)
729
+ _save_dissolve_votes(votes_root, votes)
730
+ return {"ok": True, "dissolved": False, "memory_id": memory_id,
731
+ "voted": sorted(cast), "remaining": sorted(need - cast)}
732
+
733
+ # ── dissolve 投票持久化(独立于核心 dataclass,守 North-Star 零改动)──
734
+ def _dissolve_votes_path(root: Path) -> Path:
735
+ return Path(root) / ".dissolve_votes.json"
736
+
737
+
738
+ def _load_dissolve_votes(root: Path) -> dict:
739
+ p = _dissolve_votes_path(root)
740
+ try:
741
+ return json.loads(p.read_text(encoding="utf-8"))
742
+ except Exception:
743
+ return {}
744
+
745
+
746
+ def _save_dissolve_votes(root: Path, votes: dict) -> None:
747
+ p = _dissolve_votes_path(root)
748
+ try:
749
+ p.parent.mkdir(parents=True, exist_ok=True)
750
+ p.write_text(json.dumps(votes, ensure_ascii=False), encoding="utf-8")
751
+ except Exception:
752
+ pass
753
+
580
754
 
581
755
  # ==============================================================================
582
756
  # CPERegularizer — 能力保留正则化引擎(借鉴 UIUC CPE 论文 arXiv:2605.09315)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liquid-loop
3
- Version: 0.9.2
3
+ Version: 1.0.0
4
4
  Summary: Self-Organizing Cognitive Memory for AI Agents — Liquid Loop theory implementation
5
5
  Author: fishbook0001
6
6
  Maintainer: fishbook0001
@@ -6,6 +6,7 @@ liquid_loop/__main__.py
6
6
  liquid_loop/cli.py
7
7
  liquid_loop/cognitive_budget.py
8
8
  liquid_loop/entropy.py
9
+ liquid_loop/selfspin.py
9
10
  liquid_loop/storage.py
10
11
  liquid_loop/workspace.py
11
12
  liquid_loop.egg-info/PKG-INFO
@@ -16,6 +17,7 @@ liquid_loop.egg-info/requires.txt
16
17
  liquid_loop.egg-info/top_level.txt
17
18
  liquid_loop/mesh/__init__.py
18
19
  liquid_loop/mesh/v2.py
20
+ tests/test_auth_guard.py
19
21
  tests/test_consensus_expansion.py
20
22
  tests/test_entropy.py
21
23
  tests/test_layer1_persistence.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "liquid-loop"
7
- version = "0.9.2"
7
+ version = "1.0.0"
8
8
  description = "Self-Organizing Cognitive Memory for AI Agents — Liquid Loop theory implementation"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,162 @@
1
+ """v0.9.3 多智能体授权原语无死角测试。
2
+
3
+ 覆盖:读隔离(list_for) / 删除授权(delete_as) / 一致解散(dissolve_as)。
4
+ 复现并固化「共识误删事故」修复,确保授权逻辑是单一事实源(库核心),
5
+ 而非各接入层 wrapper 重复实现。
6
+ """
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from liquid_loop import WorkspaceState
13
+
14
+
15
+ @pytest.fixture
16
+ def ws():
17
+ s = WorkspaceState()
18
+ a = s.add_anchor("fact")
19
+ ev_vera = s.add_evidence(a, "vera 知道 X", agent_id="vera")
20
+ ev_trae = s.add_evidence(a, "trae 知道 Y", agent_id="trae")
21
+ # vera 私有结晶(由 vera 自己的 2 条证据成核)
22
+ ev_vera2 = s.add_evidence(a, "vera 知道 X(佐证)", agent_id="vera")
23
+ priv = s.add_memory("vera 的私有结论", [ev_vera.id, ev_vera2.id])
24
+ priv.scope = "private"
25
+ priv.contributors = ["vera"]
26
+ # 跨主体共识结晶(vera × trae)
27
+ cons = s.add_memory("vera 与 trae 共同结论", [ev_vera.id, ev_trae.id])
28
+ cons.scope = "consensus"
29
+ cons.contributors = ["vera", "trae"]
30
+ return s, dict(a=a, ev_vera=ev_vera, ev_trae=ev_trae,
31
+ priv=priv, cons=cons)
32
+
33
+
34
+ # ── 1. 读隔离 list_for ──────────────────────────────────────────────
35
+ def test_list_empty_agent_sees_nothing(ws):
36
+ s, _ = ws
37
+ out = s.list_for("")
38
+ assert out == [], "agent_id 为空时不得泄漏任何证据/结晶"
39
+
40
+
41
+ def test_list_vera_sees_own_and_consensus(ws):
42
+ s, d = ws
43
+ ids = {x["memory_id"] for x in s.list_for("vera")}
44
+ assert d["ev_vera"].id in ids # 自己的证据可见
45
+ assert d["cons"].id in ids # 作为贡献者,共识可见
46
+ assert d["priv"].id in ids # 自己的私有可见
47
+ assert d["ev_trae"].id not in ids # 他人证据隔离
48
+
49
+
50
+ def test_list_trae_sees_own_and_consensus_not_veras_private(ws):
51
+ s, d = ws
52
+ ids = {x["memory_id"] for x in s.list_for("trae")}
53
+ assert d["ev_trae"].id in ids
54
+ assert d["cons"].id in ids
55
+ assert d["ev_vera"].id not in ids
56
+ assert d["priv"].id not in ids # vera 私有对 trae 隔离
57
+
58
+
59
+ def test_list_consensus_hidden_from_non_contributor(ws):
60
+ s, d = ws
61
+ # 伪造一个「非贡献者」agent
62
+ ids = {x["memory_id"] for x in s.list_for("mallory")}
63
+ assert d["cons"].id not in ids, "共识仅对贡献者可见"
64
+ assert d["priv"].id not in ids
65
+
66
+
67
+ # ── 2. 删除授权 delete_as ──────────────────────────────────────────
68
+ def test_delete_requires_agent_id(ws):
69
+ s, d = ws
70
+ r = s.delete_as("", d["ev_vera"].id)
71
+ assert r["ok"] is False and "agent_id required" in r["error"]
72
+
73
+
74
+ def test_delete_evidence_only_by_owner(ws):
75
+ s, d = ws
76
+ bad = s.delete_as("trae", d["ev_vera"].id)
77
+ assert bad["ok"] is False and "not owner" in bad["error"]
78
+ good = s.delete_as("vera", d["ev_vera"].id)
79
+ assert good["ok"] is True and good["deleted"] == "evidence"
80
+
81
+
82
+ def test_delete_consensus_forbidden(ws):
83
+ s, d = ws
84
+ r = s.delete_as("vera", d["cons"].id)
85
+ assert r["ok"] is False and "consensus" in r["error"]
86
+
87
+
88
+ def test_delete_private_only_by_contributor(ws):
89
+ s, d = ws
90
+ bad = s.delete_as("trae", d["priv"].id)
91
+ assert bad["ok"] is False and "not a contributor" in bad["error"]
92
+ good = s.delete_as("vera", d["priv"].id)
93
+ assert good["ok"] is True and good["deleted"] == "memory"
94
+
95
+
96
+ def test_delete_by_content_owned_private(ws):
97
+ s, d = ws
98
+ # vera 的私有结晶按 content 兜底删除(仅删本人相关)
99
+ r = s.delete_as("vera", "vera 的私有结论")
100
+ assert r["ok"] is True and r["memories"] >= 1
101
+
102
+
103
+ def test_delete_by_content_hits_consensus_forbidden(ws):
104
+ s, d = ws
105
+ r = s.delete_as("vera", "vera 与 trae 共同结论")
106
+ assert r["ok"] is False and "consensus" in r["error"]
107
+
108
+
109
+ def test_delete_not_found(ws):
110
+ s, d = ws
111
+ r = s.delete_as("vera", "nonexistent-id")
112
+ assert r["ok"] is False and r["error"] == "not_found"
113
+
114
+
115
+ # ── 3. 一致解散 dissolve_as ────────────────────────────────────────
116
+ def test_dissolve_requires_agent_id(ws):
117
+ s, d = ws
118
+ root = Path(tempfile.mkdtemp())
119
+ r = s.dissolve_as("", d["cons"].id, root)
120
+ assert r["ok"] is False and "agent_id required" in r["error"]
121
+
122
+
123
+ def test_dissolve_rejects_private(ws):
124
+ s, d = ws
125
+ root = Path(tempfile.mkdtemp())
126
+ r = s.dissolve_as("vera", d["priv"].id, root)
127
+ assert r["ok"] is False and "not_consensus" in r["error"]
128
+
129
+
130
+ def test_dissolve_rejects_non_contributor(ws):
131
+ s, d = ws
132
+ root = Path(tempfile.mkdtemp())
133
+ r = s.dissolve_as("mallory", d["cons"].id, root)
134
+ assert r["ok"] is False and "not a contributor" in r["error"]
135
+
136
+
137
+ def test_dissolve_partial_then_unanimous(ws):
138
+ s, d = ws
139
+ root = Path(tempfile.mkdtemp())
140
+ # 第一票:vera,未集齐
141
+ r1 = s.dissolve_as("vera", d["cons"].id, root)
142
+ assert r1["dissolved"] is False
143
+ assert set(r1["remaining"]) == {"trae"}
144
+ assert d["cons"].id in {m.id for m in s.memories}, "未集齐不应真删"
145
+ # 第二票:trae,集齐 → 真删
146
+ r2 = s.dissolve_as("trae", d["cons"].id, root)
147
+ assert r2["dissolved"] is True
148
+ assert d["cons"].id not in {m.id for m in s.memories}
149
+ # 票已清
150
+ from liquid_loop.workspace import _load_dissolve_votes
151
+ assert d["cons"].id not in _load_dissolve_votes(root)
152
+
153
+
154
+ def test_dissolve_vote_persists_across_instances(ws):
155
+ """投票状态独立持久化,不依赖 WorkspaceState 内存(重启不丢票)"""
156
+ from liquid_loop.workspace import _load_dissolve_votes
157
+ s, d = ws
158
+ root = Path(tempfile.mkdtemp())
159
+ s.dissolve_as("vera", d["cons"].id, root)
160
+ # 重新从磁盘读票(模拟重启)
161
+ votes = _load_dissolve_votes(root)
162
+ assert d["cons"].id in votes and "vera" in votes[d["cons"].id]
File without changes
File without changes
File without changes