@furongjun1999/dsh-memory 0.5.0 → 0.5.1

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 (138) hide show
  1. package/README.md +552 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  4. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  5. package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
  6. package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
  7. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
  8. package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
  9. package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
  10. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v1.md +156 -0
  11. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v10.md +210 -0
  12. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v11.md +227 -0
  13. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v12.md +203 -0
  14. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v13.md +233 -0
  15. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v14.md +191 -0
  16. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v15.md +213 -0
  17. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v16.md +214 -0
  18. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v17.md +199 -0
  19. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v2.md +156 -0
  20. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v3.md +152 -0
  21. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v4.md +128 -0
  22. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v5.md +114 -0
  23. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v6.md +192 -0
  24. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v7.md +187 -0
  25. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v8.md +207 -0
  26. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v9.md +203 -0
  27. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
  28. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
  29. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  30. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  31. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
  32. package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
  33. package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
  34. package/lib/bridge.d.ts +9 -0
  35. package/lib/bridge.js +35 -0
  36. package/lib/index.js +7 -1
  37. package/lib/lib/roleplay_web.js +530 -443
  38. package/lib/lib/token_store.d.ts +7 -1
  39. package/lib/lib/token_store.js +12 -3
  40. package/md_cg/audit.py +12 -1
  41. package/md_cg/backfill.py +16 -15
  42. package/md_cg/backfill_bucket_zh.py +35 -0
  43. package/md_cg/bench_e2e_judge.py +532 -0
  44. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  45. package/md_cg/bench_e2e_qa.py +256 -0
  46. package/md_cg/branches.py +18 -2
  47. package/md_cg/ccgc.py +3 -2
  48. package/md_cg/chain.py +19 -4
  49. package/md_cg/consolidate.py +7 -6
  50. package/md_cg/crosscheck.py +4 -3
  51. package/md_cg/crypto.py +439 -437
  52. package/md_cg/datapath.py +395 -335
  53. package/md_cg/evidence.py +582 -580
  54. package/md_cg/export.py +3 -1
  55. package/md_cg/forgetting.py +2 -2
  56. package/md_cg/fsutil.py +48 -0
  57. package/md_cg/hotcache.py +255 -238
  58. package/md_cg/interop.py +161 -22
  59. package/md_cg/judgment_manifest.py +177 -0
  60. package/md_cg/links.py +655 -622
  61. package/md_cg/mcp_server.py +280 -29
  62. package/md_cg/mdcg.py +616 -125
  63. package/md_cg/mdcos.py +430 -66
  64. package/md_cg/mreview/govern.py +5 -4
  65. package/md_cg/postings.py +4 -2
  66. package/md_cg/readcache.py +76 -18
  67. package/md_cg/reconcile.py +228 -0
  68. package/md_cg/review_cli.py +170 -0
  69. package/md_cg/routing.py +28 -0
  70. package/md_cg/run_tests.py +211 -0
  71. package/md_cg/scrub.py +862 -852
  72. package/md_cg/security.py +385 -275
  73. package/md_cg/selfreport.py +3 -2
  74. package/md_cg/signer.py +565 -562
  75. package/md_cg/sources.py +3 -2
  76. package/md_cg/stg.py +6 -0
  77. package/md_cg/sustain.py +1168 -1138
  78. package/md_cg/test_access_hints.py +147 -0
  79. package/md_cg/test_branch_discard_tombstone.py +136 -0
  80. package/md_cg/test_branches.py +259 -249
  81. package/md_cg/test_chain_read_isolate.py +168 -0
  82. package/md_cg/test_datapath_device_name.py +203 -0
  83. package/md_cg/test_emit_negtail_cache.py +156 -0
  84. package/md_cg/test_en_pipeline.py +186 -166
  85. package/md_cg/test_govern_directread.py +421 -0
  86. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  87. package/md_cg/test_identity_attribution.py +228 -147
  88. package/md_cg/test_index_durability.py +238 -224
  89. package/md_cg/test_interop.py +4 -2
  90. package/md_cg/test_interop_judgment.py +228 -0
  91. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  92. package/md_cg/test_links_concurrent_write.py +188 -0
  93. package/md_cg/test_merge_upsert.py +168 -0
  94. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  95. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  96. package/md_cg/test_n131_merge_gate.py +205 -0
  97. package/md_cg/test_p1x_ref_root.py +160 -0
  98. package/md_cg/test_p27_docindex.py +774 -765
  99. package/md_cg/test_p2_mcp.py +3 -0
  100. package/md_cg/test_p32_backfill.py +304 -298
  101. package/md_cg/test_p39_verify_flow.py +90 -50
  102. package/md_cg/test_p47_session_view.py +60 -25
  103. package/md_cg/test_propose_tail_index.py +157 -0
  104. package/md_cg/test_read_scope_b27.py +277 -0
  105. package/md_cg/test_readcache_default_on.py +168 -0
  106. package/md_cg/test_readcache_precise_inval.py +270 -0
  107. package/md_cg/test_readcache_prodpath.py +55 -7
  108. package/md_cg/test_reconcile_v0.py +294 -0
  109. package/md_cg/test_retr_s1.py +6 -2
  110. package/md_cg/test_retr_s1b.py +67 -0
  111. package/md_cg/test_retr_s7.py +8 -0
  112. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  113. package/md_cg/test_retr_score_once.py +208 -0
  114. package/md_cg/test_review_onepass.py +170 -0
  115. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  116. package/md_cg/test_security_audit.py +155 -0
  117. package/md_cg/test_security_audit_b26.py +161 -0
  118. package/md_cg/test_security_audit_v21.py +250 -0
  119. package/md_cg/test_semantic_canonical.py +255 -241
  120. package/md_cg/test_session_isolation.py +168 -0
  121. package/md_cg/test_snapshot_autoclose.py +187 -0
  122. package/md_cg/test_tail_watermark_race.py +208 -0
  123. package/md_cg/test_tenant_env_override_warn.py +139 -0
  124. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  125. package/md_cg/test_v14_fixes.py +415 -397
  126. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  127. package/md_cg/theory.py +276 -273
  128. package/md_cg/tokens.py +734 -677
  129. package/md_cg/units.py +3 -2
  130. package/md_cg/vision_evidence.py +4 -3
  131. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  132. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  133. package/md_cg/writepipe.py +554 -550
  134. package/package.json +6 -2
  135. package/src/bridge.ts +434 -401
  136. package/src/index.ts +526 -518
  137. package/src/lib/roleplay_web.ts +1019 -932
  138. package/src/lib/token_store.ts +202 -192
@@ -0,0 +1,147 @@
1
+ # -*- coding: utf-8 -*-
2
+ """test_access_hints —— AccessDenied 可操作指引专项(issue #34)
3
+
4
+ 背景:新用户三步接入后必然撞上的第一条失败是 guest 只读写入被拒,而该错误
5
+ 无任何 hint(与本仓 md_cg/*.py 70 处 hint 标准不一致)——issue #34 判定为
6
+ 「同一仓内标准不一致,且恰落在最影响首次体验的位置」。
7
+
8
+ 覆盖:
9
+ - guest op 拒绝:hint 含为什么(MDCG_TOKEN 未配置)+ 怎么办(tokens issue /
10
+ setx / 重启 / README 指针)+ 安抚(不是故障)+ help 指引
11
+ - 有令牌但白名单缺 op:hint 指「换角色 / --ops-allow 补授权重签」(不误导成配凭据)
12
+ - 令牌过期:hint 指重新签发
13
+ - guest require_write:hint 同步到位(同口径)
14
+ - MCP stdio 端到端(issue 复现路径):干净库 + 无 MDCG_TOKEN → cg(op=write)
15
+ 的 error JSON 结构化携带 hint
16
+
17
+ 运行:python -X utf8 -m md_cg.test_access_hints
18
+ """
19
+ import json
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+
27
+ from md_cg.security import AccessDenied, Principal # noqa: E402
28
+
29
+ passed = failed = 0
30
+
31
+
32
+ def check(name, cond, detail=""):
33
+ global passed, failed
34
+ if cond:
35
+ passed += 1
36
+ print(" [PASS] " + name)
37
+ else:
38
+ failed += 1
39
+ print(" [FAIL] " + name + " " + str(detail)[:200])
40
+
41
+
42
+ def _denied(fn):
43
+ try:
44
+ fn()
45
+ return None
46
+ except AccessDenied as e:
47
+ return e
48
+
49
+
50
+ GUEST_OPS = ("info", "route", "read", "recent", "whitebox", "status", "edges")
51
+
52
+
53
+ def main():
54
+ print("[1] guest op 拒绝(issue #34 主场景)")
55
+ guest = Principal(actor="world", role="guest", ops_allow=GUEST_OPS,
56
+ can_write=False)
57
+ e = _denied(lambda: guest.require_op("write"))
58
+ check("guest op=write 抛 AccessDenied", e is not None)
59
+ h = (e.hint if e else "") or ""
60
+ for kw, label in [("MDCG_TOKEN", "点明凭据来源"),
61
+ ("tokens issue", "给签发命令"),
62
+ ("setx", "给配置动作"),
63
+ ("README", "给文档指针"),
64
+ ("不是故障", "安抚语气"),
65
+ ('cg(op="help"', "help 指引"),
66
+ ("重启", "重启生效提示")]:
67
+ check(f"hint {label}({kw})", kw in h, h[:120])
68
+ check("hint 非空且 error 消息保留原口径",
69
+ bool(h) and "无权执行 op=write" in str(e))
70
+
71
+ print("[2] 有令牌但白名单缺 op(不误导成配凭据)")
72
+ rec = Principal(actor="demo", role="recorder",
73
+ ops_allow=("read", "write", "recent"), can_write=True)
74
+ e2 = _denied(lambda: rec.require_op("forget"))
75
+ check("recorder op=forget 抛 AccessDenied", e2 is not None)
76
+ h2 = (e2.hint if e2 else "") or ""
77
+ check("hint 指补授权/换角色(--ops-allow / 重新签发)",
78
+ "--ops-allow" in h2 and "重新签发" in h2, h2[:120])
79
+ check("hint 不误指 guest 成因(不含 MDCG_TOKEN 未配置口径)",
80
+ "未检测到写入凭据" not in h2, h2[:120])
81
+
82
+ print("[3] 令牌过期")
83
+ exp = Principal(actor="demo", role="designer",
84
+ expires_at=1, can_write=True)
85
+ e3 = _denied(lambda: exp.require_op("read"))
86
+ check("过期抛 AccessDenied 且 hint 指重新签发",
87
+ e3 is not None and "重新签发" in (e3.hint or ""), e3.hint)
88
+
89
+ print("[4] guest require_write 同口径")
90
+ e4 = _denied(lambda: guest.require_write("internal"))
91
+ check("guest 写拒绝带 hint(含 MDCG_TOKEN)",
92
+ e4 is not None and "MDCG_TOKEN" in (e4.hint or ""), e4.hint)
93
+
94
+ print("[5] MCP stdio 端到端(issue 复现路径:干净库 + 无令牌)")
95
+ root = tempfile.mkdtemp(prefix="acc_hint_")
96
+ env = dict(os.environ)
97
+ env.pop("MDCG_TOKEN", None)
98
+ env["MDCG_ROOT"] = root
99
+ env["PYTHONUTF8"] = "1"
100
+ py = sys.executable
101
+ proc = subprocess.Popen(
102
+ [py, "-X", "utf8", "-m", "md_cg.mcp_server"],
103
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
104
+ env=env, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
105
+ text=True, encoding="utf-8")
106
+
107
+ def rpc(id_, method, params=None):
108
+ req = {"jsonrpc": "2.0", "id": id_, "method": method,
109
+ "params": params or {}}
110
+ proc.stdin.write(json.dumps(req) + "\n")
111
+ proc.stdin.flush()
112
+ line = proc.stdout.readline()
113
+ return json.loads(line) if line.strip() else {}
114
+
115
+ try:
116
+ rpc(1, "initialize", {"protocolVersion": "2024-11-05",
117
+ "capabilities": {}, "clientInfo": {"name": "t"}})
118
+ # 通知无应答:直写不等(带 id 等通知应答会错位/阻塞)
119
+ proc.stdin.write(json.dumps(
120
+ {"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
121
+ proc.stdin.flush()
122
+ r = rpc(3, "tools/call", {"name": "cg", "arguments": {
123
+ "op": "write", "content": "测试写入", "content_kind": "code"}})
124
+ proc.stdin.close()
125
+ body = ((r.get("result") or {}).get("content") or [{}])[0].get("text", "")
126
+ obj = json.loads(body) if body.startswith("{") else {}
127
+ check("stdio error JSON 含 AccessDenied", "AccessDenied" in obj.get("error", ""),
128
+ body[:160])
129
+ check("stdio error JSON 结构化携带 hint(含 MDCG_TOKEN 与签发命令)",
130
+ "MDCG_TOKEN" in (obj.get("hint") or "")
131
+ and "tokens issue" in (obj.get("hint") or ""),
132
+ (obj.get("hint") or "")[:160])
133
+ check("isError=True 形态", bool((r.get("result") or {}).get("isError")))
134
+ finally:
135
+ try:
136
+ proc.wait(timeout=10)
137
+ except Exception: # noqa: BLE001
138
+ proc.kill()
139
+ import shutil
140
+ shutil.rmtree(root, ignore_errors=True)
141
+
142
+ print(f"\naccess_hints: {passed} 通过 / {failed} 失败")
143
+ return 0 if not failed else 1
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ # -*- coding: utf-8 -*-
2
+ """branches.discard 摘索引落 tombstone 守卫(缺陷:只 pop 内存 → 幽灵条目固化)。
3
+
4
+ 缺陷形态(缺陷报告 2026-09-25 复现证实,medium):discard()(branches.py:261)
5
+ 摘除分支索引条目只 `pop` 内存、不写删除记录(未走 `_unstage`),且其内部
6
+ flush(:262-267)把 fork 阶段仍在 `_dirty` 的分支条目作为 **upsert** 持久化进
7
+ `_index_log`。close 时 compact 的「计数对账重扫 + 日志重放」(mdcg.py:1010-1012
8
+ `_apply_log(self._scan_nodes())`)把已物理移走(publish 至 `_branches/<id>/`,
9
+ LAYERS 之外)的分支 upsert **叠回**干净扫描之上——幽灵条目连同目录指纹一起固化
10
+ 进 `_index.json` 快照,此后重开指纹校验放行、幽灵永久存活(get()=None、
11
+ 检索候选白跑、dangling 不归零)。
12
+
13
+ 修复口径(forget 的正确模式,mdcos.py:2244-2246 注释明载同构坑):discard
14
+ 摘除分支条目走 `_unstage(nid)`——pop 内存 + `_dirty[nid]=None`(tombstone)
15
+ + 立即 flush,重放时按序 pop 掉 fork upsert。
16
+
17
+ 红守卫 = 缺陷报告复现流程:S1 主支落快照 → S2 fork+discard → S3 close 重开
18
+ 断言索引不含 `mem_base@br1` 幽灵条目。B1/B4/B5 锁 discard 既有语义不变
19
+ (返回形态 / 教训节点存活 / 主支完好),B2/B3/B6 为红项。
20
+ 运行:python -m md_cg.test_branch_discard_tombstone
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import shutil
26
+ import sys
27
+ import tempfile
28
+
29
+ from . import branches
30
+ from .fsutil import ShardedLog
31
+ from .mdcg import LAYERS
32
+ from .mdcos import MdCGOS
33
+
34
+ PASS = FAIL = 0
35
+ FAILS = []
36
+
37
+ BID = "br1"
38
+ GHOST = "mem_base@br1"
39
+ SUMMARY = "branch_summary_br1"
40
+ SUMMARY_TEXT = "分支假设:改写可提检索命中 实验结果:无提升 教训:回归再上分支"
41
+
42
+
43
+ def ok(cond, label):
44
+ global PASS, FAIL
45
+ if cond:
46
+ PASS += 1
47
+ print(f" OK {label}")
48
+ else:
49
+ FAIL += 1
50
+ FAILS.append(label)
51
+ print(f" FAIL {label}")
52
+
53
+
54
+ def _layer_md_ids(root):
55
+ """盘面真值:LAYERS 各层目录下现存 .md 的节点 id 集合(含子桶)。"""
56
+ ids = set()
57
+ for layer in LAYERS:
58
+ base = os.path.join(root, layer)
59
+ for _dp, _dirs, files in os.walk(base):
60
+ for fn in files:
61
+ if fn.endswith(".md"):
62
+ ids.add(fn[:-3])
63
+ return ids
64
+
65
+
66
+ def main():
67
+ os.environ.pop("MDCG_READ_CACHE", None)
68
+ tmp = tempfile.mkdtemp(prefix="mdcg_br_tomb_")
69
+ try:
70
+ root = os.path.join(tmp, "root")
71
+ os.makedirs(root)
72
+
73
+ # S1:主支节点入库并 close 落快照(此后 S2 的 close 走 compact 而非 rebuild)
74
+ cg0 = MdCGOS(root)
75
+ cg0.add("mem_base", "# 功能名:分支守卫主支\n\n正文:蜂群调度基线。\n",
76
+ layer="knowledge")
77
+ cg0.close()
78
+
79
+ # S2:fork → discard(fork 后不 flush——fork 阶段条目仍在 _dirty)
80
+ cg1 = MdCGOS(root)
81
+ fr = branches.fork(cg1, ["mem_base"], branch_id=BID,
82
+ note="守卫:命中提升实验")
83
+ ok(fr.get("ok") and fr["forked"][0]["to"] == GHOST,
84
+ f"B0a fork 出分支副本 {GHOST}")
85
+ r = branches.discard(cg1, BID, SUMMARY_TEXT)
86
+ ok(r.get("ok") is True and r.get("moved") == 1
87
+ and r.get("summary_node") == SUMMARY,
88
+ f"B1 discard 语义不变(ok/moved/summary_node,实测 {r!r})")
89
+
90
+ # B2(机制,红项):摘除必须落删除记录——discard 返回时 _index_log
91
+ # 已含该 nid 的 tombstone(e is None),且 _dirty 中无待写 upsert
92
+ recs = list(ShardedLog.read_all(cg1.index_log_dir))
93
+ ok(any(x.get("id") == GHOST and x.get("e") is None for x in recs),
94
+ f"B2 discard 已落 tombstone(日志含 {GHOST} 删除记录;实测"
95
+ f" {[(x.get('id'), x.get('e') is None) for x in recs]})")
96
+ ok(GHOST not in cg1._dirty
97
+ and GHOST not in cg1.index["nodes"],
98
+ "B2b 内存索引已摘除且 _dirty 无该节点待写条目")
99
+ cg1.close()
100
+
101
+ # S3:重开——幽灵条目不得复活
102
+ cg2 = MdCGOS(root)
103
+ idx_ids = set((cg2.index.get("nodes") or {}).keys())
104
+ ok(GHOST not in idx_ids,
105
+ f"B3 重开后索引不含幽灵条目 {GHOST}(实测索引节点 "
106
+ f"{sorted(idx_ids)})")
107
+ ok("mem_base" in idx_ids and SUMMARY in idx_ids,
108
+ "B4 主支节点与教训节点存活(discard 其余语义不变)")
109
+ disk_ids = _layer_md_ids(root)
110
+ ok(idx_ids == disk_ids,
111
+ f"B5 索引与盘面对账一致(索引 {sorted(idx_ids)} / 盘面 "
112
+ f"{sorted(disk_ids)})")
113
+ ok(cg2.get(GHOST) is None and cg2.get("mem_base") is not None,
114
+ "B6 幽灵不可取回、主支可正常读取")
115
+ ok(cg2.get(SUMMARY) is not None
116
+ and "教训" in (cg2.get(SUMMARY) or {}).get("content", ""),
117
+ "B7 教训节点内容可读(可检索召回)")
118
+ # 冷归档物理保留(不违背永不删除)
119
+ cold = os.path.join(root, branches.COLD_DIR, BID,
120
+ GHOST + ".md")
121
+ ok(os.path.isfile(cold), f"B8 分支文件冷归档保留({cold})")
122
+ cg2.close()
123
+ finally:
124
+ os.environ.pop("MDCG_READ_CACHE", None)
125
+ shutil.rmtree(tmp, ignore_errors=True)
126
+
127
+ print("\n" + "=" * 60)
128
+ print(f"结果:PASS {PASS} / FAIL {FAIL}")
129
+ if FAILS:
130
+ for f in FAILS:
131
+ print(f" - {f}")
132
+ return 0 if FAIL == 0 else 1
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())