@furongjun1999/dsh-memory 0.5.0 → 0.6.0

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 (156) hide show
  1. package/README.md +572 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  4. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  5. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  6. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  7. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  8. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  9. 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
  10. 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
  11. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  12. 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
  13. 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
  14. 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
  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_v1.md +156 -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_v10.md +210 -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_v11.md +227 -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_v12.md +203 -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_v13.md +233 -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_v14.md +191 -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_v15.md +213 -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_v16.md +214 -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_v17.md +199 -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_v18.md +183 -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_v19.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_v2.md +156 -0
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. 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
  36. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  37. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  38. 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
  39. 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
  40. 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
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.d.ts +9 -0
  43. package/lib/bridge.js +35 -0
  44. package/lib/cli.d.ts +3 -0
  45. package/lib/cli.js +66 -0
  46. package/lib/index.js +7 -1
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +287 -0
  49. package/lib/lib/roleplay_web.js +530 -443
  50. package/lib/lib/token_store.d.ts +7 -1
  51. package/lib/lib/token_store.js +12 -3
  52. package/md_cg/audit.py +12 -1
  53. package/md_cg/backfill.py +16 -15
  54. package/md_cg/backfill_bucket_zh.py +35 -0
  55. package/md_cg/bench_e2e_judge.py +532 -0
  56. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  57. package/md_cg/bench_e2e_qa.py +256 -0
  58. package/md_cg/branches.py +18 -2
  59. package/md_cg/ccgc.py +3 -2
  60. package/md_cg/chain.py +19 -4
  61. package/md_cg/consolidate.py +7 -6
  62. package/md_cg/crosscheck.py +4 -3
  63. package/md_cg/crypto.py +439 -437
  64. package/md_cg/datapath.py +395 -335
  65. package/md_cg/evidence.py +8 -3
  66. package/md_cg/export.py +3 -1
  67. package/md_cg/forgetting.py +2 -2
  68. package/md_cg/fsutil.py +48 -0
  69. package/md_cg/hotcache.py +255 -238
  70. package/md_cg/interop.py +161 -22
  71. package/md_cg/judgment_manifest.py +177 -0
  72. package/md_cg/links.py +655 -622
  73. package/md_cg/mcp_server.py +282 -31
  74. package/md_cg/mdcg.py +702 -126
  75. package/md_cg/mdcos.py +454 -68
  76. package/md_cg/mreview/govern.py +5 -4
  77. package/md_cg/postings.py +4 -2
  78. package/md_cg/readcache.py +76 -18
  79. package/md_cg/reconcile.py +228 -0
  80. package/md_cg/review_cli.py +215 -0
  81. package/md_cg/routing.py +28 -0
  82. package/md_cg/run_tests.py +211 -0
  83. package/md_cg/scrub.py +862 -852
  84. package/md_cg/security.py +385 -275
  85. package/md_cg/selfreport.py +3 -2
  86. package/md_cg/signer.py +565 -562
  87. package/md_cg/sources.py +3 -2
  88. package/md_cg/stg.py +6 -0
  89. package/md_cg/sustain.py +1168 -1138
  90. package/md_cg/test_access_hints.py +147 -0
  91. package/md_cg/test_branch_discard_tombstone.py +136 -0
  92. package/md_cg/test_branches.py +259 -249
  93. package/md_cg/test_chain_read_isolate.py +168 -0
  94. package/md_cg/test_datapath_device_name.py +203 -0
  95. package/md_cg/test_emit_negtail_cache.py +156 -0
  96. package/md_cg/test_en_pipeline.py +186 -166
  97. package/md_cg/test_govern_directread.py +421 -0
  98. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  99. package/md_cg/test_identity_attribution.py +228 -147
  100. package/md_cg/test_index_crossprocess_reload.py +301 -0
  101. package/md_cg/test_index_durability.py +238 -224
  102. package/md_cg/test_interop.py +4 -2
  103. package/md_cg/test_interop_judgment.py +228 -0
  104. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  105. package/md_cg/test_links_concurrent_write.py +188 -0
  106. package/md_cg/test_merge_upsert.py +168 -0
  107. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  108. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  109. package/md_cg/test_n131_merge_gate.py +205 -0
  110. package/md_cg/test_none_id_write_guard.py +165 -0
  111. package/md_cg/test_p1x_ref_root.py +160 -0
  112. package/md_cg/test_p27_docindex.py +774 -765
  113. package/md_cg/test_p2_mcp.py +3 -0
  114. package/md_cg/test_p32_backfill.py +304 -298
  115. package/md_cg/test_p39_verify_flow.py +90 -50
  116. package/md_cg/test_p47_session_view.py +60 -25
  117. package/md_cg/test_propose_tail_index.py +157 -0
  118. package/md_cg/test_read_scope_b27.py +277 -0
  119. package/md_cg/test_readcache_default_on.py +168 -0
  120. package/md_cg/test_readcache_precise_inval.py +270 -0
  121. package/md_cg/test_readcache_prodpath.py +55 -7
  122. package/md_cg/test_reconcile_v0.py +294 -0
  123. package/md_cg/test_retr_s1.py +6 -2
  124. package/md_cg/test_retr_s1b.py +67 -0
  125. package/md_cg/test_retr_s7.py +8 -0
  126. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  127. package/md_cg/test_retr_score_once.py +208 -0
  128. package/md_cg/test_review_cli_attribution.py +177 -0
  129. package/md_cg/test_review_cli_visibility.py +235 -0
  130. package/md_cg/test_review_onepass.py +170 -0
  131. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  132. package/md_cg/test_security_audit.py +155 -0
  133. package/md_cg/test_security_audit_b26.py +161 -0
  134. package/md_cg/test_security_audit_v21.py +250 -0
  135. package/md_cg/test_semantic_canonical.py +255 -241
  136. package/md_cg/test_session_isolation.py +168 -0
  137. package/md_cg/test_snapshot_autoclose.py +187 -0
  138. package/md_cg/test_tail_watermark_race.py +208 -0
  139. package/md_cg/test_tenant_env_override_warn.py +139 -0
  140. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  141. package/md_cg/test_v14_fixes.py +415 -397
  142. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  143. package/md_cg/theory.py +276 -273
  144. package/md_cg/tokens.py +734 -677
  145. package/md_cg/units.py +3 -2
  146. package/md_cg/vision_evidence.py +4 -3
  147. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  148. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  149. package/md_cg/writepipe.py +554 -550
  150. package/package.json +11 -2
  151. package/src/bridge.ts +434 -401
  152. package/src/cli.ts +65 -0
  153. package/src/index.ts +526 -518
  154. package/src/init.ts +347 -0
  155. package/src/lib/roleplay_web.ts +1019 -932
  156. package/src/lib/token_store.ts +202 -192
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """读缓存脏集精确失效守卫(缺陷迭代第 14 轮,high:写读交替整池重装)。
4
+
5
+ 缺陷形态(缺陷报告实测,10k 临时库串行 3rep 中位):
6
+ readcache._cached 的代际哨兵取**全局单调** write_gen(整池共用一个值),
7
+ _DirtyDict 每次变更 +1——任意一次单节点写使**全部**缓存条目同时 miss,
8
+ 下一条查询全池重装(open+parse 全部 N 个节点):稳态 41.3ms/loads=0 →
9
+ 每 rep add 1 节点后同查询 1732.4ms/loads=10001(41.9× 退化),10k/20k/30k
10
+ 写后首查 1732/3515/5230ms 线性于 N。生产暴露面:mcp_server 读路径
11
+ (op=read→search/recall)与写路径(op=write→writepipe._executor→cg.add,
12
+ 链尾 _commit_visibility→flush)同一常驻实例——写读交替负载 O(每写×全池)。
13
+
14
+ 修复口径:_DirtyDict 簿记**脏 path 代际**(path_gen[path]=标脏时 write_gen),
15
+ 读缓存按「该 path 最近标脏代际 ≤ 缓存代际」判新鲜——单节点写只失效该节点;
16
+ tombstone(_dirty[nid]=None,无 path 可辨)/其余变更形态走 broad_gen 保守
17
+ 整池失效(与旧整代际口径等价的安全兜底);clear()(flush/rebuild)只推进
18
+ write_gen 不再整池失效——flush 只落索引派生物(分片日志),节点文件的内容
19
+ 变更在各写路径 `_dirty[nid]=entry`(带 path)时已精确失效;且生产写路径
20
+ 每次写后 flush(writepipe._commit_visibility),clear 若整池失效会让精确
21
+ 失效在生产写读交替负载下恒不生效。
22
+
23
+ 本守卫九项(调用计数/复杂度断言形态,非时间阈值):
24
+ G1 写读交替不整池重装:热缓存后 add 1 节点 → 同查询 loads ≤ 2
25
+ (旧整代际实现 ≈ 池+1 全量 → 红);
26
+ G2 生产写提交边界(add+flush)后同查询 loads ≤ 2(旧实现 flush 的
27
+ clear() 再叠一次整池 miss → 红);
28
+ G3 写后不陈旧(正确性保持,P3/V5 同口径):覆写既有节点 → 检索命中
29
+ 新内容、_read 缓存视图见新值;
30
+ G4 写别的节点不误伤:add B 后 A 的缓存条目 identity 不变(不重读);
31
+ G5 删除保守整池失效:_unstage(tombstone)后同查询 loads ≥ 剩余池规模
32
+ (无 path 可辨 → broad 兜底,与旧整代词语义等价,不陈旧);
33
+ G6 _DirtyDict 簿记直断:带 path 标脏 → path_gen 推进且 broad_gen 不动;
34
+ tombstone → broad_gen 推进;flush 形态 clear() 不动 path_gen/broad_gen、
35
+ clear(broad=True)(rebuild 收尾)推进 broad_gen;write_gen 单调不回退
36
+ (P7b 口径保留);
37
+ G7 派生物缓存(_doc_norm_bigrams)同口径精确失效:add 1 节点后旧条目
38
+ identity 不变(不重算);
39
+ G8 「直写文件 + rebuild_index 收尾」写方(ccgc/crosscheck/backfill 族)
40
+ 的缓存可见性:rebuild 的 clear(broad=True) 整池兜底——缓存视图见
41
+ 新内容且整池重装(回归面:全量套件 test_ccgc V4c/V5d 曾因 rebuild
42
+ 不失效而陈旧红)。
43
+
44
+ 运行:python -m md_cg.test_readcache_precise_inval
45
+ """
46
+ import os
47
+ import shutil
48
+ import sys
49
+ import tempfile
50
+
51
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
52
+
53
+ from md_cg import nodefile # noqa: E402
54
+ from md_cg.mdcos import MdCGOS # noqa: E402
55
+
56
+ PASS = 0
57
+ FAIL = 0
58
+ FAILS = []
59
+
60
+ N = 40
61
+ QUERY = "蜂群调度"
62
+
63
+
64
+ def check(name, cond, detail=""):
65
+ global PASS, FAIL
66
+ if cond:
67
+ PASS += 1
68
+ print(f" OK {name}")
69
+ else:
70
+ FAIL += 1
71
+ FAILS.append(name)
72
+ print(f" FAIL {name} {detail}")
73
+
74
+
75
+ def build_lib(root):
76
+ cg = MdCGOS(root)
77
+ for i in range(N):
78
+ cg.add("mem_%03d" % i,
79
+ "# 功能名:精确失效样本 %d\n# 正文:%s 节点 %d" % (i, QUERY, i),
80
+ layer="knowledge")
81
+ cg.flush()
82
+ return cg
83
+
84
+
85
+ def count_loads(fn):
86
+ """打桩 nodefile.loads 计数(节点文件解析唯一入口,_read 内调用)。"""
87
+ n = 0
88
+ real = nodefile.loads
89
+
90
+ def _spy(*a, **kw):
91
+ nonlocal n
92
+ n += 1
93
+ return real(*a, **kw)
94
+
95
+ nodefile.loads = _spy
96
+ try:
97
+ return fn(), n
98
+ finally:
99
+ nodefile.loads = real
100
+
101
+
102
+ def main():
103
+ root = tempfile.mkdtemp(prefix="mdcg_rc_precise_")
104
+ try:
105
+ os.environ.pop("MDCG_READ_CACHE", None) # 默认开(生产默认态)
106
+ cg = build_lib(root)
107
+ check("G0a 库就绪且读缓存默认装配",
108
+ len(cg.index["nodes"]) == N and hasattr(cg, "_read_cache"),
109
+ f"nodes={len(cg.index['nodes'])}")
110
+
111
+ # 预热:首查装缓存,二次查询零重解析(稳态前提)
112
+ cg.search(QUERY)
113
+ _, n_warm2 = count_loads(lambda: cg.search(QUERY))
114
+ check("G0b 稳态二次查询零重解析(loads==0)", n_warm2 == 0,
115
+ f"loads={n_warm2}")
116
+
117
+ # G1 写读交替不整池重装:add 1 节点 → 同查询只解析新节点
118
+ print("== G1 写读交替精确失效 ==")
119
+ cg.add("g1_new", "# 功能名:新增节点\n# 正文:%s 新增内容G1" % QUERY,
120
+ layer="knowledge")
121
+ r1, n1 = count_loads(lambda: cg.search(QUERY)[0])
122
+ check("G1a add 1 节点后同查询 loads ≤ 2(旧整代际 ≈ 池+1 全量)",
123
+ n1 <= 2, f"loads={n1} pool={N + 1}")
124
+ check("G1b 新节点可检索(不陈旧)",
125
+ any(x[0].get("id") == "g1_new" for x in r1),
126
+ f"ids={[x[0].get('id') for x in r1][:5]}")
127
+
128
+ # G4 写别的节点不误伤:A 的缓存条目 identity 不变
129
+ print("== G4 邻居条目不误伤 ==")
130
+ pa = cg.index["nodes"]["mem_000"]["path"]
131
+ snap = cg._read_cache.get(pa)
132
+ check("G4pre A 条目已在缓存", snap is not None)
133
+ cg.add("g4_other", "# 功能名:邻居写入\n# 正文:%s 邻居G4" % QUERY,
134
+ layer="knowledge")
135
+ count_loads(lambda: cg.search(QUERY))
136
+ cur = cg._read_cache.get(pa)
137
+ check("G4a 写其它节点后 A 缓存条目 identity 不变(不重读)",
138
+ cur is not None and cur is snap,
139
+ f"same={cur is snap}")
140
+
141
+ # G2 生产写提交边界:add + flush(writepipe._commit_visibility 等价形态)
142
+ print("== G2 写提交边界(add+flush)不整池重装 ==")
143
+ cg.add("g2_flush", "# 功能名:管线节点\n# 正文:%s 管线G2" % QUERY,
144
+ layer="knowledge")
145
+ cg.flush()
146
+ r2, n2 = count_loads(lambda: cg.search(QUERY)[0])
147
+ check("G2a add+flush 后同查询 loads ≤ 2(clear 不整池失效)",
148
+ n2 <= 2, f"loads={n2} pool={N + 3}")
149
+ check("G2b flush 后新节点可检索(不陈旧)",
150
+ any(x[0].get("id") == "g2_flush" for x in r2),
151
+ f"ids={[x[0].get('id') for x in r2][:5]}")
152
+
153
+ # G3 写后不陈旧(正确性保持):覆写既有节点
154
+ print("== G3 覆写后不陈旧 ==")
155
+ cg.add("mem_000", "# 功能名:覆写样本\n# 正文:%s 更新后内容BBB 独有标记G3" % QUERY,
156
+ layer="knowledge")
157
+ entry = cg.index["nodes"]["mem_000"]
158
+ fm_v, c_v = cg._read(entry) # 缓存视图(生产读形态)
159
+ check("G3a 覆写后 _read 缓存视图见新内容(不陈旧)",
160
+ c_v is not None and "BBB" in c_v, f"content={str(c_v)[:40]}")
161
+ r3, _ = count_loads(lambda: cg.search("更新后内容BBB 独有标记G3")[0])
162
+ got = next((x[0].get("content") for x in r3
163
+ if x[0].get("id") == "mem_000"), None)
164
+ check("G3b 覆写后检索命中新内容",
165
+ got is not None and "BBB" in got, f"content={str(got)[:40]}")
166
+
167
+ # G7 派生物缓存同口径:add 1 节点后旧条目 identity 不变(不重算)
168
+ print("== G7 派生物缓存(_doc_norm_bigrams)不误伤 ==")
169
+ cg.search(QUERY) # 装齐派生物缓存
170
+ nbc = getattr(cg, "_norm_bigrams_cache", {})
171
+ snap_nb = {k: v[1] for k, v in nbc.items()}
172
+ check("G7pre 派生物缓存已装载", len(snap_nb) > 0,
173
+ f"entries={len(snap_nb)}")
174
+ cg.add("g7_new", "# 功能名:派生物样本\n# 正文:%s 派生物G7" % QUERY,
175
+ layer="knowledge")
176
+ cg.search(QUERY)
177
+ nbc2 = getattr(cg, "_norm_bigrams_cache", {})
178
+ same = all(nbc2[k][1] is snap_nb[k] for k in snap_nb if k in nbc2)
179
+ check("G7a 写 1 节点后旧派生物条目零重算(identity 不变)",
180
+ same and len(snap_nb) > 0, f"snap={len(snap_nb)}")
181
+
182
+ # G5 删除保守整池失效(tombstone 无 path 可辨 → broad 兜底)
183
+ print("== G5 删除保守整池失效 ==")
184
+ cg._unstage("mem_001")
185
+ left = len(cg.index["nodes"])
186
+ _, n5 = count_loads(lambda: cg.search(QUERY)[0])
187
+ check("G5a _unstage 后同查询整池重装(loads ≥ 剩余池规模)",
188
+ n5 >= left, f"loads={n5} left={left}")
189
+
190
+ # G6 _DirtyDict 簿记直断
191
+ print("== G6 _DirtyDict 脏集簿记 ==")
192
+ from md_cg.mdcg import _DirtyDict
193
+ d = _DirtyDict()
194
+ pg = getattr(d, "path_gen", None)
195
+ bg = getattr(d, "broad_gen", None)
196
+ check("G6a path_gen/broad_gen 簿记就位", pg is not None and bg is not None)
197
+ if pg is not None:
198
+ g0 = d.write_gen
199
+ d["n1"] = {"path": "knowledge/n1.md", "layer": "knowledge"}
200
+ g1 = d.write_gen
201
+ check("G6b 带 path 标脏 → path_gen 记录、broad_gen 不动",
202
+ d.path_gen.get("knowledge/n1.md") == g1 > g0
203
+ and d.broad_gen == 0)
204
+ d["n2"] = {"path": "knowledge/n2.md", "layer": "knowledge"}
205
+ check("G6c 写另一 path 不推进前一 path 的脏代际",
206
+ d.path_gen.get("knowledge/n1.md") == g1
207
+ and d.path_gen.get("knowledge/n2.md") == d.write_gen)
208
+ d["n3"] = None # tombstone:无 path 可辨
209
+ check("G6d tombstone → broad_gen 保守推进",
210
+ d.broad_gen == d.write_gen > g1)
211
+ d.clear() # flush 收尾形态
212
+ check("G6e flush 形态 clear() 推进 write_gen 但不动 broad/path_gen",
213
+ d.write_gen > d.broad_gen
214
+ and d.path_gen.get("knowledge/n1.md") == g1)
215
+ d["n4"] = 1 # 非 entry 形态值
216
+ check("G6f 无 path 可辨的值 → broad_gen 保守推进",
217
+ d.broad_gen == d.write_gen)
218
+ d.clear(broad=True) # rebuild_index 收尾形态
219
+ check("G6h clear(broad=True) → broad_gen 推进(rebuild 兜底)",
220
+ d.broad_gen == d.write_gen)
221
+ gens = []
222
+ d2 = _DirtyDict()
223
+ d2["a"] = {"path": "x"}
224
+ gens.append(d2.write_gen)
225
+ d2["b"] = None
226
+ gens.append(d2.write_gen)
227
+ d2.clear()
228
+ gens.append(d2.write_gen)
229
+ d2["c"] = {"path": "y"}
230
+ gens.append(d2.write_gen)
231
+ check("G6g write_gen 单调不回退(P7b 口径保留)",
232
+ gens == sorted(gens) and len(set(gens)) == 4, f"gens={gens}")
233
+
234
+ # G8 「直写文件 + rebuild_index 收尾」写方(ccgc/crosscheck/backfill
235
+ # 族)的缓存可见性:直写不经 _dirty 标脏,靠 rebuild 的
236
+ # clear(broad=True) 整池兜底——回归面:全量套件 test_ccgc V4c/V5d
237
+ # 曾因 rebuild 不失效而陈旧红。
238
+ print("== G8 直写+rebuild 收尾写方缓存可见性 ==")
239
+ cg.search(QUERY) # 热缓存
240
+ e8 = cg.index["nodes"]["mem_002"]
241
+ fm8, c8 = cg._read(e8)
242
+ cg._write_node("mem_002", cg._node_disk_path(e8), fm8,
243
+ "# 功能名:直写样本\n# 正文:%s 直写后内容G8 独有标记" % QUERY)
244
+ cg.rebuild_index()
245
+ _, n8 = count_loads(lambda: cg.search(QUERY)[0])
246
+ check("G8b rebuild 后整池重装(broad 兜底,loads ≥ 池规模)",
247
+ n8 >= len(cg.index["nodes"]),
248
+ f"loads={n8} pool={len(cg.index['nodes'])}")
249
+ e8b = cg.index["nodes"]["mem_002"]
250
+ fm8b, c8b = cg._read(e8b) # 缓存视图
251
+ check("G8a 直写+rebuild 后缓存视图见新内容(不陈旧)",
252
+ c8b is not None and "直写后内容G8" in c8b,
253
+ f"content={str(c8b)[:40]}")
254
+
255
+ # 收尾清理
256
+ cg.close()
257
+ finally:
258
+ os.environ.pop("MDCG_READ_CACHE", None)
259
+ shutil.rmtree(root, ignore_errors=True)
260
+
261
+ print("\n" + "=" * 60)
262
+ print(f"结果:PASS {PASS} / FAIL {FAIL}")
263
+ if FAILS:
264
+ for f in FAILS:
265
+ print(f" - {f}")
266
+ return 0 if FAIL == 0 else 1
267
+
268
+
269
+ if __name__ == "__main__":
270
+ sys.exit(main())
@@ -7,8 +7,9 @@
7
7
  - install_read_cache 6.53x(126→19.3 ms)但只用于 benchmark;
8
8
  - path-only 无失效的朴素实现在「写入后检索」场景**陈旧读**(旧内容命中)。
9
9
 
10
- 本守卫五项:
11
- P1 默认关零变更(env 未设 → 行为与改动前逐位一致);
10
+ 本守卫七项:
11
+ P1 默认开(env 未设 → 构造即装)+ MDCG_READ_CACHE=0 opt-out 零变更退出阀
12
+ (issue #31 后续:默认关曾让生产入口每查询全池 open+parse);
12
13
  P2 开启后检索结果与关闭态一致(缓存不改变结果,只改变速度);
13
14
  P3 写后失效(能红 path-only 无失效实现):写入新内容 → 检索必须命中
14
15
  新内容、不得命中旧内容——哨兵(_dirty 代际)失效的核心断言;
@@ -81,13 +82,28 @@ def main():
81
82
  os.environ.pop("MDCG_READ_CACHE", None)
82
83
  cg = build_lib(root)
83
84
  try:
84
- # P1 默认关零变更
85
- print("== P1 默认关(env 未设)零变更 ==")
86
- check("P1a 未启用时无 _read_cache 属性", not hasattr(cg, "_read_cache"))
85
+ # P1 默认开 + opt-out 退出阀(issue #31 后续:默认关曾让生产检索入口
86
+ # 每查询全池 open+parse,3300 池实测 ~602ms/查询——默认翻转为本形态)
87
+ print("== P1 默认开(env 未设)+ MDCG_READ_CACHE=0 退出阀 ==")
88
+ check("P1a 默认(env 未设)构造即装读缓存", hasattr(cg, "_read_cache"))
87
89
  r0, _ = cg.search("蜂群调度")
88
90
  check("P1b 检索正常(基线)", len(r0) >= 1, f"results={len(r0)}")
89
-
90
- # P2/P3/P4 开启态
91
+ os.environ["MDCG_READ_CACHE"] = "0"
92
+ try:
93
+ root_o = os.path.join(root, "optout")
94
+ os.makedirs(root_o, exist_ok=True)
95
+ cg_o = MdCGSecure(root_o, principal=Principal(
96
+ actor="rc", clearance="secret", can_write=True,
97
+ role="designer", auth_mode="test"))
98
+ check("P1c MDCG_READ_CACHE=0 → 未装缓存(opt-out 零变更退出阀)",
99
+ not hasattr(cg_o, "_read_cache"))
100
+ r_o, _ = cg_o.search("占位") # 关态检索照常(空结果非崩溃即证)
101
+ del cg_o
102
+ finally:
103
+ os.environ.pop("MDCG_READ_CACHE", None)
104
+
105
+ # P2/P3/P4 开启态(默认已装配;显式 =1 与默认等价,install 再包一层
106
+ # 同语义缓存——历史形态保留,断言面不变)
91
107
  print("== P2/P3/P4 MDCG_READ_CACHE=1 ==")
92
108
  os.environ["MDCG_READ_CACHE"] = "1"
93
109
  import importlib
@@ -136,6 +152,38 @@ def main():
136
152
  n = rc.clear(cg)
137
153
  check("P5 clear 返回清空条目数", n > 0, f"cleared={n}")
138
154
 
155
+ # P7 写代际单调(批次 23,issue #31 D-4 / v20 复现场景,能红 len 代际)
156
+ print("== P7 flush 后同量写入不回退(D-4 陈旧读)==")
157
+ cg.add("x_d4", "# 功能名:占位\n# 正文:原始内容AAA 星云", layer="knowledge")
158
+ cg.add("a_d4", "# 功能名:a\n# 正文:填充甲", layer="knowledge")
159
+ cg.add("b_d4", "# 功能名:b\n# 正文:填充乙", layer="knowledge")
160
+ cg.flush()
161
+ cg.add("c_d4", "# 功能名:c\n# 正文:填充丙", layer="knowledge")
162
+ cg.add("d_d4", "# 功能名:d\n# 正文:填充丁", layer="knowledge")
163
+ cg.search("原始内容AAA") # 装 x=OLD(此时 dirty len=2)
164
+ cg.add("x_d4", "# 功能名:占位\n# 正文:更新后内容BBB 星云",
165
+ layer="knowledge") # 覆写 x=NEW
166
+ cg.flush() # dirty 清零(len 代际在此回退)
167
+ cg.add("e_d4", "# 功能名:e\n# 正文:填充戊", layer="knowledge")
168
+ cg.add("f_d4", "# 功能名:f\n# 正文:填充己", layer="knowledge") # len 巧合回到 2
169
+ r_new, _ = cg.search("更新后内容BBB 星云") # 写 NEW 后检索必须见 NEW
170
+ got = next((it[0].get("content") for it in r_new
171
+ if it[0].get("id") == "x_d4"), None)
172
+ check("P7a flush 回退场景:检索新内容返回 NEW(len 代际实现必返 OLD)",
173
+ got is not None and "BBB" in got,
174
+ f"content={str(got)[:40]}")
175
+
176
+ # P7b 哨兵单调性直断:_DirtyDict 变更序列 write_gen 永不回退
177
+ d = __import__("md_cg.mdcg", fromlist=["_DirtyDict"])._DirtyDict()
178
+ gens = []
179
+ d["a"] = 1; gens.append(d.write_gen)
180
+ d["b"] = 2; gens.append(d.write_gen)
181
+ d.clear(); gens.append(d.write_gen)
182
+ d["c"] = 3; gens.append(d.write_gen)
183
+ check("P7b write_gen 单调不回退(clear 后再写仍前进)",
184
+ gens == sorted(gens) and len(set(gens)) == 4,
185
+ f"gens={gens}")
186
+
139
187
  # 还原
140
188
  os.environ.pop("MDCG_READ_CACHE", None)
141
189
  finally:
@@ -0,0 +1,294 @@
1
+ # -*- coding: utf-8 -*-
2
+ """test_reconcile_v0.py · 启动对账 reconcile v0 守卫(v0.3 T9 落地,落地清单 P0-3)
3
+
4
+ 现场:两段式写对账(`twophase.reconcile`)只清「有 intent 无 outcome」的写入账;
5
+ 索引(派生态)vs 盘面真源(节点 .md)之间没有全量 diff——非合作写者直写文件
6
+ (缺索引条目)、外部删文件(多索引条目/幽灵)、直改正文(哈希漂移)三类背离
7
+ 零告警零修复,派生态与真源静默撕裂(T9 自稳定缺口)。
8
+
9
+ 修复(本守卫的红绿锚点):`md_cg/reconcile.py` 新增 `reconcile_state(cg)`——
10
+ · 三类 diff:多索引条目(摘除,`_unstage` 同口径)/ 缺索引条目(`_node_entry`
11
+ 重建补入)/ 哈希·路径漂移(以盘面为真源重建,增量 upsert 不触发全量
12
+ rebuild);
13
+ · T9 边界:真源自身损坏(读失败 / 非 UTF-8 字节 / frontmatter 无闭合)只
14
+ stderr 告警 + `_reconcile.jsonl` 留痕,**不自动改写**——不据损坏文件建
15
+ 条目,也不因读不出摘除既有条目(瞬态读失败被当「文件没了」= N134 同型
16
+ 放大损伤);
17
+ · 开关 `MDCG_RECONCILE=0` 关闭(缺省开);干净库零输出零写盘、二次扫描
18
+ 幂等全零;
19
+ · 承接面:`mcp_server.main()` 两段式对账之后新增 `cg.reconcile_state()`
20
+ (失败不阻塞启动)。
21
+ 先例修复(本守卫顺带锚定):`mdcg._scan_nodes` 原只捕 OSError——一个非 UTF-8
22
+ 坏文件会炸穿整库装载/重建(init 先于对账,守卫若不补,损坏库永远跑不到对账);
23
+ 补 `(OSError, UnicodeDecodeError)` 跳过,与 T9 边界同口径。
24
+
25
+ **指纹双形态**(历史事实,非本守卫发明):add 写路径入索引的 content_hash 按
26
+ 封存原文(无尾换行正文 ≠ rebuild 路径 loads 读回的带尾换行形态,实测两形态
27
+ 并存)——diff 两侧双形态都认,否则无尾换行节点每次启动都误报漂移(假阳性
28
+ 洪水)。[1b] 断言双形态下零误报,防未来有人「统一」掉一半。
29
+
30
+ 隔离纪律:临时 tempfile.mkdtemp 库 + 哑 Principal(designer),不触真实
31
+ root/令牌库/数据目录;MDCG_RECONCILE 进出还原;测毕 rmtree。
32
+ 自检 floor:断言总数低于 FLOOR 视为失败——防「探针面失效 → 假绿」。
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import contextlib
37
+ import io
38
+ import os
39
+ import shutil
40
+ import sys
41
+ import tempfile
42
+
43
+ try:
44
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
45
+ except AttributeError:
46
+ pass
47
+
48
+ from . import nodefile, reconcile
49
+ from .mdcg import MdCG
50
+ from .security import Principal
51
+
52
+ FLOOR = 26 # 断言总数下限(防假绿)
53
+ PASS, FAIL = 0, 0
54
+
55
+
56
+ def check(name, cond, detail=""):
57
+ global PASS, FAIL
58
+ if cond:
59
+ PASS += 1
60
+ print(f" [ok] {name}")
61
+ else:
62
+ FAIL += 1
63
+ print(f" [FAIL] {name} {detail}")
64
+
65
+
66
+ def _run(cg, apply=True):
67
+ """跑一次对账并抓 stderr → (report, stderr 文本)。"""
68
+ err = io.StringIO()
69
+ with contextlib.redirect_stderr(err):
70
+ rep = reconcile.reconcile_state(cg, apply=apply)
71
+ return rep, err.getvalue()
72
+
73
+
74
+ def _disk_hash_of(cg, nid):
75
+ e = cg.index["nodes"][nid]
76
+ with open(os.path.join(cg.root, e["path"]), encoding="utf-8") as f:
77
+ return nodefile.content_hash(nodefile.loads(f.read())[1])
78
+
79
+
80
+ def main():
81
+ saved_sw = os.environ.get(reconcile.ENV_SWITCH)
82
+ tmp = tempfile.mkdtemp(prefix="mdcg_recon_v0_")
83
+ try:
84
+ pr = Principal(actor="recon_guard", clearance="secret",
85
+ can_write=True, can_admin=True, role="designer")
86
+ cg = MdCG(os.path.join(tmp, "cg"))
87
+ # a1 刻意**无尾换行**(add 指纹形态);a2 带尾换行(rebuild 指纹形态)
88
+ cg.add("a1", "hello recon body", layer="knowledge")
89
+ cg.add("a2", "world recon body\n", layer="knowledge")
90
+ cg.flush()
91
+
92
+ # ---- [1] 干净库:三类全零 + 双形态零误报 + 零输出零写盘 ----
93
+ print("[1] 干净库零误报(含指纹双形态)")
94
+ rep, err = _run(cg)
95
+ check("1a 三类 diff 全零(多/缺/漂移)",
96
+ not rep["ghost_entries"] and not rep["missing_entries"]
97
+ and not rep["hash_drift"], f"rep={rep}")
98
+ check("1b 无尾换行节点零误报(add 形态指纹被双形态接受集收容)",
99
+ "a1" in cg.index["nodes"] and not rep["hash_drift"],
100
+ f"drift={rep['hash_drift']}")
101
+ check("1c rebuild 后(rebuild 形态指纹)再次对账仍零漂移",
102
+ (cg.rebuild_index() or {}).get("nodes", {}).get("a1") is not None
103
+ and _run(cg)[0]["hash_drift"] == [],
104
+ f"drift={_run(cg)[0]['hash_drift']}")
105
+ check("1d 干净库零 stderr 输出(diff 非空才发声)",
106
+ err == "", f"stderr={err[:120]!r}")
107
+ check("1e 干净库零写盘(无留痕文件)",
108
+ not os.path.isfile(reconcile.trace_path(cg)),
109
+ f"trace={reconcile.trace_path(cg)}")
110
+
111
+ # ---- [2] 多索引条目(幽灵):外部直删 a2 文件 ----
112
+ print("[2] 多索引条目:外部删文件 → 检出+摘除收敛+告警留痕")
113
+ os.remove(os.path.join(cg.root, cg.index["nodes"]["a2"]["path"]))
114
+ rep, err = _run(cg)
115
+ check("2a diff 检出 ghost=[a2]", rep["ghost_entries"] == ["a2"],
116
+ f"ghost={rep['ghost_entries']}")
117
+ check("2b 自动修复:条目摘除且 tombstone 落账(unstaged=1)",
118
+ "a2" not in cg.index["nodes"]
119
+ and rep["repaired"]["unstaged"] == 1, f"rep={rep}")
120
+ check("2c stderr 告警点名", "幽灵条目" in err, f"stderr={err[:160]!r}")
121
+ check("2d 留痕落盘(_reconcile.jsonl)",
122
+ os.path.isfile(reconcile.trace_path(cg)),
123
+ f"trace={reconcile.trace_path(cg)}")
124
+ rep2, err2 = _run(cg)
125
+ check("2e 幂等:二次扫描三类全零且零输出",
126
+ not (rep2["ghost_entries"] or rep2["missing_entries"]
127
+ or rep2["hash_drift"]) and err2 == "",
128
+ f"rep={rep2} stderr={err2[:80]!r}")
129
+
130
+ # ---- [3] 缺索引条目:非合作写者直写文件 ----
131
+ print("[3] 缺索引条目:直写 .md 不经索引 → 检出+补入收敛")
132
+ fm = {"id": "x1", "layer": "knowledge", "importance": 0.4,
133
+ "created_at": 123.0}
134
+ with open(os.path.join(cg.root, "knowledge", "x1.md"), "w",
135
+ encoding="utf-8") as f:
136
+ f.write(nodefile.dumps(fm, "ghost writer body"))
137
+ rep, err = _run(cg)
138
+ g = cg.get("x1")
139
+ check("3a diff 检出 missing=[x1]", rep["missing_entries"] == ["x1"],
140
+ f"missing={rep['missing_entries']}")
141
+ check("3b 自动修复:条目以 _node_entry 补入(staged=1)且 get 可读",
142
+ rep["repaired"]["staged"] == 1 and g is not None
143
+ and "ghost writer body" in (g or {}).get("content", ""),
144
+ f"rep={rep} get={(g or {}).get('content', '')[:40]!r}")
145
+ check("3c 补入条目 content_hash 与盘面重算一致",
146
+ cg.index["nodes"]["x1"]["content_hash"] == _disk_hash_of(cg, "x1"),
147
+ f"idx={cg.index['nodes']['x1']['content_hash']} "
148
+ f"disk={_disk_hash_of(cg, 'x1')}")
149
+
150
+ # ---- [4] 内容漂移:外部直改 a1 正文 ----
151
+ print("[4] 哈希漂移:直改正文 → 检出+以盘面重建收敛")
152
+ p1 = os.path.join(cg.root, cg.index["nodes"]["a1"]["path"])
153
+ with open(p1, encoding="utf-8") as f:
154
+ txt = f.read()
155
+ with open(p1, "w", encoding="utf-8") as f:
156
+ f.write(txt.replace("hello recon body", "DRIFTED recon body"))
157
+ rep, err = _run(cg)
158
+ g1 = cg.get("a1")
159
+ check("4a diff 检出 drift=[a1]", rep["hash_drift"] == ["a1"],
160
+ f"drift={rep['hash_drift']}")
161
+ check("4b 自动修复:检索面读到盘面新正文(盘面为真源)",
162
+ g1 is not None and "DRIFTED recon body" in (g1 or {}).get(
163
+ "content", ""),
164
+ f"get={(g1 or {}).get('content', '')[:40]!r}")
165
+ check("4c 修复后索引指纹 == 盘面重算指纹",
166
+ cg.index["nodes"]["a1"]["content_hash"]
167
+ == _disk_hash_of(cg, "a1"),
168
+ f"idx={cg.index['nodes']['a1']['content_hash']}")
169
+
170
+ # ---- [5] 真源损坏:只告警不改写(T9 边界)----
171
+ print("[5] 真源损坏两面:无闭合 frontmatter + 非 UTF-8 字节")
172
+ cg.add("a3", "body three", layer="knowledge")
173
+ cg.add("a4", "body four\n", layer="knowledge")
174
+ cg.flush()
175
+ with open(os.path.join(cg.root, "knowledge", "bad1.md"), "w",
176
+ encoding="utf-8") as f:
177
+ f.write("---\n" + 'id: bad1\n') # 无闭合 → 结构非法
178
+ p4 = os.path.join(cg.root, cg.index["nodes"]["a4"]["path"])
179
+ with open(p4, "wb") as f:
180
+ f.write(b"---\xff\xfe\x00binary garbage") # 非 UTF-8 字节
181
+ rep, err = _run(cg)
182
+ kinds = sorted(p["kind"] for p in rep["unwritable_source"])
183
+ check("5a 损坏检出两面(decode_error + frontmatter_unterminated)",
184
+ kinds == ["decode_error", "frontmatter_unterminated"],
185
+ f"kinds={kinds}")
186
+ check("5b 损坏文件不建条目(bad1 不入索引——不据损坏真源写派生态)",
187
+ "bad1" not in cg.index["nodes"]
188
+ and "bad1" not in rep["missing_entries"],
189
+ f"missing={rep['missing_entries']}")
190
+ check("5c 读不出的既有条目不摘除(a4 保留——瞬态/损坏 ≠ 文件没了)",
191
+ "a4" in cg.index["nodes"]
192
+ and "a4" not in rep["ghost_entries"]
193
+ and "a4" in rep.get("kept_unreadable", []),
194
+ f"ghost={rep['ghost_entries']} kept={rep.get('kept_unreadable')}")
195
+ check("5d stderr 告警含 T9 边界口径(只告警不改写)",
196
+ "真源损坏" in err and "不改写" in err.replace("(", "("),
197
+ f"stderr={err[:200]!r}")
198
+ with open(reconcile.trace_path(cg), encoding="utf-8") as f:
199
+ trace_txt = f.read()
200
+ check("5e 留痕记录损坏清单(trace 含 problems)",
201
+ "decode_error" in trace_txt
202
+ and "frontmatter_unterminated" in trace_txt,
203
+ f"trace={trace_txt[:200]!r}")
204
+
205
+ # ---- [6] 开关:MDCG_RECONCILE=0 关闭 ----
206
+ print("[6] 关闭开关 MDCG_RECONCILE=0(缺省开)")
207
+ open(os.path.join(cg.root, "knowledge", "x9.md"), "w",
208
+ encoding="utf-8").write(
209
+ nodefile.dumps({"id": "x9", "layer": "knowledge"}, "off switch body"))
210
+ os.environ[reconcile.ENV_SWITCH] = "0"
211
+ rep_off, _e = _run(cg)
212
+ check("6a 开关关:skipped=True 不触盘", rep_off.get("skipped") is True,
213
+ f"rep={rep_off}")
214
+ check("6b 开关关:diff 在场也不修(x9 不入索引)",
215
+ "x9" not in cg.index["nodes"], f"nodes={list(cg.index['nodes'])}")
216
+ os.environ.pop(reconcile.ENV_SWITCH, None)
217
+ rep_on, _e = _run(cg)
218
+ check("6c 缺省开:开关还原后 x9 补入收敛",
219
+ rep_on["missing_entries"] == ["x9"]
220
+ and cg.get("x9") is not None,
221
+ f"missing={rep_on['missing_entries']}")
222
+
223
+ # ---- [7] dry-run:只 diff 不修 ----
224
+ print("[7] dry-run(apply=False)只报告不改写")
225
+ p1 = os.path.join(cg.root, cg.index["nodes"]["a1"]["path"])
226
+ with open(p1, encoding="utf-8") as f:
227
+ _txt = f.read() # 先读后写("w" 打开即截断)
228
+ with open(p1, "w", encoding="utf-8") as f:
229
+ f.write(_txt.replace("DRIFTED recon body", "DRIFT2 recon body"))
230
+ rep_dry, _e = _run(cg, apply=False)
231
+ check("7a dry-run 检出漂移但不修(applied=False,索引条目仍旧指纹——"
232
+ "get 直读盘面本就见新正文,dry-run 不修的是索引面)",
233
+ rep_dry["hash_drift"] == ["a1"] and rep_dry["applied"] is False
234
+ and cg.index["nodes"]["a1"]["content_hash"]
235
+ != _disk_hash_of(cg, "a1"),
236
+ f"rep={rep_dry}")
237
+ rep_fix, _e = _run(cg)
238
+ check("7b 正式对账收敛漂移(检索面见 DRIFT2)",
239
+ "DRIFT2 recon body" in (cg.get("a1") or {}).get("content", ""),
240
+ f"get={(cg.get('a1') or {}).get('content', '')[:40]!r}")
241
+
242
+ # ---- [8] 损坏在场重开:装载不炸(_scan_nodes 守卫)+ 收敛保持 ----
243
+ print("[8] 损坏真源在场重开库(装载路径守卫)")
244
+ root = cg.root
245
+ cg.close()
246
+ cg2 = MdCG(root)
247
+ check("8a 非 UTF-8 坏文件在场,重开装载不炸(_scan_nodes 跳过)",
248
+ isinstance(cg2.index.get("nodes"), dict),
249
+ "重开抛 UnicodeDecodeError 即假绿")
250
+ check("8b 重开后补入条目可见(x1)、摘除不复活(a2)",
251
+ cg2.get("x1") is not None and "a2" not in cg2.index["nodes"],
252
+ f"x1={cg2.get('x1') is not None} a2={'a2' in cg2.index['nodes']}")
253
+ cg2.close()
254
+
255
+ # ---- [9] 承接面源断言:mcp_server main 启动序列接线 ----
256
+ print("[9] 承接面源断言")
257
+ here = os.path.dirname(os.path.abspath(__file__))
258
+ with open(os.path.join(here, "mcp_server.py"), encoding="utf-8") as fh:
259
+ msrc = fh.read()
260
+ _anchor = msrc.find("cg.reconcile_writes()")
261
+ win = msrc[_anchor:msrc.find("_start_sustain", _anchor)]
262
+ check("9a mcp_server.main() 两段式对账之后新增 reconcile_state 接线",
263
+ "cg.reconcile_state()" in win, "main() 缺启动对账 v0 承接")
264
+ check("9b 接线以 try/except 包裹(对账失败不阻塞启动)",
265
+ "except Exception" in win and "不阻塞启动" in win,
266
+ "缺容错承接")
267
+ with open(os.path.join(here, "mdcg.py"), encoding="utf-8") as fh:
268
+ dsrc = fh.read()
269
+ check("9c MdCG.reconcile_state 委托在位(延迟导入 reconcile)",
270
+ "def reconcile_state" in dsrc
271
+ and "reconcile.reconcile_state" in dsrc, "缺委托方法")
272
+ with open(os.path.join(here, "mdcg.py"), encoding="utf-8") as fh:
273
+ check("9d _scan_nodes 捕 (OSError, UnicodeDecodeError)(装载路径 "
274
+ "T9 守卫)",
275
+ "except (OSError, UnicodeDecodeError):" in dsrc,
276
+ "装载路径缺解码守卫")
277
+
278
+ # ---- [10] 守卫自检 ----
279
+ print("[10] 守卫自检")
280
+ check(f"10a 断言总数 ≥ {FLOOR}(防探针面失效假绿)",
281
+ PASS + FAIL >= FLOOR, f"total={PASS + FAIL}")
282
+ finally:
283
+ if saved_sw is None:
284
+ os.environ.pop(reconcile.ENV_SWITCH, None)
285
+ else:
286
+ os.environ[reconcile.ENV_SWITCH] = saved_sw
287
+ shutil.rmtree(tmp, ignore_errors=True)
288
+
289
+ print(f"\n=== reconcile v0 tests: {PASS} passed, {FAIL} failed ===")
290
+ sys.exit(1 if FAIL else 0)
291
+
292
+
293
+ if __name__ == "__main__":
294
+ sys.exit(main())
@@ -326,8 +326,12 @@ def main():
326
326
  and "observation_position" not in r[0])
327
327
  for r in _r7),
328
328
  str([sorted(r[0].keys()) for r in _r7])[:160])
329
- check("默认关:索引条目本身未被就地改写",
330
- cg7.index["nodes"]["q1"].get("big_domain") == "工程")
329
+ # V21-5(批次 34):就地剥离为定案正确语义——残留是一次性污染,自愈后
330
+ # 索引条目不再带门控键(批次 31 的「不改写」形态 = no-op 回写,被 V21
331
+ # 报告定案为缺陷;本断言随语义修正翻转)
332
+ check("默认关:索引条目门控键已就地剥离(V21-5 自愈)",
333
+ "big_domain" not in cg7.index["nodes"]["q1"]
334
+ and "observation_position" not in cg7.index["nodes"]["q1"])
331
335
  _restore(old)
332
336
 
333
337
  print("\ntest_retr_s1: %d 通过 / %d 失败" % (passed, failed))