@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,208 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """生产 search T2/T3 重打分守卫(v7 N51 留档复测·修复,缺陷迭代第 14 轮)。
4
+
5
+ 缺陷形态(缺陷报告/v9.md:111 留档,600 节点临时库实测逐位复现):
6
+ 生产 MdCGOS.search 的 try_stage(mdcos.py)无 scored 形参——T2/T3 均为
7
+ 「打分截断后又对 picked 重打」:cut_by_relevance(hits, self._score(hits))
8
+ 之后 try_stage(hits) 内部再 _score 一遍;T3 再对 picked 重打。实测无交集
9
+ 查询 _score 恰 4 调、per-call docs=[0,0,600,500]、共打分 1100 文档——
10
+ 500/1100≈45% 打分工作量冗余(GLOBAL_CAP=500)。基类 try_stage 早有
11
+ scored=None 形参(mdcg.py P2-4,批次 31,reach 分支已用)未回移生产覆写。
12
+
13
+ 修复口径:生产 try_stage 照基类 P2-4 模板补 scored=None 形参;T2 hits 腿
14
+ (截断打分结果经 doc_key 键表回填)与 T3 picked 腿、reach hits_r 腿一并
15
+ 透传——同一 _score 纯函数(单次 search 内 docs/q/qb/pool_cfg 均不变),
16
+ 透传不改变任何分值与输出。
17
+
18
+ 本守卫(调用计数/复杂度断言形态,非时间阈值):
19
+ T3 段(无交集查询,600 节点):_score 恰 2 调、per-call [0,600]、共 600
20
+ 文档(旧实现 4 调 [0,0,600,500]=1100 → 红);tier/scanned/结果规模
21
+ 不变;
22
+ T2 段(有交集查询):_score 恰 1 调、共 600 文档(旧实现 2 调
23
+ [600,500]=1100 → 红);
24
+ 纯度段:对计数包装记录的每笔 (docs, scored) 复算 _score 逐位相等
25
+ (透传值 ≡ 现算值——不改结果的直接证据);
26
+ 确定性段:同查询连跑两轮结果 id 序与分值逐位一致。
27
+
28
+ 运行:python -m md_cg.test_retr_score_once
29
+ """
30
+ import os
31
+ import shutil
32
+ import sys
33
+ import tempfile
34
+
35
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
36
+
37
+ from md_cg.mdcos import MdCGOS # noqa: E402
38
+
39
+ PASS = 0
40
+ FAIL = 0
41
+ FAILS = []
42
+
43
+ N = 600
44
+ QUERY_MISS = "完全无交集查询词项甲乙丙" # LIKE 零命中 → T3 全量兜底
45
+ QUERY_HIT = "蜂群调度" # 有交集 → T2 命中
46
+
47
+
48
+ def check(name, cond, detail=""):
49
+ global PASS, FAIL
50
+ if cond:
51
+ PASS += 1
52
+ print(f" OK {name}")
53
+ else:
54
+ FAIL += 1
55
+ FAILS.append(name)
56
+ print(f" FAIL {name} {detail}")
57
+
58
+
59
+ def build_lib(root):
60
+ cg = MdCGOS(root)
61
+ for i in range(N):
62
+ cg.add("mem_%04d" % i,
63
+ "# 功能名:打分样本 %d\n# 正文:%s 节点 %d" % (i, QUERY_HIT, i),
64
+ layer="knowledge")
65
+ cg.flush()
66
+ return cg
67
+
68
+
69
+ def count_score(cg, fn):
70
+ """包装实例 _score:记录每笔 (docs 数, scored);另存 docs 引用于旁表。"""
71
+ calls = [] # [docs_n]
72
+ pairs = [] # [(docs, scored)]
73
+ real = MdCGOS._score
74
+
75
+ def _spy(self, docs, *a, **kw):
76
+ out = real(self, docs, *a, **kw)
77
+ calls.append(len(docs))
78
+ # 记录时拷贝:_emit 会对 scored 列表原地排序(记录引用会被事后变更)
79
+ pairs.append((list(docs), list(out)))
80
+ return out
81
+
82
+ cg._score = _spy.__get__(cg, MdCGOS)
83
+ try:
84
+ return fn(), calls, pairs
85
+ finally:
86
+ del cg._score # 还原类属性查找
87
+
88
+
89
+ def main():
90
+ os.environ.pop("MDCG_READ_CACHE", None)
91
+ root = tempfile.mkdtemp(prefix="mdcg_score_once_")
92
+ cg = None
93
+ try:
94
+ cg = build_lib(root)
95
+ cg.search(QUERY_MISS) # 预热(读缓存装载,非度量面)
96
+
97
+ print("== T3 无交集查询:打分恰一次/文档 ==")
98
+ for rep in range(2):
99
+ (res, meta), calls, _ = count_score(
100
+ cg, lambda: cg.search(QUERY_MISS))
101
+ check("T3a rep%d _score 调用数 == 2(旧 4)" % rep,
102
+ len(calls) == 2, f"calls={calls}")
103
+ check("T3b rep%d per-call docs == [0, 600](旧 [0,0,600,500])" % rep,
104
+ calls == [0, 600], f"calls={calls}")
105
+ check("T3c rep%d 共打分 600 文档(旧 1100,省 45)" % rep,
106
+ sum(calls) == 600, f"total={sum(calls)}")
107
+ check("T3d rep%d tier/scanned/结果规模不变" % rep,
108
+ meta.get("tier") == "T3_global_scan"
109
+ and meta.get("scanned") == N and len(res) == 20,
110
+ f"tier={meta.get('tier')} scanned={meta.get('scanned')} "
111
+ f"n={len(res)}")
112
+
113
+ print("== T2 有交集查询:打分恰一次/文档 ==")
114
+ for rep in range(2):
115
+ (res, meta), calls, _ = count_score(
116
+ cg, lambda: cg.search(QUERY_HIT))
117
+ check("T2a rep%d _score 调用数 == 1(旧 2)" % rep,
118
+ len(calls) == 1, f"calls={calls}")
119
+ check("T2b rep%d 共打分 600 文档(旧 1100)" % rep,
120
+ calls == [600], f"calls={calls}")
121
+ check("T2c rep%d tier 不变(T2_global_like)" % rep,
122
+ meta.get("tier") == "T2_global_like",
123
+ f"tier={meta.get('tier')}")
124
+
125
+ print("== 纯度:透传 scored ≡ 现算 _score(不改结果的直接证据)==")
126
+ (res, meta), calls, pairs = count_score(
127
+ cg, lambda: cg.search(QUERY_MISS))
128
+ # 与 search 同口径重建 q/qb(mdcos.py:751-773 的打分输入)
129
+ from md_cg.mdcg import bigrams, normalize_en, en_zh_bigrams
130
+ from md_cg.semantic.unify import unify_query
131
+ q_p = unify_query(QUERY_MISS)
132
+ qb_p = bigrams(normalize_en(q_p)) | en_zh_bigrams(q_p)
133
+ pure = True
134
+ for i, (docs, scored) in enumerate(pairs):
135
+ again = MdCGOS._score(cg, docs, q_p, qb_p, None)
136
+ if again != scored:
137
+ pure = False
138
+ print(f" 第{i}笔复算不一致: {len(docs)} docs")
139
+ check("P1a 每笔 (docs→scored) 复算逐位相等(_score 纯函数)", pure)
140
+
141
+ print("== 确定性:同查询两轮结果逐位一致 ==")
142
+ r1, m1 = cg.search(QUERY_MISS)
143
+ r2, m2 = cg.search(QUERY_MISS)
144
+ ids1 = [(x[0].get("id"), round(x[1], 12)) for x in r1]
145
+ ids2 = [(x[0].get("id"), round(x[1], 12)) for x in r2]
146
+ check("D1a 两轮结果 id 序与分值逐位一致", ids1 == ids2,
147
+ f"n={len(ids1)}/{len(ids2)}")
148
+ rh1, _ = cg.search(QUERY_HIT)
149
+ rh2, _ = cg.search(QUERY_HIT)
150
+ check("D1b T2 路径两轮结果逐位一致",
151
+ [(x[0].get("id"), round(x[1], 12)) for x in rh1]
152
+ == [(x[0].get("id"), round(x[1], 12)) for x in rh2])
153
+
154
+ print("== reach 腿(MDCG_REACH=1):P2-4 模板形态修复 ==")
155
+ # 相邻缺陷(本轮 stash 取证):基类 reach 分支旧 _smap 模板对 scored
156
+ # 的 card dict 调 pooling.doc_key(形参须 (entry,fm,content) 三元组)
157
+ # → MDCG_REACH=1 + 命中集非空即 KeyError: 1(HEAD 原生在位)。
158
+ from md_cg.mdcg import MdCG
159
+ os.environ["MDCG_REACH"] = "1"
160
+ try:
161
+ root2 = tempfile.mkdtemp(prefix="mdcg_score_reach_")
162
+ cg2 = MdCGOS(root2)
163
+ for i in range(30):
164
+ cg2.add("rm_%03d" % i,
165
+ "# 功能名:reach 样本\n# 正文:%s 节点 %d" % (QUERY_HIT, i),
166
+ layer="knowledge")
167
+ cg2.flush()
168
+ try:
169
+ (res_r, meta_r), calls_r, _ = count_score(
170
+ cg2, lambda: cg2.search(QUERY_HIT))
171
+ check("R1a 生产 reach 腿不再 KeyError(HEAD 实测崩)且有结果",
172
+ len(res_r) >= 1, f"n={len(res_r)} tier={meta_r.get('tier')}")
173
+ check("R1b 生产 reach 腿打分 ≤ 2 调(透传无重打)",
174
+ len(calls_r) <= 2, f"calls={calls_r}")
175
+ finally:
176
+ cg2.close()
177
+ shutil.rmtree(root2, ignore_errors=True)
178
+ root3 = tempfile.mkdtemp(prefix="mdcg_score_reachb_")
179
+ cg3 = MdCG(root3)
180
+ for i in range(30):
181
+ cg3.add("bm_%03d" % i,
182
+ "# 功能名:reach 样本\n# 正文:%s 节点 %d" % (QUERY_HIT, i),
183
+ layer="knowledge")
184
+ cg3.flush()
185
+ try:
186
+ res_b, meta_b = cg3.search(QUERY_HIT)
187
+ check("R2a 基类 reach 分支不再 KeyError(stash 取证 HEAD 崩)",
188
+ len(res_b) >= 1, f"n={len(res_b)} tier={meta_b.get('tier')}")
189
+ finally:
190
+ cg3.close()
191
+ shutil.rmtree(root3, ignore_errors=True)
192
+ finally:
193
+ os.environ.pop("MDCG_REACH", None)
194
+ cg.close()
195
+ finally:
196
+ os.environ.pop("MDCG_READ_CACHE", None)
197
+ shutil.rmtree(root, ignore_errors=True)
198
+
199
+ print("\n" + "=" * 60)
200
+ print(f"结果:PASS {PASS} / FAIL {FAIL}")
201
+ if FAILS:
202
+ for f in FAILS:
203
+ print(f" - {f}")
204
+ return 0 if FAIL == 0 else 1
205
+
206
+
207
+ if __name__ == "__main__":
208
+ sys.exit(main())
@@ -0,0 +1,177 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · review_cli 归因守卫(P1 裁决面身份归因退化,DSH 端在役实测)
3
+
4
+ 缺陷(修复前):`review_cli._cg` 构造 `Principal(actor="designer-cli", ...)`
5
+ 不传 session → `security.Principal.__init__` 每次随机生成 `sess_<uuid12>`;
6
+ accept 落盘经 `MdCGSecure._attribution` 的 setdefault,writer 缺省成裁决者
7
+ designer-cli——于是 DSH 端实测:5 个裁决节点 5 个互不相同的随机会话 id、
8
+ writer 全被 designer-cli 覆盖,原始写入者(dsh-memory,提案记录 actor 有)
9
+ 不可追溯。
10
+
11
+ 守卫(修复前红):
12
+ ① review_cli accept --session <s> → 落盘节点 frontmatter.session == <s>
13
+ ② 落盘节点 frontmatter.writer == 提案记录的原始 actor(dsh-memory)
14
+ ③ 裁决者身份不丢失:frontmatter.reviewer == "designer-cli"
15
+ ④ 稳定性:同一 --session 两次裁决 → 两节点 session 一致
16
+ ⑤ 兜底:未传 --session 时环境变量 MDCG_SESSION 生效
17
+ ⑥ 仍无(两者皆缺省)→ stderr 告警一行 + 维持随机会话(现状兼容)
18
+
19
+ 运行:python -m md_cg.test_review_cli_attribution
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+
29
+ from . import nodefile
30
+ from .mdcos import MdCGOS, MdCGSecure
31
+ from .security import Principal
32
+
33
+ PASS = FAIL = 0
34
+ FAILS = []
35
+
36
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37
+
38
+ DUMMY_KEY = "ab" * 32 # 哑主密钥:子进程绝不触真实 ~/.mdcg/master.key
39
+
40
+
41
+ def check(name, cond, detail=""):
42
+ global PASS, FAIL
43
+ if cond:
44
+ PASS += 1
45
+ print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
46
+ else:
47
+ FAIL += 1
48
+ FAILS.append(name)
49
+ print(f" [FAIL] {name} · {detail}")
50
+
51
+
52
+ def _child_env(extra=None, remove=("MDCG_SESSION", "DSH_SESSION_ID",
53
+ "MDCG_REDTEAM_REQUIRED")):
54
+ """子进程环境:哑密钥 + UTF-8 + PYTHONPATH 指向本仓(纪律 15)。
55
+
56
+ remove 里的会话/红队环境变量先摘除——守卫 ①~④⑥ 必须证明 --session
57
+ 是唯一来源,不能被外层环境偷跑。
58
+ """
59
+ env = dict(os.environ)
60
+ for k in remove:
61
+ env.pop(k, None)
62
+ env["MDCG_MASTER_KEY"] = DUMMY_KEY
63
+ env["PYTHONUTF8"] = "1"
64
+ env["PYTHONIOENCODING"] = "utf-8"
65
+ env["PYTHONPATH"] = REPO + os.pathsep + env.get("PYTHONPATH", "")
66
+ env.update(extra or {})
67
+ return env
68
+
69
+
70
+ def _run_cli(root, argv, extra=None):
71
+ """跑一次 review_cli 子命令(包内入口 python -m md_cg.review_cli)。"""
72
+ return subprocess.run(
73
+ [sys.executable, "-m", "md_cg.review_cli"] + argv,
74
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
75
+ env=_child_env(extra), cwd=REPO)
76
+
77
+
78
+ def _propose(root, nid, content):
79
+ """以原始写入者身份(dsh-memory,DSH 端 agent 形态)入队一条提案。"""
80
+ cg = MdCGSecure(root, principal=Principal(
81
+ actor="dsh-memory", clearance="internal", can_write=True,
82
+ can_admin=False, role="recorder", session="sess_writer_orig"))
83
+ try:
84
+ return cg.propose(nid, content, layer="knowledge", tags=["attr"])
85
+ finally:
86
+ cg.close()
87
+
88
+
89
+ def _fm(root, nid):
90
+ """直读节点文件 frontmatter(不经密级读闸——守卫只看归因字段)。"""
91
+ probe = MdCGOS(root, actor="probe")
92
+ try:
93
+ e = (probe.index.get("nodes") or {}).get(nid)
94
+ if not e:
95
+ return None
96
+ with open(os.path.join(root, e["path"]), encoding="utf-8") as f:
97
+ fm, _c = nodefile.loads(f.read())
98
+ return fm
99
+ finally:
100
+ probe.close()
101
+
102
+
103
+ def main():
104
+ roots = []
105
+ try:
106
+ print("\n【①~③】accept --session → 落盘归因(session/writer/reviewer)")
107
+ root_a = tempfile.mkdtemp(prefix="cliattrib_a_")
108
+ roots.append(root_a)
109
+ pid = _propose(root_a, "attr-node-1", "P1 归因守卫:原始写入者应可追溯。")
110
+ r = _run_cli(root_a, ["accept", pid, "--session", "sess_decide_01",
111
+ "--root", root_a, "--reason", "归因守卫"])
112
+ check("review_cli accept --session 退出码 0", r.returncode == 0,
113
+ (r.stdout or r.stderr or "")[:200])
114
+ fm = _fm(root_a, "attr-node-1")
115
+ check("frontmatter.session == 传入 --session",
116
+ bool(fm) and fm.get("session") == "sess_decide_01",
117
+ str(fm and fm.get("session")))
118
+ check("frontmatter.writer == 提案原始 actor(dsh-memory)",
119
+ bool(fm) and fm.get("writer") == "dsh-memory",
120
+ str(fm and fm.get("writer")))
121
+ check("裁决者身份保留于 frontmatter.reviewer == designer-cli",
122
+ bool(fm) and fm.get("reviewer") == "designer-cli",
123
+ str(fm and fm.get("reviewer")))
124
+
125
+ print("\n【④】同一 --session 两次裁决 → 落盘归属一致")
126
+ pid2 = _propose(root_a, "attr-node-2",
127
+ "P1 归因守卫第二提案:同批裁决同会话。")
128
+ r2 = _run_cli(root_a, ["accept", pid2, "--session", "sess_decide_01",
129
+ "--root", root_a, "--reason", "归因守卫"])
130
+ fm2 = _fm(root_a, "attr-node-2")
131
+ check("第二节点 session 与第一节点一致",
132
+ r2.returncode == 0 and bool(fm) and bool(fm2)
133
+ and fm2.get("session") == fm.get("session") == "sess_decide_01",
134
+ "%r vs %r" % (fm and fm.get("session"), fm2 and fm2.get("session")))
135
+
136
+ print("\n【⑤】MDCG_SESSION 环境变量兜底")
137
+ pid3 = _propose(root_a, "attr-node-3",
138
+ "P1 归因守卫第三提案:环境变量会话。")
139
+ r3 = _run_cli(root_a, ["accept", pid3, "--root", root_a,
140
+ "--reason", "归因守卫"],
141
+ extra={"MDCG_SESSION": "sess_env_03"})
142
+ fm3 = _fm(root_a, "attr-node-3")
143
+ check("无 --session 时 MDCG_SESSION 生效",
144
+ r3.returncode == 0 and bool(fm3)
145
+ and fm3.get("session") == "sess_env_03",
146
+ str(fm3 and fm3.get("session")))
147
+
148
+ print("\n【⑥】两者皆缺省 → stderr 告警一行 + 维持随机会话")
149
+ pid4 = _propose(root_a, "attr-node-4",
150
+ "P1 归因守卫第四提案:随机会话告警。")
151
+ r4 = _run_cli(root_a, ["accept", pid4, "--root", root_a,
152
+ "--reason", "归因守卫"])
153
+ fm4 = _fm(root_a, "attr-node-4")
154
+ check("退出码 0(告警不阻断裁决)", r4.returncode == 0,
155
+ (r4.stdout or r4.stderr or "")[:200])
156
+ warn = [ln for ln in (r4.stderr or "").splitlines() if ln.strip()]
157
+ check("stderr 恰一行随机会话告警",
158
+ len(warn) == 1 and "随机会话" in warn[0], str(warn))
159
+ check("缺省仍落随机会话 id(sess_ 前缀,现状兼容)",
160
+ bool(fm4) and str(fm4.get("session", "")).startswith("sess_"),
161
+ str(fm4 and fm4.get("session")))
162
+ check("无 --session 时原始 writer 归因同样在位",
163
+ bool(fm4) and fm4.get("writer") == "dsh-memory",
164
+ str(fm4 and fm4.get("writer")))
165
+ finally:
166
+ for r_ in roots:
167
+ shutil.rmtree(r_, ignore_errors=True)
168
+
169
+ print("\n" + "=" * 64)
170
+ print(f"PASS={PASS} FAIL={FAIL}")
171
+ if FAILS:
172
+ print("失败项:" + ";".join(FAILS))
173
+ return 0 if FAIL == 0 else 1
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())
@@ -0,0 +1,235 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · review_cli 裁决落盘跨进程可见性守卫(P1b autoflush 第二落点,DSH 端在役实测)
3
+
4
+ 缺陷(修复前):`review_cli._cg` 构造 `MdCGSecure(_root(args), principal=p)`
5
+ 不传 autoflush → 缺省 64。库的索引增量链路是「写入先经 `_stage` 入内存
6
+ `_dirty` 与内存索引,攒够 autoflush 条才 flush() 落分片日志 `_index_log/`」;
7
+ 短命进程的**正常退出**另有 main finally `cg.close()` + 库层 atexit 双兜底
8
+ (mdcg.py `_LIVE_CGS`,2026-09-16 取证),但**被 kill / 崩溃退出时两者都不
9
+ 执行**(兜底刻意只覆盖正常退出这一条路径)——裁决落盘的少量节点停在内存
10
+ 索引、分片日志从未落,盘面节点文件在而索引链路断。DSH 端在役实测:裁决后
11
+ MCP 读面 read null。对照:mcp_server.py:3695-3702 同症状 2026-09-16 已修
12
+ (server 级 autoflush=1 兜底),review_cli 这个短命工具落点被遗漏——本修复
13
+ 补齐,与 server 同款。
14
+
15
+ 守卫(四组,全部临时库 + 哑主密钥,绝不触真实 ~/.mdcg):
16
+ ① 真实在役入口:subprocess 跑 `python -m md_cg.review_cli accept`(正常
17
+ 完成)→ 进程 B(全新 python 子进程 _load_index + read)断言业务节点与
18
+ 审计记录节点均可见。
19
+ ② kill 形态可见性:进程 A' 等价直接调库落盘 2 条(无任何显式 flush)后
20
+ `os._exit(0)`(模拟被杀:close/atexit 全部跳过)→ 进程 B 全新子进程
21
+ 断言读回可见。
22
+ ③ 落账契约(autoflush=1 的直接可观察效应,**修复前红**):② 的进程 A'
23
+ 被杀退出后,盘面 `_index_log/` 必须已含其全部写入的索引记录——"写入即
24
+ 落分片日志",与 mcp_server 的 server 级 autoflush=1 同一契约。
25
+ ④ 同病工具面:evidence._open_cg / scripts/linkref_backfill /
26
+ scripts/migrate_restricted 同样补 autoflush=1(前两者行为断言
27
+ .autoflush==1,后两者源级断言构造语句带参)。
28
+
29
+ 如实边界:本仓 HEAD 上 ①② 的读面断言在修复前已绿——正常路径由 09-16 的
30
+ review_decide 显式 flush(mdcos.py)+ close/atexit 兜底覆盖;kill 形态的
31
+ 重开读面由批次39(2026-09-25)`_load_index` 指纹失配全扫自愈兜住。修复前
32
+ 红的只有 ③ 的落账契约与在役旧读面(0.4.x 副本 `_load_index` 盲信快照)——
33
+ 后者正是 DSH 端观察到 read null 的形态;本守卫把读面断言一并钉住,防上述
34
+ 两层兜底未来被移除时旧症复发。
35
+
36
+ 运行:python -m md_cg.test_review_cli_visibility
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import os
42
+ import re
43
+ import shutil
44
+ import subprocess
45
+ import sys
46
+ import tempfile
47
+
48
+ from .mdcg import ShardedLog
49
+ from .mdcos import MdCGSecure
50
+ from .security import Principal
51
+
52
+ PASS = FAIL = 0
53
+ FAILS = []
54
+
55
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
56
+
57
+ DUMMY_KEY = "ab" * 32 # 哑主密钥:子进程绝不触真实 ~/.mdcg/master.key
58
+
59
+ KILL_CHILD = r'''
60
+ import argparse, os, sys
61
+ sys.path.insert(0, sys.argv[2])
62
+ from md_cg import review_cli
63
+ # 等价直接调库落盘:走 review_cli 的生产构造路径 _cg(与 CLI 同一 autoflush),
64
+ # 随后裸 add 两条(无任何显式 flush)——覆盖 review_decide 之外的写入形态。
65
+ cg = review_cli._cg(argparse.Namespace(root=sys.argv[1]))
66
+ sys.stderr.write("staged=0 autoflush=%d\n" % cg.autoflush)
67
+ cg.add("vis-node-1", "P1b 可见性守卫节点一:kill 形态落账。", layer="knowledge",
68
+ tags=["vis:p1b"])
69
+ cg.add("vis-node-2", "P1b 可见性守卫节点二:kill 形态落账。", layer="knowledge",
70
+ tags=["vis:p1b"])
71
+ os._exit(0) # 硬退:close / atexit 一律不执行(模拟被杀)
72
+ '''
73
+
74
+ READ_CHILD = r'''
75
+ import sys
76
+ sys.path.insert(0, sys.argv[2])
77
+ from md_cg.mdcos import MdCGSecure
78
+ from md_cg.security import Principal
79
+ p = Principal(actor="vis-reader", clearance="internal", can_write=True,
80
+ can_admin=True, role="designer", auth_mode="test")
81
+ cg = MdCGSecure(sys.argv[1], principal=p)
82
+ ids = sys.argv[3].split(",")
83
+ print("READ " + " ".join("1" if cg.get(nid) is not None else "0" for nid in ids))
84
+ '''
85
+
86
+
87
+ def check(name, cond, detail=""):
88
+ global PASS, FAIL
89
+ if cond:
90
+ PASS += 1
91
+ print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
92
+ else:
93
+ FAIL += 1
94
+ FAILS.append(name)
95
+ print(f" [FAIL] {name} · {detail}")
96
+
97
+
98
+ def _child_env(extra=None):
99
+ """子进程环境:哑密钥 + UTF-8 + PYTHONPATH 指向本仓(纪律 15)。"""
100
+ env = dict(os.environ)
101
+ env["MDCG_MASTER_KEY"] = DUMMY_KEY
102
+ env["PYTHONUTF8"] = "1"
103
+ env["PYTHONIOENCODING"] = "utf-8"
104
+ env["PYTHONPATH"] = REPO + os.pathsep + env.get("PYTHONPATH", "")
105
+ env.update(extra or {})
106
+ return env
107
+
108
+
109
+ def _run_cli(root, argv):
110
+ """跑一次 review_cli(包内入口 python -m md_cg.review_cli,真实在役入口)。"""
111
+ return subprocess.run(
112
+ [sys.executable, "-X", "utf8", "-m", "md_cg.review_cli"] + argv,
113
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
114
+ env=_child_env(), cwd=REPO)
115
+
116
+
117
+ def _spawn(code, root, arg3=""):
118
+ """独立子进程跑一段内嵌脚本(进程 A' 杀退形态 / 进程 B 全新读面)。"""
119
+ return subprocess.run(
120
+ [sys.executable, "-X", "utf8", "-c", code, root, REPO, arg3],
121
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
122
+ env=_child_env())
123
+
124
+
125
+ def _propose(root, nid, content):
126
+ """以原始写入者身份入队一条提案(复刻 DSH 端 agent 形态)。"""
127
+ cg = MdCGSecure(root, principal=Principal(
128
+ actor="dsh-memory", clearance="internal", can_write=True,
129
+ can_admin=False, role="recorder", session="sess_writer_orig"))
130
+ try:
131
+ return cg.propose(nid, content, layer="knowledge", tags=["vis:p1b"])
132
+ finally:
133
+ cg.close()
134
+
135
+
136
+ def _log_ids(root):
137
+ """读盘面分片日志的记录 id 集(落账契约的观察点)。"""
138
+ log_dir = os.path.join(root, "_index_log")
139
+ if not os.path.isdir(log_dir):
140
+ return None # 目录不存在 = 一条都没落过
141
+ return {rec.get("id") for rec in ShardedLog.read_all(log_dir)
142
+ if rec.get("id")}
143
+
144
+
145
+ def main():
146
+ roots = []
147
+ try:
148
+ # ---------- ① 真实在役入口:review_cli accept → 进程 B 读 ----------
149
+ print("\n【①】subprocess 跑 review_cli accept(正常完成)→ 进程 B 读")
150
+ root_1 = tempfile.mkdtemp(prefix="clivis_1_")
151
+ roots.append(root_1)
152
+ pid = _propose(root_1, "vis-acc-node",
153
+ "P1b 可见性守卫:accept 落盘后其它进程立即可见。")
154
+ r = _run_cli(root_1, ["accept", pid, "--root", root_1,
155
+ "--reason", "可见性守卫"])
156
+ check("review_cli accept 退出码 0", r.returncode == 0,
157
+ (r.stdout or r.stderr or "")[:200])
158
+ b = _spawn(READ_CHILD, root_1, "vis-acc-node") # 全新子进程 _load_index+read
159
+ m = re.search(r"READ (\d)", b.stdout or "")
160
+ check("进程 B 读回业务节点 vis-acc-node 可见",
161
+ bool(m) and m.group(1) == "1",
162
+ (b.stdout or b.stderr or "")[:200])
163
+ pa = MdCGSecure(root_1, principal=Principal(
164
+ actor="vis-reader", clearance="internal", can_write=True,
165
+ can_admin=True, role="designer", auth_mode="test"))
166
+ try:
167
+ recs = pa.review_records(pid)
168
+ finally:
169
+ pa.close()
170
+ check("进程 B 侧审计记录节点可见(review_records 命中 1 条)",
171
+ len(recs) == 1, str(recs))
172
+
173
+ # ---------- ②③ kill 形态:调库落盘后 os._exit → 落账契约 + 读 ----------
174
+ print("\n【②③】进程 A' 调库落 2 条即 os._exit(模拟被杀)→ 落账契约 + 进程 B 读")
175
+ root_2 = tempfile.mkdtemp(prefix="clivis_2_")
176
+ roots.append(root_2)
177
+ a = _spawn(KILL_CHILD, root_2)
178
+ check("进程 A' 经生产构造 _cg(autoflush=1 回显)",
179
+ a.returncode == 0 and "autoflush=1" in (a.stderr or ""),
180
+ (a.stderr or "")[:200])
181
+ ids = _log_ids(root_2)
182
+ check("③ 落账契约:A' 被杀退出后 _index_log 已含全部 2 条写入记录"
183
+ "(autoflush=1 写毕即落日志;修复前 autoflush=64 未达阈值=红)",
184
+ ids is not None and {"vis-node-1", "vis-node-2"} <= ids,
185
+ "log_ids=%s" % sorted(ids or []))
186
+ b2 = _spawn(READ_CHILD, root_2, "vis-node-1,vis-node-2") # 全新子进程读
187
+ m2 = re.search(r"READ (\d) (\d)", b2.stdout or "")
188
+ check("② 进程 B 读回 kill 形态的两条节点均可见",
189
+ bool(m2) and m2.group(1) == "1" and m2.group(2) == "1",
190
+ (b2.stdout or b2.stderr or "")[:200])
191
+
192
+ # ---------- ④ 同病工具面:三处同款补 autoflush=1 ----------
193
+ print("\n【④】同病工具面 autoflush=1")
194
+ cg_rv = None
195
+ try:
196
+ from . import review_cli as _rv
197
+ cg_rv = _rv._cg(argparse.Namespace(root=root_1))
198
+ check("review_cli._cg 构造 autoflush==1", cg_rv.autoflush == 1,
199
+ str(cg_rv.autoflush))
200
+ finally:
201
+ if cg_rv is not None:
202
+ cg_rv.close()
203
+ from . import evidence as _ev
204
+ cg_ev = _ev._open_cg(root_1)
205
+ try:
206
+ check("evidence._open_cg 构造 autoflush==1", cg_ev.autoflush == 1,
207
+ str(cg_ev.autoflush))
208
+ finally:
209
+ cg_ev.close()
210
+ for rel, anchor in (
211
+ ("scripts/linkref_backfill.py",
212
+ "cg = MdCGSecure(a.root, principal=p, autoflush=1)"),
213
+ ("scripts/migrate_restricted.py", None)):
214
+ with open(os.path.join(REPO, rel), encoding="utf-8") as f:
215
+ src = f.read()
216
+ if anchor is not None:
217
+ ok = anchor in src
218
+ else:
219
+ # migrate_restricted 构造跨多行:断言构造语句带 autoflush=1
220
+ ok = re.search(r"MdCGSecure\([^)]*?auth_mode=\"test\"\), "
221
+ r"autoflush=1\)", src, re.S) is not None
222
+ check("%s 构造语句带 autoflush=1(源级断言)" % rel, ok)
223
+ finally:
224
+ for r_ in roots:
225
+ shutil.rmtree(r_, ignore_errors=True)
226
+
227
+ print("\n" + "=" * 64)
228
+ print(f"PASS={PASS} FAIL={FAIL}")
229
+ if FAILS:
230
+ print("失败项:" + ";".join(FAILS))
231
+ return 0 if FAIL == 0 else 1
232
+
233
+
234
+ if __name__ == "__main__":
235
+ sys.exit(main())