@furongjun1999/dsh-memory 0.4.7 → 0.4.8

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 (172) hide show
  1. package/README.md +92 -37
  2. package/codebuddy/CODEBUDDY.md +195 -185
  3. package/docs/Pi/345/217/257/345/255/246/344/271/240/344/274/230/347/202/271_/347/201/265/346/236/242/345/244/247/350/204/221/346/224/271/350/277/233/344/272/244/346/216/245_20260915.md +144 -0
  4. package/docs/README.md +111 -0
  5. package/docs/discipline/harnesses.yaml +215 -152
  6. package/docs/discipline/templates/full.md.tmpl +60 -58
  7. package/docs/eval//347/254/254/344/270/211/346/226/271/351/252/214/350/257/201/346/212/245/345/221/212_LoCoMo_/347/201/265/346/236/242_.md +127 -0
  8. package/docs/eval//347/254/254/344/270/211/346/226/271/351/252/214/350/257/201/346/212/245/345/221/212_LoCoMo_/347/201/265/346/236/242_.png +0 -0
  9. package/docs/eval//347/254/254/344/270/211/346/226/271/351/252/214/350/257/201/346/212/245/345/221/212_/347/201/265/346/236/242_vs_dejavu_/347/273/237/344/270/200/350/257/204/345/210/206_v7.md +202 -0
  10. package/docs/experiments/m4-role-probe/census.py +86 -0
  11. package/docs/experiments/m4-role-probe/probe.py +89 -0
  12. package/docs/experiments/mapped_confidence/bench_mapped_conf.py +216 -0
  13. package/docs/experiments/mapped_confidence/post_check.py +75 -0
  14. package/docs/experiments/mapped_confidence/repro_thirdparty_tol.py +83 -0
  15. package/docs/experiments/mapped_confidence/result_mapped_conf.json +215 -0
  16. package/docs/{ → hive/}/350/234/202/345/267/242/345/267/245/344/275/234/350/256/260/345/277/206_/351/241/271/347/233/256/350/256/241/345/210/222.md +14 -2
  17. package/docs/{README → mdcg/README}/350/257/246/347/273/206/347/211/210_v0.4.5.md +9 -9
  18. package/docs/{ → mdcg/}/344/270/273/344/273/243/347/220/206/345/255/220/344/273/243/347/220/206/350/256/260/345/277/206/346/236/266/346/236/204/350/256/276/350/256/241.md +1 -1
  19. 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 +261 -0
  20. package/docs/{ → mdcg/}/345/215/225/345/205/203/350/207/252/346/210/221/351/224/232/347/202/271_/347/263/273/347/273/237/346/217/220/347/244/272/350/257/215/346/240/207/345/207/206_v0.3.md +1 -1
  21. package/docs/mdcg//345/255/220/344/273/243/347/220/206/351/205/215/347/275/256/346/240/207/345/207/206_v0.4.md +148 -0
  22. package/docs/{ → mdcg/}/346/272/220/347/240/201/347/272/247/346/236/266/346/236/204/345/256/241/350/256/241_GPT/346/211/271/350/257/204/345/257/271/347/205/247_v1.0.md +2 -2
  23. package/docs/{ → mdcg/}/347/201/265/346/236/242/344/270/211/345/261/202/346/213/206/345/210/206/350/247/204/345/210/222_v0.1.md +181 -181
  24. package/docs/{ → mdcg/}/347/274/272/345/217/243/345/215/225_P0/346/224/266/345/217/243_v0.1.md +2 -2
  25. package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_G4-G8/347/274/272/345/217/243/350/243/201/345/256/232/345/215/225_v0.1.md +1 -1
  26. package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +4 -4
  27. package/docs/{ → mdcg/}/350/256/260/345/277/206/346/223/215/344/275/234/347/263/273/347/273/237_MdCGOS/344/270/216MCP/346/216/245/345/205/245_v0.1.md +2 -2
  28. package/docs/plans/GridWorld/346/234/200/345/260/217/351/227/255/347/216/257/350/247/204/346/240/274_v0.1.md +176 -0
  29. package/docs/{ → swarm/}/350/234/202/347/276/244/344/272/222/350/201/224_v0.1.md +3 -3
  30. package/docs/swarm//350/234/202/347/276/244/345/220/214/351/224/231/346/243/200/346/265/213/345/256/236/351/252/214/345/215/217/350/256/256_v0.1.md +242 -0
  31. package/docs/theory//345/215/225/347/272/277/347/250/213/344/270/216/346/263/250/346/204/217/345/212/233/351/233/206/344/270/255_/346/227/240/344/272/211/350/256/256/347/220/206/350/256/272/346/226/207/346/241/243_v1.0.md +129 -0
  32. package/docs/theory//345/271/266/345/217/221/345/277/205/347/204/266/346/200/247/347/220/206/350/256/272_v0.2.md +134 -0
  33. package/docs/theory//346/246/202/345/277/265/345/210/206/345/261/202/345/257/271/351/275/220/350/241/250_v0.1.md +145 -0
  34. package/docs/{ → theory/}/347/220/206/350/256/272_/346/234/272/345/210/266_/344/273/243/347/240/201_/345/256/236/351/252/214_/347/274/272/345/217/243/347/237/251/351/230/265_v0.1.md +3 -3
  35. package/docs/{ → theory/}/347/220/206/350/256/272/344/273/223/346/213/206/345/210/206/344/270/216/347/231/275/347/256/261/347/237/245/350/257/206/345/272/223/345/206/205/350/277/201_v0.1.md +1 -1
  36. package/docs/theory//350/256/244/347/237/245/344/273/243/347/220/206/344/270/216/346/224/266/346/225/233/347/273/223/346/236/204_/346/235/241/344/273/266/350/256/272/351/207/215/346/236/204_v0.1.md +221 -0
  37. package/docs/world_model//344/270/226/347/225/214/346/250/241/345/236/213_/347/245/236/347/273/217/347/275/221/347/273/234/345/272/225/345/261/202/346/236/266/346/236/204_v1.0.md +237 -0
  38. package/docs//345/255/230/347/256/227/344/270/200/344/275/223/346/236/266/346/236/204/345/255/246/344/271/240_/345/244/226/351/203/250/347/220/206/350/256/272/345/257/271/347/205/247/344/270/216/350/267/257/347/272/277/344/272/244/346/216/245_20260916.md +98 -0
  39. package/docs//345/255/230/347/256/227/344/270/200/344/275/223/347/245/236/347/273/217/347/275/221/347/273/234/346/236/266/346/236/204_/346/200/273/347/272/262/344/270/216/344/272/244/346/216/245_20260916.md +109 -0
  40. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +34 -6
  41. package/docs//347/231/275/347/256/261/345/214/226/347/272/262/351/242/206_/344/270/211/351/241/271/347/233/256/345/255/246/344/271/240/346/200/273/346/236/266/346/236/204/344/270/216Ghidra/344/272/244/346/216/245_20260916.md +104 -0
  42. package/docs//350/256/260/345/277/206/350/257/204/345/256/241/347/263/273/347/273/237_/347/253/213/351/241/271/350/256/276/350/256/241/344/270/216/346/226/275/345/267/245/344/272/244/346/216/245_20260915.md +183 -0
  43. package/dsh/README.md +1 -1
  44. package/lib/hooks.d.ts +5 -0
  45. package/lib/hooks.js +10 -1
  46. package/lib/lib/prompt_safety.d.ts +48 -0
  47. package/lib/lib/prompt_safety.js +62 -0
  48. package/md_cg/audit.py +76 -2
  49. package/md_cg/bench_membench.py +10 -5
  50. package/md_cg/bench_progressive.py +48 -9
  51. package/md_cg/branches.py +275 -0
  52. package/md_cg/ccgc.py +884 -0
  53. package/md_cg/conformance.py +662 -0
  54. package/md_cg/consistency.py +1 -1
  55. package/md_cg/corpus.py +1 -1
  56. package/md_cg/eval_common.py +6 -5
  57. package/md_cg/forgetting.py +11 -3
  58. package/md_cg/fsutil.py +63 -1
  59. package/md_cg/identity.py +2 -2
  60. package/md_cg/insight.py +2 -2
  61. package/md_cg/lifecycle.py +262 -0
  62. package/md_cg/mcp_server.py +547 -140
  63. package/md_cg/mdcg.py +206 -18
  64. package/md_cg/mdcos.py +512 -50
  65. package/md_cg/metacognition.py +2 -2
  66. package/md_cg/mreview/__init__.py +25 -0
  67. package/md_cg/mreview/__main__.py +107 -0
  68. package/md_cg/mreview/bundle.py +170 -0
  69. package/md_cg/mreview/candidates.py +253 -0
  70. package/md_cg/mreview/govern.py +674 -0
  71. package/md_cg/mreview/locate.py +905 -0
  72. package/md_cg/mreview/pipeline.py +701 -0
  73. package/md_cg/mreview/rules/duplication.json +21 -0
  74. package/md_cg/mreview/rules/field_coverage.json +54 -0
  75. package/md_cg/mreview/rules/source_license.json +21 -0
  76. package/md_cg/mreview/rules/template_flow.json +21 -0
  77. package/md_cg/mreview/ruleset.py +238 -0
  78. package/md_cg/nodefile.py +38 -0
  79. package/md_cg/predict.py +24 -6
  80. package/md_cg/protect.py +4 -4
  81. package/md_cg/refindex.py +41 -2
  82. package/md_cg/scrub.py +5 -1
  83. package/md_cg/self_state.py +1 -1
  84. package/md_cg/semantic/en_normalizer.py +85 -25
  85. package/md_cg/sustain.py +18 -0
  86. package/md_cg/tasks.py +447 -0
  87. package/md_cg/test_action_derive.py +203 -0
  88. package/md_cg/test_audit_rotate.py +270 -0
  89. package/md_cg/test_branches.py +249 -0
  90. package/md_cg/test_ccg_perturb.py +1 -1
  91. package/md_cg/test_ccgc.py +423 -0
  92. package/md_cg/test_conformance.py +343 -0
  93. package/md_cg/test_en_pipeline.py +24 -0
  94. package/md_cg/test_health_scale.py +173 -0
  95. package/md_cg/test_identity_attribution.py +6 -0
  96. package/md_cg/test_index_durability.py +224 -0
  97. package/md_cg/test_lifecycle.py +309 -0
  98. package/md_cg/test_mr_m1.py +679 -0
  99. package/md_cg/test_mr_m2.py +587 -0
  100. package/md_cg/test_mr_m3.py +703 -0
  101. package/md_cg/test_mr_m4.py +485 -0
  102. package/md_cg/test_p1.py +1 -1
  103. package/md_cg/test_p11_consistency.py +1 -1
  104. package/md_cg/test_p12_metacognition.py +2 -2
  105. package/md_cg/test_p16_self_state.py +1 -1
  106. package/md_cg/test_p26_refindex.py +1 -1
  107. package/md_cg/test_p27_docindex.py +22 -2
  108. package/md_cg/test_p28_refcheck.py +1 -1
  109. package/md_cg/test_p29_session_ingest_export.py +1 -1
  110. package/md_cg/test_p2_mcp.py +7 -1
  111. package/md_cg/test_p38_contextualize.py +1 -1
  112. package/md_cg/test_p39_vision_evidence.py +1 -1
  113. package/md_cg/test_p40_refine_worklist.py +1 -1
  114. package/md_cg/test_p41_evolve_patrol.py +1 -1
  115. package/md_cg/test_p42_provenance.py +1 -1
  116. package/md_cg/test_p43_pooling.py +41 -13
  117. package/md_cg/test_read_clip.py +137 -0
  118. package/md_cg/test_review_conformance.py +310 -0
  119. package/md_cg/test_tasks.py +409 -0
  120. package/md_cg/test_tool_face.py +189 -0
  121. package/md_cg/test_twophase.py +286 -0
  122. package/md_cg/test_writepipe.py +210 -0
  123. package/md_cg/theory.py +1 -1
  124. package/md_cg/tokens.py +95 -5
  125. package/md_cg/tool_face.py +250 -0
  126. package/md_cg/twophase.py +221 -0
  127. package/md_cg/units.py +546 -0
  128. package/md_cg/vision_evidence.py +1 -1
  129. package/md_cg/weights.py +1 -1
  130. package/md_cg/whitebox.py +1 -1
  131. package/md_cg/whitebox_kb/data/verify_cache.json +28 -0
  132. package/md_cg/whitebox_kb/data/verify_savings.jsonl +46 -0
  133. package/md_cg/whitebox_kb/wisdom/audit_log/chain_heat.json +10 -10
  134. package/md_cg/whitebox_kb/wisdom/wisdom-book-cloud.db-shm +0 -0
  135. package/md_cg/whitebox_kb/wisdom/wisdom-book-cloud.db-wal +0 -0
  136. package/md_cg/writelimit.py +19 -3
  137. package/md_cg/writepipe.py +372 -0
  138. package/package.json +1 -1
  139. package/src/hooks.ts +10 -1
  140. package/src/lib/prompt_safety.ts +62 -0
  141. package/zcode/AGENTS.md +195 -185
  142. package/docs//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +0 -221
  143. /package/docs/{AGI → eval/AGI}/344/270/203/347/273/264/350/257/204/345/210/206/346/212/245/345/221/212_md_cg_v1.0.md" +0 -0
  144. /package/docs/{AGI → eval/AGI}/344/270/203/347/273/264/350/257/204/345/210/206/346/212/245/345/221/212_md_cg_v2.0.md" +0 -0
  145. /package/docs/{ → eval/}/345/256/236/351/252/214/346/226/271/346/241/210_/345/255/246/344/271/240/351/227/255/347/216/257AB/344/270/216/346/250/252/350/257/204_v1.0.md" +0 -0
  146. /package/docs/{ → eval/}/346/250/252/350/257/204_/345/205/255/345/256/266100/351/242/230/344/270/255/350/213/261/345/217/214/346/237/245_v1.0.md" +0 -0
  147. /package/docs/{guardrail-charter.md → mdcg/guardrail-charter.md} +0 -0
  148. /package/docs/{lingshu_tutorial.html → mdcg/lingshu_tutorial.html} +0 -0
  149. /package/docs/{memory-assessment.html → mdcg/memory-assessment.html} +0 -0
  150. /package/docs/{memory_score.html → mdcg/memory_score.html} +0 -0
  151. /package/docs/{memory_score.png → mdcg/memory_score.png} +0 -0
  152. /package/docs/{release_v0.3.0.md → mdcg/release_v0.3.0.md} +0 -0
  153. /package/docs/{release_v0.4.5.md → mdcg/release_v0.4.5.md} +0 -0
  154. /package/docs/{tool_table_v0.3.0.md → mdcg/tool_table_v0.3.0.md} +0 -0
  155. /package/docs/{ → mdcg/}/344/273/244/347/211/214/344/270/216/350/247/222/350/211/262/346/235/203/350/201/214/345/210/206/347/246/273_v0.1.md" +0 -0
  156. /package/docs/{ → mdcg/}/347/201/265/346/236/24282/345/267/245/345/205/267_/345/212/237/350/203/275/346/225/264/347/220/206/344/270/216/350/277/201/347/247/273/346/230/240/345/260/204_v0.1.md" +0 -0
  157. /package/docs/{ → mdcg/}/347/201/265/346/236/242MCP/345/267/245/345/205/267/346/200/273/350/241/250_v3.4.md" +0 -0
  158. /package/docs/{ → mdcg/}/347/201/265/346/236/242_/350/207/252/346/210/221/345/261/202/345/256/232/344/271/211.md" +0 -0
  159. /package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_MD/347/233/256/345/275/225/346/226/271/346/241/210_v0.1.md" +0 -0
  160. /package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276_/346/235/241/344/273/266/347/251/272/351/227/264/345/220/210/346/210/220/344/270/216/347/224/237/346/225/210/346/235/241/344/273/266/345/217/243/345/276/204_v0.1.md" +0 -0
  161. /package/docs/{ → mdcg/}/350/256/244/347/237/245/345/233/276/344/275/234/344/270/272/350/256/260/345/277/206/346/223/215/344/275/234/347/263/273/347/273/237_/350/257/204/344/274/260/344/270/216/350/267/257/347/272/277_v0.1.md" +0 -0
  162. /package/docs/{ → plans/}/345/221/275/345/220/215/346/262/273/347/220/206_/351/241/271/347/233/256/350/256/241/345/210/222.md" +0 -0
  163. /package/docs/{ → plans/}/347/237/245/350/257/206/345/233/276/350/260/261/351/241/271/347/233/256_/344/272/244/346/216/245/346/226/207/346/241/243_20260914.md" +0 -0
  164. /package/docs/{ → swarm/}/350/234/202/347/276/244/345/244/232/346/231/272/350/203/275/344/275/223_/344/275/277/347/224/250/346/212/245/345/221/212_20260913.md" +0 -0
  165. /package/docs/{ → swarm/}/350/234/202/347/276/244/345/244/232/346/231/272/350/203/275/344/275/223_/345/212/237/350/203/275/350/257/264/346/230/216_v0.6.md" +0 -0
  166. /package/docs/{ → swarm/}/350/234/202/347/276/244/347/233/262/345/214/272/346/240/207/350/256/260_v1.0.md" +0 -0
  167. /package/docs/{ → theory/}/344/277/241/346/201/257/345/267/256/344/270/272/344/273/200/344/271/210/345/277/205/347/204/266/345/255/230/345/234/250/344/270/224/350/207/252/347/204/266/346/211/251/345/244/247.md" +0 -0
  168. /package/docs/{ → theory/}/346/231/272/350/203/275/347/232/204/345/205/254/347/220/206/345/214/226/345/237/272/347/237/263.md" +0 -0
  169. /package/docs/{ → theory/}/346/231/272/350/203/275/347/232/204/350/256/244/347/237/245/350/277/207/347/250/213.md" +0 -0
  170. /package/docs/{ → theory/}/346/231/272/350/203/275/350/256/2723.4.md" +0 -0
  171. /package/docs/{ → theory/}/347/231/275/347/256/261/346/231/272/350/203/275/346/230/257/344/273/200/344/271/210/357/274/237.md" +0 -0
  172. /package/docs/{ → theory/}/347/231/275/347/256/261/346/231/272/350/203/275/347/263/273/345/210/227/302/267/347/254/254/344/272/224/347/257/207/357/274/232/350/256/251AI/347/234/237/346/255/243/350/243/205/344/270/212/350/256/260/345/277/206.md" +0 -0
package/md_cg/ccgc.py ADDED
@@ -0,0 +1,884 @@
1
+ # -*- coding: utf-8 -*-
2
+ """ccgc.py · 对话记录 → CCG 六要素编译器(接口契约形态)。
3
+
4
+ 裁定 B(使用者 2026-09-16):六要素不是注释,是**接口契约**——
5
+ 功能名 = 签名 signature | 生效条件 = 前置条件 precondition
6
+ 子功能 = 依赖 dependency | 执行 = 调用 invocation
7
+ 验证方式 = 后置条件 postcondition + test | 不适用条件 = 拒绝域 rejection_domain
8
+ 缺任一入参即**编译错误**(E001-E004),不静默降级、不猜、不发明。
9
+
10
+ 裁定 A(使用者 2026-09-16):**LLM 不得自己验证自己**。本模块是三段式中间段——
11
+ compile(生成候选) → attest(认知图**外**的验证方签章) → link(签章通过才写入)
12
+ LLM 若参与,只能经 `parser=` 注入(能力外置,与 compiler 的 llm_bridge 同构),
13
+ 且其产物**必须**过名实门(E010/E011):每个写入值都必须是对话记录的**字面子串**,
14
+ 幻觉在编译期即被机械检出,不进「看起来合理」的灰区。
15
+ 签章字段在候选结构里**没有位置**:`verifier` 只能由 attest 显式给出,
16
+ 且 E041 机械拒绝 `verifier == 编译执行者`——结构级阻断,不依赖 prompt 自觉。
17
+
18
+ 对齐既有实现(零发明):
19
+ · 验证能力外置 / 缺能力恒不通过 → md_cg/audit.py(CONTENT_KINDS + register_verifier)
20
+ · 缺参即 error、success 由 errors 定 → compiler/api.py(CompileResult 范式)
21
+ · 生效条件唯一合成入口 → nodefile.condition_space_text(本模块不另写合成)
22
+ · 六行写入口径 → consolidate._upsert_ccg_line(同款语义,就地实现防 import 环)
23
+ · 留痕可回滚 → 对齐 backfill 的 _backfill.jsonl 纪律
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import time
33
+ from dataclasses import asdict, dataclass, field
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ from . import nodefile
37
+
38
+ # ---- 四态:复用 audit / judge_qualification 的裁决语汇(不新造状态机) ----
39
+ ACCEPT, REJECT, DEFER, BLINDSPOT = "ACCEPT", "REJECT", "DEFER", "BLINDSPOT"
40
+ STATES = (ACCEPT, REJECT, DEFER, BLINDSPOT)
41
+
42
+ LOG_NAME = "_ccgc.jsonl"
43
+
44
+ # ---- 裁定 B:六要素的契约角色(术语真源在 nodefile,此处只引用不另定义) ----
45
+ # 单一真源纪律:术语若在 ccgc 再写一份,会与 nodefile 漂移(谁是真源无从判定)。
46
+ CONTRACT_ROLES = nodefile.CCG_CONTRACT_ROLES
47
+
48
+ # ---- 编译错误码(裁定 B:缺参数即编译错误,绝不静默降级) ----
49
+ E_CODES = {
50
+ "E001": "缺少源:dialog 为空(无可编译的对话记录)",
51
+ "E002": "缺少目标:node_id 为空或节点不存在",
52
+ "E003": "缺少主体:actor 为空(验证溯源必需)",
53
+ "E004": "源不可读:密文或权限拒绝(fail-closed)",
54
+ "E010": "引用定位失败:span 不是对话记录的字面子串",
55
+ "E011": "值捏造:写入值不是对话记录的字面子串",
56
+ "E020": "四槽不全:condition_space 缺槽,不构成生效条件",
57
+ "E021": "槽值非法:空值、待填充占位或不可渲染",
58
+ "E030": "验证基底非法:不在 VERIFICATION_BASIS 枚举内",
59
+ "E040": "无验证签章:未提供 attestation(缺能力恒不写入)",
60
+ "E041": "自证拒绝:验证方标识 == 编译执行者(LLM 不得自己验证自己)",
61
+ "E042": "验证未通过:签章判定 REJECT / BLINDSPOT",
62
+ "E043": "修正非法:只允许修正 condition_space 四槽",
63
+ }
64
+
65
+ # ---- 候选来源标识(写进留痕,可溯源到「谁说的」) ----
66
+ SRC_EXPLICIT = "explicit" # 显式入参(人 / 设计者提供)
67
+ SRC_PARSER = "parser" # 外部注入解析器(LLM 等能力外置通道)
68
+ SRC_RULE = "rule" # 内生规则解析器(诚实下界能力)
69
+
70
+ #: 参与名实门的五要素(生效条件由四槽合成,不来自候选,故不在此列)
71
+ _CAND_MARKS = ("功能名", "子功能", "执行", "验证方式", "不适用条件")
72
+
73
+
74
+ # =============================================================================
75
+ # 结果对象(对齐 compiler/api.py 的 CompileResult 范式)
76
+ # =============================================================================
77
+
78
+ @dataclass
79
+ class CompileResult:
80
+ """编译结果。`success = (len(errors) == 0)`——与 compiler 同口径。"""
81
+ success: bool = False
82
+ node_id: str = ""
83
+ actor: str = ""
84
+ lines: Dict[str, str] = field(default_factory=dict) # 六要素行
85
+ slots: Dict[str, Any] = field(default_factory=dict) # condition_space 四槽
86
+ spans: Dict[str, List[str]] = field(default_factory=dict) # 要素 → 引用 span 清单
87
+ sources: Dict[str, str] = field(default_factory=dict) # 要素 → explicit|parser|rule
88
+ ungrounded: List[Dict[str, str]] = field(default_factory=list) # 未过名实门的项
89
+ errors: List[str] = field(default_factory=list)
90
+ warnings: List[str] = field(default_factory=list)
91
+ verdict: Optional[Dict] = None # 验证单元终裁
92
+
93
+ @property
94
+ def has_errors(self) -> bool:
95
+ return len(self.errors) > 0
96
+
97
+ def err(self, code: str, extra: str = "") -> None:
98
+ msg = E_CODES.get(code, code)
99
+ self.errors.append(code + " " + msg + (("(" + extra + ")") if extra else ""))
100
+
101
+ def summary(self) -> str:
102
+ out = ["编译成功" if self.success else
103
+ "编译失败(%d 个错误)" % len(self.errors)]
104
+ out.append(" 节点: %s" % (self.node_id or "-"))
105
+ out.append(" 要素: %s" % ", ".join(k for k in nodefile.CCG_MARKS if k in self.lines))
106
+ for e in self.errors[:10]:
107
+ out.append(" ERROR " + e)
108
+ for w in self.warnings[:5]:
109
+ out.append(" WARN " + w)
110
+ return "\n".join(out)
111
+
112
+
113
+ @dataclass
114
+ class AttestResult:
115
+ """验证签章结果。`ok=True` 才构成 link 的准入凭证。"""
116
+ ok: bool = False
117
+ node_id: str = ""
118
+ state: str = ""
119
+ verifier: str = "" # 认知图**外**的验证方(子代理 / designer / user)
120
+ compiled_by: str = "" # 编译执行者(供 E041 比对)
121
+ evidence: str = ""
122
+ error: str = ""
123
+ token: str = "" # 签章令牌(留痕可查)
124
+ slot_corrections: Dict[str, Any] = field(default_factory=dict)
125
+ ts: float = 0.0
126
+
127
+
128
+ @dataclass
129
+ class LinkResult:
130
+ ok: bool = False
131
+ node_id: str = ""
132
+ dry_run: bool = True
133
+ written: int = 0
134
+ errors: List[str] = field(default_factory=list)
135
+ warnings: List[str] = field(default_factory=list)
136
+ entry_id: str = ""
137
+
138
+
139
+ # =============================================================================
140
+ # 工具(就地实现,避免跨模块 import 环——与 mdcg.py 复制 _ccg_line 的既有做法一致)
141
+ # =============================================================================
142
+
143
+ def _sha(s: str) -> str:
144
+ return hashlib.sha1((s or "").encode("utf-8")).hexdigest()[:12]
145
+
146
+
147
+ def _log_path(cg) -> str:
148
+ return os.path.join(cg.root, LOG_NAME)
149
+
150
+
151
+ def _as_cg(x):
152
+ """接受 root 路径或已构造的 cg 实例——保持密级隔离与密钥上下文。"""
153
+ if x is None or not isinstance(x, str):
154
+ return x
155
+ from .mdcos import MdCGOS
156
+ return MdCGOS(x)
157
+
158
+
159
+ def _has_ccg_line(content: str, field_name: str) -> bool:
160
+ text = content or ""
161
+ return ("# " + field_name + ":") in text or ("# " + field_name + ":") in text
162
+
163
+
164
+ def _upsert_ccg_line(content: str, field_name: str, value: str) -> str:
165
+ """写入/替换 `# <字段>:<值>`,优先插在「# 功能名」之后。
166
+
167
+ 与 consolidate._upsert_ccg_line 同款语义(就近实现,避免 import 环)。
168
+ """
169
+ lines = (content or "").split("\n")
170
+ for i, ln in enumerate(lines):
171
+ s = ln.strip()
172
+ if not s.startswith("#") or field_name not in s:
173
+ continue
174
+ name = s.lstrip("#").strip().split(":")[0].split(":")[0].strip()
175
+ if name == field_name:
176
+ lines[i] = "# " + field_name + ":" + value
177
+ return "\n".join(lines)
178
+ newline = "# " + field_name + ":" + value
179
+ for i, ln in enumerate(lines):
180
+ if ln.strip().startswith("# 功能名"):
181
+ lines.insert(i + 1, newline)
182
+ return "\n".join(lines)
183
+ return newline + "\n" + (content or "")
184
+
185
+
186
+ def _append_jsonl(path: str, rec: dict) -> None:
187
+ try:
188
+ with open(path, "a", encoding="utf-8") as f:
189
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
190
+ except OSError:
191
+ pass
192
+
193
+
194
+ def _read_jsonl(path: str) -> List[dict]:
195
+ out: List[dict] = []
196
+ if not path or not os.path.exists(path):
197
+ return out
198
+ try:
199
+ with open(path, encoding="utf-8") as f:
200
+ for ln in f:
201
+ ln = ln.strip()
202
+ if not ln:
203
+ continue
204
+ try:
205
+ out.append(json.loads(ln))
206
+ except ValueError:
207
+ continue
208
+ except OSError:
209
+ return []
210
+ return out
211
+
212
+
213
+ # =============================================================================
214
+ # 名实门(幻觉的编译期机械检出)
215
+ # =============================================================================
216
+
217
+ #: 值分片分隔符——值可为多句枚举,但**每个分片**都必须是原文子串
218
+ _FRAG_SPLIT = re.compile(r"[;;、,,]+")
219
+
220
+
221
+ def _value_grounded(dialog: str, value) -> bool:
222
+ """值是否 grounded:按分片切分后,每个分片都是对话记录的字面子串。
223
+
224
+ 分片口径(而非整串口径)是为容纳「多句枚举」这一合法形态(`# 子功能:A;B`);
225
+ 但**任一分片不在原文即判捏造**——改写、拼接、概括一律被挡在门外。
226
+ """
227
+ s = "" if value is None else str(value).strip()
228
+ if not s:
229
+ return False
230
+ frags = [f.strip() for f in _FRAG_SPLIT.split(s) if f.strip()]
231
+ if not frags:
232
+ return False
233
+ return all(f in dialog for f in frags)
234
+
235
+
236
+ def _span_grounded(dialog: str, span) -> bool:
237
+ """引用 span 是否 grounded:必须是对话记录的**连续**字面子串。"""
238
+ s = "" if span is None else str(span).strip()
239
+ return bool(s) and s in dialog
240
+
241
+
242
+ # =============================================================================
243
+ # 候选来源:显式入参 / 外部 parser / 内生规则解析器
244
+ # =============================================================================
245
+
246
+ #: 内生规则解析器的线索词(确定性、可审计;不做语义推断)
247
+ _CUES = {
248
+ "observation_position": ("位于", "在本地", "在服务", "在文件", "在系统",
249
+ "端上", "本地", "文件系统", "服务器"),
250
+ "observation_tool": ("使用", "采用", "通过", "调用", "借助", "依托", "方法"),
251
+ "existence_constraint": ("仅", "只", "前提", "除非", "限于", "必须是", "应当"),
252
+ }
253
+ _NEG_CUES = ("不适用", "不处理", "除外", "例外", "排除")
254
+ _EXEC_CUES = ("先", "再", "然后", "依次", "步骤", "读", "调用", "执行")
255
+ _SUB_CUES = ("分", "包含", "由", "组成", "包括")
256
+ #: 验证基底线索词 → VERIFICATION_BASIS 枚举
257
+ _BASIS_CUES = (
258
+ ("测试", "test"), ("实测", "test"), ("回放", "test"), ("断言", "test"),
259
+ ("编译", "compiler"), ("测量", "measurement"), ("证明", "formal_proof"),
260
+ ("统计", "data"), ("教材", "textbook"), ("公开知识", "public_kb"),
261
+ )
262
+
263
+ _SENT_SPLIT = re.compile(r"[。!?!?;;\n]+")
264
+ _TS_PAT = re.compile(r"(20\d{2})[-/年](\d{1,2})[-/月](\d{1,2})")
265
+
266
+
267
+ def _sentences(dialog: str) -> List[str]:
268
+ return [s.strip() for s in _SENT_SPLIT.split(dialog or "") if s.strip()]
269
+
270
+
271
+ def _pick_by_cues(sents: List[str], cues, limit: int = 1) -> List[str]:
272
+ """按线索词命中挑句(命中即整句作为候选——整句天然是原文子串,不会捏造)。"""
273
+ out: List[str] = []
274
+ for s in sents:
275
+ if any(c in s for c in cues):
276
+ out.append(s)
277
+ if len(out) >= limit:
278
+ break
279
+ return out
280
+
281
+
282
+ class RuleParser:
283
+ """内生规则解析器:**诚实下界能力**,不是主路径。
284
+
285
+ 按线索词从对话记录整句摘取候选,产出天然 grounded(无幻觉可能),但覆盖率低:
286
+ 四槽不齐即 E020 编译失败——这正是「缺能力时不假装通过」的既有纪律
287
+ (对齐 audit.py:未注入验证器恒 DEFER,绝不假装通过)。
288
+ 真正的抽取能力由外部经 `parser=` 注入(LLM 等),能力外置、认知图只留接口。
289
+ """
290
+
291
+ name = SRC_RULE
292
+
293
+ def candidates(self, dialog: str, ctx: Optional[dict] = None) -> Dict[str, Any]:
294
+ sents = _sentences(dialog)
295
+ out: Dict[str, Any] = {"marks": {}, "slots": {}}
296
+
297
+ if sents:
298
+ head = sents[0]
299
+ name = head if len(head) <= 30 else head[:30]
300
+ out["marks"]["功能名"] = {"value": name, "span": name}
301
+
302
+ pos = _pick_by_cues(sents, _CUES["observation_position"])
303
+ tool = _pick_by_cues(sents, _CUES["observation_tool"])
304
+ cons = _pick_by_cues(sents, _CUES["existence_constraint"])
305
+ if pos:
306
+ out["slots"]["observation_position"] = {"value": pos[0], "span": pos[0]}
307
+ if tool:
308
+ out["slots"]["observation_tool"] = {"value": tool[0], "span": tool[0]}
309
+ if cons:
310
+ out["slots"]["existence_constraint"] = {"value": cons[0], "span": cons[0]}
311
+
312
+ # 时间槽:含 ISO 日期 → 该日全天(UTC);不含 → 全时窗。
313
+ # 二者都是**合法声明**(nodefile 已定义全时窗语义),非占位;
314
+ # basis 经 synthetic 标注,避免被误读为「已测得具体时刻」。
315
+ m = _TS_PAT.search(dialog or "")
316
+ if m:
317
+ try:
318
+ t = time.mktime((int(m.group(1)), int(m.group(2)), int(m.group(3)),
319
+ 0, 0, 0, 0, 0, 0)) - time.timezone
320
+ out["slots"]["time_window"] = {"value": [t, t + 86399], "span": m.group(0)}
321
+ except (ValueError, OverflowError, OSError):
322
+ pass
323
+ if "time_window" not in out["slots"]:
324
+ out["slots"]["time_window"] = {
325
+ "value": [nodefile.FULL_TIME_WINDOW_MIN, nodefile.FULL_TIME_WINDOW_MAX],
326
+ "span": "", "synthetic": "full_time_window"}
327
+
328
+ for field_name, cues in (("不适用条件", _NEG_CUES), ("执行", _EXEC_CUES),
329
+ ("子功能", _SUB_CUES)):
330
+ hit = _pick_by_cues(sents, cues)
331
+ if hit:
332
+ out["marks"][field_name] = {"value": hit[0], "span": hit[0]}
333
+ for cue, basis in _BASIS_CUES:
334
+ if cue in (dialog or ""):
335
+ out["marks"]["验证方式"] = {"value": cue, "span": cue, "basis": basis}
336
+ break
337
+ return out
338
+
339
+
340
+ def _invoke_parser(parser, dialog: str, ctx: Optional[dict]) -> Dict[str, Any]:
341
+ """调用外部/内生解析器,统一形态;异常不视作通过(返回空候选)。"""
342
+ fn = getattr(parser, "candidates", parser)
343
+ try:
344
+ out = fn(dialog, ctx or {})
345
+ except Exception: # noqa: BLE001 —— 解析器异常 = 无候选,不冒充成功
346
+ return {"marks": {}, "slots": {}}
347
+ if not isinstance(out, dict):
348
+ return {"marks": {}, "slots": {}}
349
+ out.setdefault("marks", {})
350
+ out.setdefault("slots", {})
351
+ return out
352
+
353
+
354
+ # =============================================================================
355
+ # 核心:compile_dialog(对话记录 → 六要素候选;只编译,不入库)
356
+ # =============================================================================
357
+
358
+ def compile_dialog(dialog: str, node_id: str, actor: str, *,
359
+ marks: Optional[Dict[str, Any]] = None,
360
+ slots: Optional[Dict[str, Any]] = None,
361
+ parser: Any = None,
362
+ strict_spans: bool = True,
363
+ cg: Any = None) -> CompileResult:
364
+ """按五环编译:前端契约检查 → 候选 → 名实门 → 四槽合成 → 生成六行 → 终裁。
365
+
366
+ 入参优先级:显式 `marks`/`slots` > 外部 `parser=` > 内生 `RuleParser`。
367
+ 显式入参在 `strict_spans=True`(默认)时**同样**过名实门——人要写条件,
368
+ 也须拿得出对话原文依据;确需写入对话之外的声明时显式传 `strict_spans=False`,
369
+ 该豁免计入 warnings 与留痕(可审计,不静默)。
370
+
371
+ Args:
372
+ dialog: 对话记录原文(编译源)
373
+ node_id: 目标节点 id
374
+ actor: 编译执行者标识(供 attest 的 E041 比对,必填)
375
+ marks: 显式五要素(功能名/子功能/执行/验证方式/不适用条件)
376
+ slots: 显式 condition_space 四槽
377
+ parser: 外部候选生成器(对象含 candidates(dialog, ctx),或直接可调用)
378
+ strict_spans: 显式入参是否也过名实门(默认 True)
379
+ cg: 可选 cg 实例/root——提供时校验节点存在性
380
+ """
381
+ res = CompileResult(node_id=str(node_id or "").strip(),
382
+ actor=str(actor or "").strip())
383
+
384
+ # ---- 第 0 环:前端契约检查(缺参即编译错误,裁定 B) ----
385
+ if not (dialog or "").strip():
386
+ res.err("E001")
387
+ if not res.node_id:
388
+ res.err("E002")
389
+ if not res.actor:
390
+ res.err("E003")
391
+ _cg = _as_cg(cg)
392
+ if _cg is not None and res.node_id:
393
+ if not ((_cg.index.get("nodes") or {}).get(res.node_id)):
394
+ res.err("E002", "节点不存在:" + res.node_id)
395
+ if res.errors:
396
+ res.verdict = {"passed": False, "reason": res.errors[0],
397
+ "authority": "VERIFICATION_UNIT"}
398
+ return res
399
+
400
+ src = str(dialog)
401
+ ctx = {"cg": _cg, "actor": res.actor, "node_id": res.node_id}
402
+
403
+ # ---- 第 1 环:收集候选(显式 > 外部 parser > 内生 rule) ----
404
+ cand_marks: Dict[str, Any] = {}
405
+ cand_slots: Dict[str, Any] = {}
406
+ if marks or slots:
407
+ src_kind = SRC_EXPLICIT
408
+ for k, v in (slots or {}).items():
409
+ cand_slots[k] = v if isinstance(v, dict) else {"value": v}
410
+ for k, v in (marks or {}).items():
411
+ cand_marks[k] = v if isinstance(v, dict) else {"value": v}
412
+ else:
413
+ if parser is None:
414
+ src_kind = SRC_RULE
415
+ res.warnings.append(
416
+ "未注入外部解析器(parser=)且无显式入参,退回内生规则解析器(下界能力);"
417
+ "四槽不齐将按 E020 编译失败——缺能力时不假装通过。")
418
+ else:
419
+ src_kind = SRC_PARSER
420
+ res.warnings.append(
421
+ "外部解析器产物已过名实门(E010/E011):任一值非对话记录字面子串即编译失败。")
422
+ got = _invoke_parser(parser if parser is not None else RuleParser(), src, ctx)
423
+ cand_marks = dict(got.get("marks") or {})
424
+ cand_slots = dict(got.get("slots") or {})
425
+
426
+ # 显式入参在 strict_spans=True 时同样过门;豁免须显式声明且留痕
427
+ exempt = (src_kind == SRC_EXPLICIT and not strict_spans)
428
+ if exempt:
429
+ res.warnings.append(
430
+ "strict_spans=False:显式入参豁免名实门,含未经对话原文支撑的声明(已留痕)。")
431
+
432
+ def _field(item):
433
+ """候选项 → (value, span, basis, synthetic);容忍裸值与 dict 两种形态。"""
434
+ if isinstance(item, dict):
435
+ return (item.get("value"), item.get("span"),
436
+ item.get("basis"), item.get("synthetic"))
437
+ return (item, None, None, None)
438
+
439
+ # ---- 第 2 环:四槽校验 + 生效条件合成(唯一入口 nodefile.condition_space_text) ----
440
+ clean_slots: Dict[str, Any] = {}
441
+ for key, _label in nodefile.CONDITION_SLOTS:
442
+ if key not in cand_slots:
443
+ continue
444
+ value, span, _basis, synthetic = _field(cand_slots[key])
445
+ if key == "time_window":
446
+ # 全时窗哨兵是合法声明;其余须为 [lo, hi] 数值对
447
+ if not (isinstance(value, (list, tuple)) and len(value) == 2):
448
+ res.err("E021", "槽 time_window 非 [lo, hi]:" + repr(value))
449
+ continue
450
+ else:
451
+ if nodefile.is_placeholder_text(value):
452
+ res.err("E021", "槽 " + key + " 空值或待填充占位:" + repr(value))
453
+ continue
454
+ if not exempt and not _value_grounded(src, value):
455
+ res.err("E011", "槽 " + key + " 值非原文子串:" + repr(value))
456
+ res.ungrounded.append({"field": "condition_space." + key,
457
+ "span": str(span), "value": str(value)})
458
+ continue
459
+ if not exempt and span and not _span_grounded(src, span):
460
+ res.err("E010", "槽 " + key + " 引用定位失败:" + repr(span))
461
+ res.ungrounded.append({"field": "condition_space." + key,
462
+ "span": str(span), "value": str(value)})
463
+ continue
464
+ clean_slots[key] = value
465
+ span_list = res.spans.setdefault("condition_space." + key, [])
466
+ if span:
467
+ span_list.append(str(span))
468
+ if synthetic:
469
+ res.sources["condition_space." + key] = synthetic
470
+
471
+ missing = nodefile.condition_space_missing(clean_slots)
472
+ env_text = nodefile.condition_space_text(clean_slots)
473
+ if missing or not env_text:
474
+ res.err("E020", "缺槽 " + ",".join(missing or list(nodefile.CONDITION_SLOTS_REQUIRED)))
475
+ res.slots = clean_slots
476
+
477
+ # ---- 第 3 环:五要素过名实门 ----
478
+ clean_marks: Dict[str, str] = {}
479
+ basis_from_cand = None
480
+ for field_name in _CAND_MARKS:
481
+ if field_name not in cand_marks:
482
+ continue
483
+ value, span, basis, _synthetic = _field(cand_marks[field_name])
484
+ if nodefile.is_placeholder_text(value):
485
+ res.err("E021", "要素 " + field_name + " 空值或待填充占位:" + repr(value))
486
+ continue
487
+ if not exempt:
488
+ if not _value_grounded(src, value):
489
+ res.err("E011", "要素 " + field_name + " 值非原文子串:" + repr(value))
490
+ res.ungrounded.append({"field": field_name, "span": str(span),
491
+ "value": str(value)})
492
+ continue
493
+ if span and not _span_grounded(src, span):
494
+ res.err("E010", "要素 " + field_name + " 引用定位失败:" + repr(span))
495
+ res.ungrounded.append({"field": field_name, "span": str(span),
496
+ "value": str(value)})
497
+ continue
498
+ clean_marks[field_name] = str(value).strip()
499
+ res.sources[field_name] = src_kind
500
+ span_list = res.spans.setdefault(field_name, [])
501
+ if span:
502
+ span_list.append(str(span))
503
+ if field_name == "验证方式" and basis:
504
+ basis_from_cand = basis
505
+ res.sources["_src_kind"] = src_kind
506
+
507
+ # ---- 第 4 环:验证基底(显式 basis > 文本线索词 > other) ----
508
+ basis_val = basis_from_cand
509
+ if basis_val is None and clean_marks.get("验证方式"):
510
+ for cue, enum_val in _BASIS_CUES:
511
+ if cue in clean_marks["验证方式"]:
512
+ basis_val = enum_val
513
+ break
514
+ if basis_val is None:
515
+ basis_val = "other"
516
+ res.warnings.append("未声明验证基底,落默认 other(不得据以宣称已实测)。")
517
+ if basis_val not in nodefile.VERIFICATION_BASIS:
518
+ res.err("E030", repr(basis_val))
519
+ res.sources["verification_basis"] = basis_val
520
+
521
+ # ---- 第 5 环:生成六行(生效条件**只能**由四槽合成,无第二通路) ----
522
+ lines: Dict[str, str] = {}
523
+ if clean_marks.get("功能名"):
524
+ lines["功能名"] = clean_marks["功能名"]
525
+ if env_text:
526
+ lines["生效条件"] = env_text
527
+ for k in ("子功能", "执行", "验证方式", "不适用条件"):
528
+ if clean_marks.get(k):
529
+ lines[k] = clean_marks[k]
530
+ res.lines = lines
531
+
532
+ # ---- 第 6 环:验证单元终裁(编译器自证部分;签章由 attest 独立完成) ----
533
+ solid = not res.errors
534
+ res.verdict = {
535
+ "passed": solid,
536
+ "reason": ("候选可提交外部验证:六要素齐备且全部 grounded" if solid
537
+ else (res.errors[0] if res.errors else "未知")),
538
+ "authority": "VERIFICATION_UNIT",
539
+ "requires_attestation": True, # 裁定 A:编译通过 ≠ 可写入,须外部签章
540
+ }
541
+ if solid:
542
+ absent = [m for m in nodefile.CCG_REQUIRED if m not in lines]
543
+ if absent:
544
+ res.warnings.append("六要素尚缺:" + ",".join(absent) + "(link 前须补)")
545
+ res.success = solid
546
+ return res
547
+
548
+
549
+ # =============================================================================
550
+ # attest:认知图**外**的验证方签章(裁定 A 的落点)
551
+ # =============================================================================
552
+
553
+ def attest(node_id: str, verdict: str, verifier: str, compiled_by: str, *,
554
+ slot_corrections: Optional[Dict[str, Any]] = None,
555
+ evidence: str = "", cg: Any = None, ledger: bool = True) -> AttestResult:
556
+ """验证方签章。**验证方必须是编译方之外的一方**(子代理 / 设计者 / 用户)。
557
+
558
+ E041 机械拒绝 `verifier == compiled_by`——LLM 不得自己验证自己。
559
+ 这是结构性保证:不依赖 prompt 自觉,也不依赖验证方「愿意」自证。
560
+ DEFER 不构成签章(未定 = 未通过)。
561
+ """
562
+ res = AttestResult(node_id=str(node_id or "").strip(),
563
+ verifier=str(verifier or "").strip(),
564
+ compiled_by=str(compiled_by or "").strip(),
565
+ state=str(verdict or "").strip(),
566
+ evidence=str(evidence or ""),
567
+ slot_corrections=dict(slot_corrections or {}),
568
+ ts=time.time())
569
+ if not res.node_id:
570
+ res.error = "E002 " + E_CODES["E002"]
571
+ elif not res.verifier:
572
+ res.error = "E003 缺少验证方标识(verifier)"
573
+ elif res.verifier == res.compiled_by:
574
+ res.error = "E041 " + E_CODES["E041"] + "(verifier=" + res.verifier + ")"
575
+ elif res.state not in STATES:
576
+ res.error = ("非法裁决:" + repr(res.state) + "(可选 " + ",".join(STATES) + ")")
577
+ elif res.state != ACCEPT:
578
+ res.error = "E042 " + E_CODES["E042"] + "(state=" + res.state + ")"
579
+ else:
580
+ res.ok = True
581
+ res.token = _sha("|".join([res.node_id, res.verifier, res.state,
582
+ str(res.ts), res.evidence]))
583
+ if ledger:
584
+ _cg = _as_cg(cg)
585
+ if _cg is not None:
586
+ _append_jsonl(_log_path(_cg), {
587
+ "action": "ccgc_attest", "ts": res.ts, "node": res.node_id,
588
+ "verifier": res.verifier, "compiled_by": res.compiled_by,
589
+ "state": res.state, "ok": res.ok, "token": res.token,
590
+ "evidence": res.evidence,
591
+ "slot_corrections": res.slot_corrections, "error": res.error})
592
+ return res
593
+
594
+
595
+ # =============================================================================
596
+ # link:签章通过才写入(缺签章恒不写入)
597
+ # =============================================================================
598
+
599
+ def link(compiled: CompileResult, attestation: Optional[AttestResult], *,
600
+ cg: Any = None, apply: bool = False, actor: str = "",
601
+ basis: str = "") -> LinkResult:
602
+ """把编译产物写入节点。**准入条件 = 有效签章**(裁定 A)。
603
+
604
+ E040 无签章 / E041 自证 / E042 未通过 —— 任一命中即拒绝写入,
605
+ 对齐 audit.py「缺能力返回 DEFER,绝不假装通过」。
606
+ """
607
+ node_id = getattr(compiled, "node_id", "") or ""
608
+ out = LinkResult(node_id=node_id, dry_run=not apply,
609
+ entry_id=_sha("ccgc|" + node_id + "|" + time.strftime("%Y%m%d%H%M%S")))
610
+ if not compiled.success:
611
+ out.errors.append("编译未通过,拒绝写入:" +
612
+ (compiled.errors[0] if compiled.errors else "未知"))
613
+ return out
614
+ if attestation is None:
615
+ out.errors.append("E040 " + E_CODES["E040"])
616
+ return out
617
+ if attestation.node_id != node_id:
618
+ out.errors.append("签章与产物目标不一致:" + attestation.node_id + " != " + node_id)
619
+ return out
620
+ if attestation.verifier and attestation.verifier == (actor or compiled.actor):
621
+ out.errors.append("E041 " + E_CODES["E041"])
622
+ return out
623
+ if not attestation.ok:
624
+ out.errors.append("E042 " + E_CODES["E042"] +
625
+ (("(" + attestation.error + ")") if attestation.error else ""))
626
+ return out
627
+
628
+ _cg = _as_cg(cg)
629
+ if _cg is None:
630
+ out.errors.append("E002 " + E_CODES["E002"] + "(未提供 cg,无法写入)")
631
+ return out
632
+ entry = (_cg.index.get("nodes") or {}).get(node_id)
633
+ if not entry:
634
+ out.errors.append("E002 节点不存在:" + node_id)
635
+ return out
636
+ if not apply:
637
+ out.ok = True
638
+ out.warnings.append("dry-run(apply=False):未写入;签章 token=" + attestation.token)
639
+ return out
640
+
641
+ from . import crypto
642
+ fm, content = _cg._read(entry)
643
+ if fm is None or crypto.is_encrypted(content):
644
+ out.errors.append("E004 " + E_CODES["E004"])
645
+ return out
646
+
647
+ before = {"生效条件": (fm.get("state_attributes") or {}).get("comment", {}).get("生效条件")
648
+ if isinstance((fm.get("state_attributes") or {}).get("comment"), dict) else None}
649
+ new_content = content
650
+ for field_name, value in compiled.lines.items():
651
+ new_content = _upsert_ccg_line(new_content, field_name, value)
652
+
653
+ fm["condition_space"] = compiled.slots
654
+ fm["verification_basis"] = basis or compiled.sources.get("verification_basis") or "other"
655
+ if compiled.lines.get("不适用条件"):
656
+ fm["non_applicable_conditions"] = [
657
+ s.strip() for s in re.split(r"[;;]", compiled.lines["不适用条件"]) if s.strip()]
658
+ st = fm.get("state_attributes")
659
+ if not isinstance(st, dict):
660
+ st = {}
661
+ fm["state_attributes"] = st
662
+ comment = st.get("comment")
663
+ if not isinstance(comment, dict):
664
+ comment = {}
665
+ st["comment"] = comment
666
+ if compiled.lines.get("生效条件"):
667
+ comment["生效条件"] = compiled.lines["生效条件"]
668
+
669
+ _cg._write_node(node_id, os.path.join(_cg.root, entry["path"]), fm,
670
+ new_content, durable=True)
671
+ _append_jsonl(_log_path(_cg), {
672
+ "action": "ccgc_link", "ts": time.time(), "node": node_id,
673
+ "actor": actor or compiled.actor, "verifier": attestation.verifier,
674
+ "token": attestation.token, "entry_id": out.entry_id,
675
+ "fields": {k: {"after": v, "source": compiled.sources.get(k, "")}
676
+ for k, v in compiled.lines.items()},
677
+ "slots": compiled.slots, "before": before,
678
+ "content_hash_after": nodefile.content_hash(new_content)})
679
+ _cg.rebuild_index()
680
+ out.ok = True
681
+ out.written = len(compiled.lines)
682
+ return out
683
+
684
+
685
+ # =============================================================================
686
+ # recalibrate:用后续验证修正生效条件(裁定 A 的闭环)
687
+ # =============================================================================
688
+
689
+ def recalibrate(node_id: str, corrections: Dict[str, Any], verifier: str,
690
+ compiled_by: str, *, evidence: str = "", cg: Any = None,
691
+ apply: bool = False) -> LinkResult:
692
+ """用验证方给出的**四槽修正**重合成生效条件(唯一入口重算,可回滚)。
693
+
694
+ E043:只允许修正 condition_space 四槽——生效条件行本身不是修正入口,
695
+ 否则会绕开「四槽 → 合成」的唯一通路,退回到手写条件的老问题。
696
+ 修正须由编译方**之外**的一方给出(E041)。
697
+ """
698
+ out = LinkResult(node_id=str(node_id or "").strip(), dry_run=not apply,
699
+ entry_id=_sha("ccgc_recal|" + str(node_id) + "|" + str(time.time())))
700
+ if not out.node_id:
701
+ out.errors.append("E002 " + E_CODES["E002"])
702
+ return out
703
+ if verifier and verifier == compiled_by:
704
+ out.errors.append("E041 " + E_CODES["E041"])
705
+ return out
706
+ if not corrections:
707
+ out.errors.append("E043 " + E_CODES["E043"] + "(未给出任何修正)")
708
+ return out
709
+ allowed = {k for k, _l in nodefile.CONDITION_SLOTS}
710
+ bad = [k for k in corrections if k not in allowed]
711
+ if bad:
712
+ out.errors.append("E043 " + E_CODES["E043"] + "(非法键 " + ",".join(bad) + ")")
713
+ return out
714
+
715
+ _cg = _as_cg(cg)
716
+ if _cg is None:
717
+ out.errors.append("E002 " + E_CODES["E002"] + "(未提供 cg)")
718
+ return out
719
+ entry = (_cg.index.get("nodes") or {}).get(out.node_id)
720
+ if not entry:
721
+ out.errors.append("E002 节点不存在:" + out.node_id)
722
+ return out
723
+
724
+ from . import crypto
725
+ fm, content = _cg._read(entry)
726
+ if fm is None or crypto.is_encrypted(content):
727
+ out.errors.append("E004 " + E_CODES["E004"])
728
+ return out
729
+ old_cs = dict(fm.get("condition_space") or {})
730
+ new_cs = dict(old_cs)
731
+ new_cs.update(corrections)
732
+ new_text = nodefile.condition_space_text(new_cs)
733
+ if not new_text:
734
+ out.errors.append("E020 " + E_CODES["E020"] +
735
+ "(修正后仍缺 " + ",".join(nodefile.condition_space_missing(new_cs)) + ")")
736
+ return out
737
+ out.warnings.append("修正后生效条件:" + new_text)
738
+ if not apply:
739
+ out.ok = True
740
+ return out
741
+
742
+ new_content = _upsert_ccg_line(content, "生效条件", new_text)
743
+ fm["condition_space"] = new_cs
744
+ _cg._write_node(out.node_id, os.path.join(_cg.root, entry["path"]), fm,
745
+ new_content, durable=True)
746
+ _append_jsonl(_log_path(_cg), {
747
+ "action": "ccgc_recalibrate", "ts": time.time(), "node": out.node_id,
748
+ "verifier": verifier, "compiled_by": compiled_by, "evidence": evidence,
749
+ "entry_id": out.entry_id,
750
+ "slots_before": old_cs, "slots_after": new_cs,
751
+ "condition_before": nodefile.condition_space_text(old_cs),
752
+ "condition_after": new_text})
753
+ _cg.rebuild_index()
754
+ out.ok = True
755
+ out.written = 1
756
+ return out
757
+
758
+
759
+ # =============================================================================
760
+ # pending:三段式在「跨调用」形态下的持久化(候选与签章绑定,防篡改)
761
+ # =============================================================================
762
+ #
763
+ # 为什么需要:compile / attest / link 在 MCP 面上是**三次独立调用**(签章必须由
764
+ # 认知图**外**的一方在别处完成),候选对象无法跨调用传递。若让调用方把候选 JSON
765
+ # 原样回传,改写「六行」不会被任何检查发现(签章 token 只覆盖 node/verifier/
766
+ # state/evidence,不含候选内容)。因此候选落盘到 `_ccgc_pending/`(LAYERS 之外、
767
+ # `.json` 后缀 → 索引双隐身的冷区),并对其内容取 hash;link 时校验 hash,
768
+ # 不一致即拒绝写入。
769
+ #
770
+ # 纪律:pending 是**暂存**不是存档——link 成功即删除;留痕在 _ccgc.jsonl。
771
+
772
+ PENDING_DIR = "_ccgc_pending"
773
+
774
+
775
+ def _safe_name(node_id: str) -> str:
776
+ """node_id → 文件名安全形态(分支节点形如 mem_x@br1,须剥掉非 [A-Za-z0-9_.-])。"""
777
+ return re.sub(r"[^0-9A-Za-z_.\-]", "-", str(node_id or ""))[:80] or "unnamed"
778
+
779
+
780
+ def pending_path(cg, node_id: str) -> str:
781
+ _cg = _as_cg(cg)
782
+ root = getattr(_cg, "root", None)
783
+ if not root:
784
+ return ""
785
+ return os.path.join(str(root), PENDING_DIR, _safe_name(node_id) + ".json")
786
+
787
+
788
+ def _payload_hash(lines, slots) -> str:
789
+ """候选内容摘要(六行 + 四槽,键序无关)——签章与产物的绑定依据。"""
790
+ return _sha(json.dumps({"lines": lines or {}, "slots": slots or {}},
791
+ ensure_ascii=False, sort_keys=True))
792
+
793
+
794
+ def save_pending(cg, compiled: CompileResult,
795
+ attestation: Optional[AttestResult] = None) -> dict:
796
+ """候选(+ 可选签章)落 pending;返回 {ok, path, hash}。"""
797
+ _cg = _as_cg(cg)
798
+ if _cg is None:
799
+ return {"ok": False, "error": "未提供 cg"}
800
+ p = pending_path(_cg, compiled.node_id)
801
+ if not p:
802
+ return {"ok": False, "error": "无法定位 pending 目录"}
803
+ rec = {"node_id": compiled.node_id, "actor": compiled.actor, "ts": time.time(),
804
+ "hash": _payload_hash(compiled.lines, compiled.slots),
805
+ "compiled": asdict(compiled),
806
+ "attest": asdict(attestation) if attestation is not None else None}
807
+ try:
808
+ os.makedirs(os.path.dirname(p), exist_ok=True)
809
+ with open(p, "w", encoding="utf-8") as f:
810
+ json.dump(rec, f, ensure_ascii=False, indent=1)
811
+ except OSError as exc:
812
+ return {"ok": False, "error": "%s: %s" % (type(exc).__name__, exc), "path": p}
813
+ return {"ok": True, "path": p, "hash": rec["hash"]}
814
+
815
+
816
+ def load_pending(cg, node_id: str) -> dict:
817
+ """读回候选与签章,并校验 hash(内容被改即 hash_ok=False,调用方须拒绝写入)。"""
818
+ _cg = _as_cg(cg)
819
+ out = {"ok": False, "compiled": None, "attest": None, "hash_ok": False,
820
+ "path": "", "error": ""}
821
+ if _cg is None:
822
+ out["error"] = "未提供 cg"
823
+ return out
824
+ p = pending_path(_cg, node_id)
825
+ out["path"] = p
826
+ if not p or not os.path.isfile(p):
827
+ out["error"] = "未找到待写入的编译产物(pending):" + str(node_id)
828
+ return out
829
+ try:
830
+ with open(p, encoding="utf-8") as f:
831
+ rec = json.load(f)
832
+ except (OSError, ValueError) as exc:
833
+ out["error"] = "pending 不可读:%s" % type(exc).__name__
834
+ return out
835
+ cfields = {f.name for f in dataclasses.fields(CompileResult)}
836
+ afields = {f.name for f in dataclasses.fields(AttestResult)}
837
+ c = CompileResult(**{k: v for k, v in (rec.get("compiled") or {}).items()
838
+ if k in cfields})
839
+ a = None
840
+ if rec.get("attest"):
841
+ a = AttestResult(**{k: v for k, v in rec["attest"].items() if k in afields})
842
+ out.update({"compiled": c, "attest": a,
843
+ "hash_ok": rec.get("hash") == _payload_hash(c.lines, c.slots)})
844
+ out["ok"] = True
845
+ return out
846
+
847
+
848
+ def drop_pending(cg, node_id: str) -> bool:
849
+ """删除 pending(link 成功后调用;失败静默——待办件不是事实,无需强保证)。"""
850
+ p = pending_path(cg, node_id)
851
+ if not p:
852
+ return False
853
+ try:
854
+ os.remove(p)
855
+ return True
856
+ except OSError:
857
+ return False
858
+
859
+
860
+ def link_pending(cg, node_id: str, *, apply: bool = False, actor: str = "",
861
+ basis: str = "") -> LinkResult:
862
+ """从 pending 读回候选与签章后 link:hash 校验 → 签章准入 → 写入 → 清理 pending。"""
863
+ out = LinkResult(node_id=str(node_id or "").strip(), dry_run=not apply)
864
+ got = load_pending(cg, node_id)
865
+ if not got.get("ok"):
866
+ out.errors.append(got.get("error") or "pending 不可用")
867
+ return out
868
+ if not got.get("hash_ok"):
869
+ out.errors.append("pending 产物与编译时不一致(hash 不符),拒绝写入——"
870
+ "候选落盘后不得被改写,请重新编译")
871
+ return out
872
+ res = link(got["compiled"], got.get("attest"), cg=cg, apply=apply,
873
+ actor=actor, basis=basis)
874
+ if res.ok and apply:
875
+ drop_pending(cg, node_id)
876
+ return res
877
+
878
+
879
+ __all__ = ["ACCEPT", "REJECT", "DEFER", "BLINDSPOT", "STATES", "CONTRACT_ROLES",
880
+ "E_CODES", "CompileResult", "AttestResult", "LinkResult", "RuleParser",
881
+ "compile_dialog", "attest", "link", "recalibrate",
882
+ "PENDING_DIR", "pending_path", "save_pending", "load_pending",
883
+ "drop_pending", "link_pending"]
884
+