@furongjun1999/dsh-memory 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +588 -572
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/discipline/harnesses.yaml +31 -0
  5. package/docs/discipline/templates/full.md.tmpl +1 -1
  6. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  7. package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
  8. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
  9. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
  10. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
  11. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
  12. package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
  13. package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
  14. package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
  15. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
  16. package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -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_v20.md +283 -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_v21.md +224 -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_v22.md +223 -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_v23.md +293 -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_v24.md +303 -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_v25.md +230 -0
  23. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +20 -0
  24. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
  25. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  26. 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
  27. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  28. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
  29. 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 +39 -3
  30. package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
  31. package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
  32. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
  33. 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 +460 -460
  34. package/lib/bridge.js +24 -2
  35. package/lib/hooks.d.ts +31 -2
  36. package/lib/hooks.js +219 -16
  37. package/lib/init.js +16 -1
  38. package/lib/lib/prompt_safety.d.ts +52 -1
  39. package/lib/lib/prompt_safety.js +76 -1
  40. package/md_cg/audit.py +652 -379
  41. package/md_cg/bench6_arms.py +1 -1
  42. package/md_cg/bench_p0.py +1 -1
  43. package/md_cg/branches.py +2 -2
  44. package/md_cg/ccgc.py +1071 -1006
  45. package/md_cg/chain.py +1 -1
  46. package/md_cg/consistency.py +97 -9
  47. package/md_cg/consolidate.py +34 -9
  48. package/md_cg/crypto.py +59 -26
  49. package/md_cg/docindex.py +16 -1
  50. package/md_cg/evolution.py +17 -1
  51. package/md_cg/export.py +1 -1
  52. package/md_cg/forgetting.py +309 -13
  53. package/md_cg/fsutil.py +454 -8
  54. package/md_cg/identity.py +3 -3
  55. package/md_cg/insight.py +24 -3
  56. package/md_cg/linkref.py +1 -1
  57. package/md_cg/logref.py +327 -0
  58. package/md_cg/mcp_server.py +4152 -3818
  59. package/md_cg/mdcg.py +4208 -3547
  60. package/md_cg/mdcos.py +4491 -4159
  61. package/md_cg/mreview/pipeline.py +15 -1
  62. package/md_cg/nodefile.py +639 -575
  63. package/md_cg/protect.py +158 -11
  64. package/md_cg/protocol.py +41 -2
  65. package/md_cg/provenance.py +22 -4
  66. package/md_cg/reach.py +4 -4
  67. package/md_cg/readcache.py +76 -15
  68. package/md_cg/reconcile.py +1 -1
  69. package/md_cg/refindex.py +246 -37
  70. package/md_cg/refine.py +1 -1
  71. package/md_cg/routing.py +32 -5
  72. package/md_cg/run_tests.py +17 -0
  73. package/md_cg/scrub.py +30 -3
  74. package/md_cg/security.py +47 -1
  75. package/md_cg/self_state.py +5 -5
  76. package/md_cg/sources.py +23 -6
  77. package/md_cg/srcindex.py +352 -0
  78. package/md_cg/stg.py +47 -3
  79. package/md_cg/subgraph.py +2 -2
  80. package/md_cg/sustain.py +1299 -1168
  81. package/md_cg/tasks.py +469 -470
  82. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  83. package/md_cg/test_b1b2_write_face.py +417 -0
  84. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  85. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  86. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  87. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  88. package/md_cg/test_c3_transient_read_negative.py +793 -0
  89. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  90. package/md_cg/test_ccg_form_parity.py +188 -0
  91. package/md_cg/test_ccgc.py +28 -3
  92. package/md_cg/test_govern_directread.py +17 -4
  93. package/md_cg/test_h2_session_view_norm.py +233 -0
  94. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  95. package/md_cg/test_hive_ingest.py +285 -285
  96. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  97. package/md_cg/test_issue43_default_policy.py +865 -0
  98. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  99. package/md_cg/test_linkref.py +10 -3
  100. package/md_cg/test_lock.py +2 -2
  101. package/md_cg/test_logref.py +1109 -0
  102. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  103. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  104. package/md_cg/test_mr_m2.py +15 -3
  105. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  106. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  107. package/md_cg/test_n176_link_trust.py +221 -0
  108. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  109. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  110. package/md_cg/test_n195_writepath_reload.py +283 -0
  111. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  112. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  113. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  114. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  115. package/md_cg/test_n201_proposal_visibility.py +382 -0
  116. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  117. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  118. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  119. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  120. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  121. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  122. package/md_cg/test_n225_nonobject_load.py +1110 -0
  123. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  124. package/md_cg/test_neg_condition_hits.py +333 -0
  125. package/md_cg/test_neg_tail_honesty.py +663 -0
  126. package/md_cg/test_none_id_write_guard.py +4 -3
  127. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  128. package/md_cg/test_p1.py +5 -5
  129. package/md_cg/test_p11_consistency.py +161 -12
  130. package/md_cg/test_p26_refindex.py +2 -2
  131. package/md_cg/test_p27_docindex.py +777 -774
  132. package/md_cg/test_p28_refcheck.py +740 -13
  133. package/md_cg/test_p29_session_ingest_export.py +2 -2
  134. package/md_cg/test_p2_mcp.py +12 -1
  135. package/md_cg/test_p30_maintain.py +33 -2
  136. package/md_cg/test_p31_insight.py +1 -0
  137. package/md_cg/test_p9c_dedup_hints.py +276 -0
  138. package/md_cg/test_policy_required_ccg.py +426 -0
  139. package/md_cg/test_protocol.py +22 -0
  140. package/md_cg/test_rank_parity_score_mode.py +516 -0
  141. package/md_cg/test_read_face_input_gates.py +363 -0
  142. package/md_cg/test_read_face_semantics.py +489 -0
  143. package/md_cg/test_recall_face_guards.py +812 -0
  144. package/md_cg/test_rejected_credential_forms.py +188 -0
  145. package/md_cg/test_rejected_redact.py +146 -0
  146. package/md_cg/test_retr_s1b.py +2 -2
  147. package/md_cg/test_retr_s5.py +20 -7
  148. package/md_cg/test_review_conformance.py +21 -3
  149. package/md_cg/test_security_audit_v21.py +2 -2
  150. package/md_cg/test_server_version.py +67 -0
  151. package/md_cg/test_srcindex.py +171 -0
  152. package/md_cg/test_tenant_env_override_warn.py +63 -50
  153. package/md_cg/test_token_lowercase_form.py +315 -0
  154. package/md_cg/test_v21r1_package_version.py +310 -0
  155. package/md_cg/test_writepipe.py +10 -4
  156. package/md_cg/tokens.py +225 -95
  157. package/md_cg/tool_face.py +4 -4
  158. package/md_cg/trust.py +49 -5
  159. package/md_cg/units.py +204 -6
  160. package/md_cg/weights.py +4 -4
  161. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  162. package/md_cg/writelimit.py +42 -8
  163. package/md_cg/writepipe.py +651 -554
  164. package/package.json +3 -2
  165. package/skills/plugin.json +1 -1
  166. package/src/bridge.ts +25 -2
  167. package/src/hooks.ts +229 -16
  168. package/src/init.ts +15 -1
  169. package/src/lib/prompt_safety.ts +88 -1
  170. package/zcode/AGENTS.md +10 -10
@@ -0,0 +1,812 @@
1
+ # -*- coding: utf-8 -*-
2
+ # 功能名:召回面守卫——负覆盖提示条目的**消费方语义**(H10①②③:按分数不得压过真实答案、
3
+ # 条数 ≤ k、D 分母只数真实候选)、负层与目标槽不进正排(H7⑦:同一负节点不双列)、
4
+ # 飞轮只对真冲突建单 + 工单语义身份与现场刷新(H11⑥⑦)、CCG 哨兵漏斗同口径(H11⑧)、
5
+ # bucket_health 呼叫点传节点总数(M3)
6
+ # 生效条件:md_cg/mdcg.py 的 `_emit`/`_neg_tail`/`_primary_slots`/`_compute_d`/`search`
7
+ # 候选收集/`add_unresolved`/`_refresh_unresolved`/`_ticket_slug`/`health`/
8
+ # `flywheel_step`、md_cg/consistency.py 的 `check`(`FLYWHEEL_TRIGGERS` +
9
+ # 哨兵漏斗)、md_cg/routing.py 的 `bucket_health(counts, total_nodes)` 在位时;
10
+ # 沙箱条件:库根一律 tempfile.mkdtemp,绝不触在役库/在役服务。
11
+ # 子功能:
12
+ # N 组 H10①②④(负覆盖尾诚实化·**消费方视角**):N1 按分数降序排后提示条目不得插进
13
+ # 真实候选之前(被断言的正是「任何按 score 排序/取首位的下游」会看到的顺序)、
14
+ # N2 最高分不得是提示、N3 返回条数恒 ≤ k 且 k=1 边界、N4 k=0 → 零结果、
15
+ # N5 提示不得呈现为「有效命中」(state≠ACCEPT ∧ score 不为正 ∧ 负性有独立可判字段)、
16
+ # N6 提示携带的 node_id 可被公共 API 解析回真节点(id/path 仍是落盘路径)、
17
+ # N7 提示只出现在尾部
18
+ # D 组 H10③:_compute_d 的分母只数真实候选(对照断言证明判别力)
19
+ # G 组 H7⑦:同一次结果的**节点身份**无重复(调用方按节点去重必成功)、负层/目标槽不以
20
+ # 正候选形态出现、基类与生产路径同口径、include_neg=False 负节点完全不出现
21
+ # F 组 H11⑥:真冲突 REJECT 仍建单(收窄≠关飞轮)、DEFER/BLINDSPOT/写链 DEFER 不建单、
22
+ # 判据与声明面同源
23
+ # S 组 H11⑦⑤:措辞不同的同一问题=同一张工单(零新建)、不同实际态=不同工单、
24
+ # 工单 id 可读可解析、现场刷新(新值在/旧值不在/id 与 created_at 不变)、同输入零写盘
25
+ # C 组 H11⑧:哨兵形态族在直连入口与「不传」同判(同表同口径)、真负条件不被吞(防放宽)
26
+ # H 组 M3:有节点零分桶 ≠ 真空库(两态可区分)、缺省调用与加参数前逐位一致、
27
+ # 单桶不再被说成巨桶(判据未放宽)、多桶真巨桶仍报巨桶
28
+ # 执行:python -X utf8 -m md_cg.test_recall_face_guards
29
+ # python -X utf8 -m md_cg.test_recall_face_guards --source-mutate=all
30
+ # python -X utf8 -m md_cg.test_recall_face_guards --source-mutate=H10-score
31
+ # 验证方式:本文件自跑(自带断言计数与退出码 rc=0/1);`--source-mutate` = **定点变异
32
+ # 自证**——把待验修复**在临时副本的源码文本上**逐点抽回改动前语义(在役工作树
33
+ # 只读、字节不变,变异结束用 md5 复核),再以子进程实跑本守卫:必须 rc=1 且
34
+ # 打红**预期断言集合**;随后在役树原样复跑必须转绿(无残余)。
35
+ # 不适用条件:情感交互|闲聊|纯查询无改动;负覆盖条目在 MCP 结果面的**渲染**
36
+ # (`_node_view` 只透 id/path/frontmatter/content)不属本文件面内;
37
+ # 「对方节点被覆写但工单未重发」形态无新鲜现场数据可依,不在面内。
38
+ import hashlib
39
+ import os
40
+ import re
41
+ import shutil
42
+ import subprocess
43
+ import sys
44
+ import tempfile
45
+
46
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
47
+
48
+ from md_cg import consistency, mdcos, routing # noqa: E402
49
+ from md_cg.mdcos import MdCGOS # noqa: E402
50
+ from md_cg.mdcg import MdCG # noqa: E402
51
+
52
+ PASS = 0
53
+ FAIL = 0
54
+ FAILS = []
55
+ _RUN = [0]
56
+ _SANDBOX = tempfile.mkdtemp(prefix="mdcg_recall_face_")
57
+ _HERE = os.path.dirname(os.path.abspath(__file__))
58
+
59
+ QUERY = "牛肉面 汤头"
60
+ NEG_REJ = "# 假设:牛肉面 用工业浓汤宝\n# 否决原因:偏离手工工艺\n"
61
+ NEG_UNR = "# 问题:牛肉面 汤色发黑\n# 目标:(未设定)\n"
62
+ #: 哨兵形态语料(现行 ⑧ 的现场:`不适用条件:无` 与 `约束:无` 相撞)
63
+ SENT_A = ("# 功能名:部署面端口约定\n"
64
+ "# 生效条件:载体/位置:prod 集群;方法:部署清单核对;约束:无\n"
65
+ "# 子功能:登记各监听端口\n# 执行:核对 manifest 的 ports 段\n"
66
+ "# 验证方式:test\n"
67
+ "# 不适用条件:无\n\n网关监听端口 8080/HTTP\n")
68
+ SENT_B = ("# 功能名:构建面产物约定\n"
69
+ "# 生效条件:载体/位置:ci 集群;方法:产物清单核对;约束:无\n"
70
+ "# 子功能:登记产物指纹\n# 执行:核对 manifest 的 artifacts 段\n"
71
+ "# 验证方式:test\n"
72
+ "# 不适用条件:无\n\n构建产物指纹 sha256 前 16 位\n")
73
+ #: B 组要一个真「有效命中」(否则 accept=0 时含尾/不含尾两种分母都得 1.0,无判别力)
74
+ ACCEPT_NODE = ("# 功能名:汤头工艺基线\n"
75
+ "# 生效条件:牛肉面 汤头\n"
76
+ "# 子功能:登记熬制工艺\n"
77
+ "# 执行:按基线核对\n"
78
+ "# 验证方式:test\n"
79
+ "# 不适用条件:工业化浓汤宝\n\n"
80
+ "牛肉面 汤头 熬制 工艺:小火 6 小时\n")
81
+ NEG_ROUTE = ("rejected", "unresolved", "goals")
82
+
83
+
84
+ def check(name, cond, detail=""):
85
+ global PASS, FAIL
86
+ if cond:
87
+ PASS += 1
88
+ print(" OK %s" % name)
89
+ else:
90
+ FAIL += 1
91
+ FAILS.append(name)
92
+ print(" FAIL %s %s" % (name, detail))
93
+
94
+
95
+ def check_exc(name, fn):
96
+ """fn() → (cond, detail)。抛异常记为该断言失败(变异运行不得因崩溃丢掉红项读数)。"""
97
+ try:
98
+ cond, detail = fn()
99
+ except Exception as e: # noqa: BLE001
100
+ cond, detail = False, "EXC %s: %s" % (type(e).__name__, e)
101
+ check(name, cond, detail)
102
+
103
+
104
+ def _mk(tag):
105
+ return MdCGOS(os.path.join(_SANDBOX, "lib_%s_r%d" % (tag, _RUN[0])),
106
+ autoflush=0)
107
+
108
+
109
+ def _base(tag):
110
+ return MdCG(os.path.join(_SANDBOX, "base_%s_r%d" % (tag, _RUN[0])))
111
+
112
+
113
+ def _is_neg(r):
114
+ """消费方视角的「这是提示不是候选」判据:独立字段(不从分数位反推)。"""
115
+ return bool((r[0] or {}).get("negative_coverage")
116
+ or (r[2] or {}).get("negative_coverage"))
117
+
118
+
119
+ def _node_key(card):
120
+ """一条结果背后的**节点身份**(提示条目走 node_id,正候选走 frontmatter.id)。
121
+
122
+ 只看结果里的 id 字符串会被「同一节点两种 id 形态」骗过(正候选用节点 id、
123
+ 提示条目用带层前缀的落盘路径)——去重必须落在节点身份上。
124
+ """
125
+ fm = (card or {}).get("frontmatter") or {}
126
+ return (card.get("node_id") or fm.get("id")
127
+ or card.get("id") or card.get("path"))
128
+
129
+
130
+ def _split(res):
131
+ return ([r for r in res if _is_neg(r)], [r for r in res if not _is_neg(r)])
132
+
133
+
134
+ def _neg_lib(tag):
135
+ """2 正候选(与查询**部分**词面重合 → 分数落在 (0,1) 开区间)+ 2 负节点。
136
+
137
+ 「分数落在开区间」是排序判别力的前提:若真实候选也能拿满分(1.0),
138
+ 改动前提示条目的 1.0 与之并列、稳定排序下真实候选仍在前,排序断言就抓不到
139
+ 「提示压过真实答案」这件事(实测定点变异实测:满分候选存在时 N1/N2 空转)。
140
+ """
141
+ cg = _mk(tag)
142
+ cg.add("mem_a", "# 功能名:样本a\n# 子功能:牛肉面 汤底 备用配方\n",
143
+ layer="knowledge")
144
+ cg.add("mem_b", "# 功能名:样本b\n# 子功能:汤头 温度 曲线\n",
145
+ layer="knowledge")
146
+ cg.add("r0", NEG_REJ, layer="rejected", verification_basis="test")
147
+ cg.add("u0", NEG_UNR, layer="unresolved", verification_basis="data")
148
+ cg.flush()
149
+ return cg
150
+
151
+
152
+ def _d_lib(tag):
153
+ """1 个 ACCEPT 有效命中 + 2 个非 ACCEPT 正候选 + 2 个负节点(D 分母对照用)。"""
154
+ cg = _mk(tag)
155
+ cg.add("acc_0", ACCEPT_NODE, layer="knowledge", verification_basis="test")
156
+ cg.add("mem_1", "# 功能名:样本1\n# 正文:牛肉面 汤头 备用配方",
157
+ layer="knowledge")
158
+ cg.add("mem_2", "# 功能名:样本2\n# 正文:牛肉面 汤头", layer="knowledge")
159
+ cg.add("r0", NEG_REJ, layer="rejected", verification_basis="test")
160
+ cg.add("u0", NEG_UNR, layer="unresolved", verification_basis="data")
161
+ cg.flush()
162
+ return cg
163
+
164
+
165
+ # ------------------------------------------------------------------ N 组(H10①②④)
166
+ def group_n():
167
+ print("\n[N] 负覆盖提示:消费方语义(排序位次 / k 预算 / 不冒充有效命中)")
168
+ cg = _neg_lib("tail")
169
+ res, _meta = cg.search(QUERY, k=4, record=False)
170
+ neg, prim = _split(res)
171
+ check_exc("N0 前置:确有提示条目与真实候选同场,且真实候选分数落在 (0,1) 开区间"
172
+ "(否则排序断言空转——改动前的提示 1.0 与满分候选并列时排不出差别)",
173
+ lambda: (len(neg) == 2 and len(prim) == 2
174
+ and 0.0 < min(r[1] for r in prim)
175
+ and max(r[1] for r in prim) < 1.0,
176
+ "neg=%d prim=%d prim_scores=%s"
177
+ % (len(neg), len(prim), [r[1] for r in prim])))
178
+ # 真实候选数:按「主候选实取数」= 结果总数 − 提示数
179
+ n_prim = len(prim)
180
+
181
+ def _score_sorted_first_k_is_primary():
182
+ order = sorted(range(len(res)), key=lambda i: (-res[i][1], i))
183
+ head = order[:n_prim]
184
+ bad = [i for i in head if _is_neg(res[i])]
185
+ return (not bad), ("降序前 %d 位含提示:%s | 降序=%s"
186
+ % (n_prim, [res[i][0].get("id") for i in bad],
187
+ [(res[i][0].get("id"), res[i][1]) for i in order]))
188
+
189
+ check_exc("N1 按分数降序排(下游最常见的消费方式)后,前「真实候选数」个位置"
190
+ "全是真实候选——提示条目不会被任何按 score 排序的消费方排到真实答案之前",
191
+ _score_sorted_first_k_is_primary)
192
+
193
+ def _top1_not_neg():
194
+ top = max(res, key=lambda r: r[1])
195
+ return (not _is_neg(top),
196
+ "top1=%s score=%s" % (top[0].get("id"), top[1]))
197
+
198
+ check_exc("N2 取最高分者不是提示条目(MCP 三处结果面把 score 逐字透给调用方,"
199
+ "调用方按 score 取首位必须命中真实答案)", _top1_not_neg)
200
+
201
+ def _neg_le_prim():
202
+ if not neg or not prim:
203
+ return False, "neg=%d prim=%d" % (len(neg), len(prim))
204
+ hi_neg = max(r[1] for r in neg)
205
+ hi_prim = max(r[1] for r in prim)
206
+ return hi_neg < hi_prim, "max(neg)=%s vs max(prim)=%s" % (hi_neg, hi_prim)
207
+
208
+ check_exc("N2b 提示条目的分数严格低于最高分真实候选(分数位不承载「更优」语义)",
209
+ _neg_le_prim)
210
+
211
+ def _k_budget():
212
+ detail = []
213
+ ok = True
214
+ for k in (1, 2, 3, 5, 20):
215
+ r, m = cg.search(QUERY, k=k, record=False)
216
+ nn, pp = _split(r)
217
+ b = m.get("k_budget") or {}
218
+ detail.append("k=%d len=%d prim=%d neg=%d budget=%s"
219
+ % (k, len(r), len(pp), len(nn), b))
220
+ if len(r) > k or len(r) != len(pp) + len(nn):
221
+ ok = False
222
+ if nn and b != {"k": k, "primary": len(pp), "neg_tail": len(nn)}:
223
+ ok = False
224
+ return ok, "; ".join(detail)
225
+
226
+ check_exc("N3 返回条数恒 ≤ k(提示计入 k 预算:改前 k=3→5、k=5→7,与 k 无关),"
227
+ "且 meta.k_budget 与实取逐位一致", _k_budget)
228
+
229
+ def _k0():
230
+ r, m = cg.search(QUERY, k=0, record=False)
231
+ return (r == [] and "k_budget" not in m, "k=0 → %s meta.k_budget=%s"
232
+ % ([x[0].get("id") for x in r], m.get("k_budget")))
233
+
234
+ check_exc("N4 k=0 → 零结果(提示也不超发;改前 k=0 仍追加 ≤3 条提示)", _k0)
235
+
236
+ def _not_answer():
237
+ bad = []
238
+ for r in neg:
239
+ card, sc, q = r
240
+ if (q or {}).get("state") == "ACCEPT" or sc > 0.0:
241
+ bad.append((card.get("id"), sc, (q or {}).get("state")))
242
+ indep = all((r[0] or {}).get("negative_coverage") is True
243
+ and (r[2] or {}).get("negative_coverage") is True
244
+ and r[0].get("neg_layer") in ("rejected", "unresolved")
245
+ for r in neg)
246
+ return (not bad and indep), "冒充有效命中=%s 独立字段齐备=%s" % (bad, indep)
247
+
248
+ check_exc("N5 提示不呈现为「有效命中」:state≠ACCEPT ∧ 分数不为正,"
249
+ "负性由独立字段(card/qual 的 negative_coverage + neg_layer)承载",
250
+ _not_answer)
251
+
252
+ def _node_id():
253
+ rows = []
254
+ for r in neg:
255
+ card = r[0]
256
+ nid = card.get("node_id")
257
+ got = cg.get(nid) if nid else None
258
+ ok_path = (card.get("id") == card.get("path")
259
+ and str(card.get("id") or "").startswith(
260
+ (card.get("neg_layer") or "") + "/"))
261
+ rows.append((nid, bool(got), ok_path))
262
+ return (all(bool(x[1]) and x[2] for x in rows), str(rows))
263
+
264
+ check_exc("N6 提示携带真正可用的节点身份:node_id 能被公共 API 解析回真节点,"
265
+ "而 id/path 仍是落盘路径(既有消费者按路径认路)", _node_id)
266
+
267
+ def _tail_only():
268
+ flags = [_is_neg(r) for r in res]
269
+ return (flags == [False] * len(prim) + [True] * len(neg),
270
+ str([(r[0].get("id"), _is_neg(r)) for r in res]))
271
+
272
+ check_exc("N7 提示只出现在尾部(全部真实候选之后),位置语义与注释一致",
273
+ _tail_only)
274
+ cg.close()
275
+
276
+
277
+ # ------------------------------------------------------------------ D 组(H10③)
278
+ def group_d():
279
+ print("\n[D] _compute_d 分母只数真实候选(提示不进分母,D 不虚高)")
280
+ cg = _d_lib("d")
281
+ res, _m = cg.search(QUERY, k=5, record=False)
282
+ neg, prim = _split(res)
283
+ acc = sum(1 for r in prim if r[1] > 0 and (r[2] or {}).get("state") == "ACCEPT")
284
+ d = cg._compute_d(QUERY, res)
285
+ d_naive = max(0.0, 1.0 - acc / len(res))
286
+ check_exc("D0 前置:确有 1 条 ACCEPT 有效命中(对照断言才有判别力)",
287
+ lambda: (acc == 1 and len(prim) == 3 and len(res) == 5,
288
+ "acc=%d prim=%d all=%d" % (acc, len(prim), len(res))))
289
+ check_exc("D1 同一批结果喂进去,D 与「只喂真实候选」的 D 逐位一致(分母不含提示)",
290
+ lambda: (abs(d - (1.0 - acc / len(prim))) < 1e-9,
291
+ "D=%s / 仅真实候选=%s" % (d, 1.0 - acc / len(prim))))
292
+ check_exc("D2 判别力对照:若把提示算进分母,D 会变成另一个数"
293
+ "(说明 D1 的相等不是恒等式)",
294
+ lambda: (abs(d - d_naive) > 1e-9,
295
+ "D=%s / 含尾分母=%s" % (d, d_naive)))
296
+ check_exc("D3 结果全是提示(无真实候选)→ 如实报「完全空白」1.0,不拿提示条数充数",
297
+ lambda: (cg._compute_d(QUERY, neg) == 1.0, str(d)))
298
+ check_exc("D4 空结果仍 1.0(既有语义未变)",
299
+ lambda: (cg._compute_d(QUERY, []) == 1.0, ""))
300
+ cg.close()
301
+
302
+
303
+ # ------------------------------------------------------------------ G 组(H7⑦)
304
+ def group_g():
305
+ print("\n[G] 负层与目标槽不进正排:同一负节点不双列(基类与生产路径同口径)")
306
+ b = _base("dedup")
307
+ b.add("mem_0", "# 功能名:正\n# 子功能:牛肉面 汤头 熬制 工艺\n",
308
+ layer="knowledge")
309
+ b.add("r0", NEG_REJ, layer="rejected", verification_basis="test")
310
+ b.add("u0", NEG_UNR, layer="unresolved", verification_basis="data")
311
+ b.add_goal("牛肉面 汤头 标准化", priority=0.6)
312
+ b.flush()
313
+ res, _meta = b.search(QUERY, k=5, record=False)
314
+ ids = [r[0].get("id") for r in res]
315
+ keys = [_node_key(r[0]) for r in res]
316
+
317
+ def _dup_free():
318
+ dups = sorted({k for k in keys if keys.count(k) > 1})
319
+ return (len(keys) == len(set(keys)) and len(ids) == len(set(ids)),
320
+ "ids=%s keys=%s 重复节点=%s" % (ids, keys, dups))
321
+
322
+ check_exc("G1 同一次结果里**节点身份**无重复(尾部提示与正候选指向同一节点时只算一次;"
323
+ "调用方按 id/节点去重必成功——改前同一负节点以两种 id 形态出现两次)",
324
+ _dup_free)
325
+
326
+ def _no_neg_as_positive():
327
+ bad = []
328
+ for r in res:
329
+ if _is_neg(r):
330
+ continue
331
+ layer = ((r[0].get("frontmatter") or {}).get("layer")
332
+ or str(r[0].get("id") or "").split("/")[0])
333
+ if layer in NEG_ROUTE:
334
+ bad.append((r[0].get("id"), layer))
335
+ return (not bad, "以正候选形态出现的负层/目标槽节点=%s" % bad)
336
+
337
+ check_exc("G2 负层(rejected/unresolved)与目标槽节点不以**正候选**形态出现"
338
+ "(无 negative_coverage 标记的条目的 layer 都不在 NEG_ROUTE_LAYERS)",
339
+ _no_neg_as_positive)
340
+
341
+ osr = _mk("dedup_os")
342
+ osr.add("mem_0", "# 功能名:正\n# 子功能:牛肉面 汤头 熬制 工艺\n",
343
+ layer="knowledge")
344
+ osr.add("r0", NEG_REJ, layer="rejected", verification_basis="test")
345
+ osr.add("u0", NEG_UNR, layer="unresolved", verification_basis="data")
346
+ osr.add_goal("牛肉面 汤头 标准化", priority=0.6)
347
+ osr.flush()
348
+ res_os, _m2 = osr.search(QUERY, k=5, record=False)
349
+ check_exc("G3 基类与生产路径(MdCGOS)同口径:同库同查询结果 id 逐位一致",
350
+ lambda: ([r[0].get("id") for r in res_os] == ids,
351
+ "%s vs %s" % ([r[0].get("id") for r in res_os], ids)))
352
+ res_off, _m3 = b.search(QUERY, k=5, include_neg=False, record=False)
353
+ check_exc("G4 include_neg=False → 负节点完全不出现(改前仍作正候选出现一次)",
354
+ lambda: ([r[0].get("id") for r in res_off] == ["mem_0"],
355
+ str([r[0].get("id") for r in res_off])))
356
+ check_exc("G5 目标槽不进正排(与生产路径 _candidates 同口径)",
357
+ lambda: (not any(str(i).startswith("goal_") for i in ids), str(ids)))
358
+ b.close()
359
+ osr.close()
360
+
361
+
362
+ # ------------------------------------------------------------------ F 组(H11⑥)
363
+ def group_f():
364
+ print("\n[F] 飞轮建单只限真冲突 REJECT(收窄 ≠ 静默关掉飞轮)")
365
+
366
+ def _unres(cg):
367
+ return [n for n, e in list((cg.index.get("nodes") or {}).items())
368
+ if e.get("layer") == "unresolved"]
369
+
370
+ cg = _mk("fw")
371
+ cg.add("discipline_prod", "# 功能:生产纪律\n# 执行:禁止删除生产数据\n",
372
+ layer="self", tags=["discipline"],
373
+ non_applicable_conditions=["删除生产数据"])
374
+ cg.add("k_offline",
375
+ "# 功能:离线批处理\n# 生效条件:离线环境\n# 不适用条件:生产环境\n",
376
+ layer="knowledge", non_applicable_conditions=["生产环境"])
377
+ cg.flush()
378
+ check_exc("F1 判据单点且声明面同源:FLYWHEEL_TRIGGERS == ('REJECT',),"
379
+ "consistency_catalog() 的 triggers_on 与之逐位一致",
380
+ lambda: (consistency.FLYWHEEL_TRIGGERS == ("REJECT",)
381
+ and cg.consistency_catalog()["auto_flywheel"]["triggers_on"]
382
+ == ["REJECT"], str(consistency.FLYWHEEL_TRIGGERS)))
383
+ r = cg.check_consistency("删除生产数据", layer="self", auto_flywheel=True)
384
+ check_exc("F2 真冲突 REJECT 仍建单(收窄不得把飞轮一起关掉)",
385
+ lambda: (r["verdict"] == "REJECT" and bool(r.get("unresolved_id"))
386
+ and len(_unres(cg)) == 1,
387
+ "%s / %s / %s" % (r["verdict"], r.get("unresolved_id"),
388
+ _unres(cg))))
389
+ n0 = len(_unres(cg))
390
+ r2 = cg.check_consistency("# 功能:生产批处理\n# 生效条件:生产环境\n",
391
+ layer="knowledge", auto_flywheel=True)
392
+ check_exc("F3 DEFER(条件互斥待确认)不建单,但判定本身照旧返回"
393
+ "(verdict=DEFER 且 strength ≥ CLASH_LOW)",
394
+ lambda: (r2["verdict"] == "DEFER"
395
+ and r2["conflict_strength"] >= consistency.CLASH_LOW
396
+ and not r2.get("unresolved_id")
397
+ and len(_unres(cg)) == n0,
398
+ "%s/%s/unr=%s/n=%d" % (r2["verdict"],
399
+ r2["conflict_strength"],
400
+ r2.get("unresolved_id"),
401
+ len(_unres(cg)))))
402
+ e = _mk("fw_empty")
403
+ r3 = e.check_consistency("# 功能:某情景\n# 生效条件:某个特殊条件\n",
404
+ layer="contextual", auto_flywheel=True)
405
+ check_exc("F4 BLINDSPOT(既有节点全无声明 ⇒ 检测前提不存在)不建单"
406
+ "(改前空库上每写一条都建一张工单)",
407
+ lambda: (r3["verdict"] == "BLINDSPOT"
408
+ and not r3.get("unresolved_id") and not _unres(e),
409
+ "%s / unr=%s / n=%d" % (r3["verdict"],
410
+ r3.get("unresolved_id"),
411
+ len(_unres(e)))))
412
+ cg.add("k_x", "# 功能:x\n# 生效条件:离线环境\n", layer="knowledge")
413
+ n1 = len(_unres(cg))
414
+ cg.add("k_y", "# 功能:y\n# 生效条件:生产环境\n", layer="knowledge",
415
+ consistency=True)
416
+ check_exc("F5 写链(add(consistency=True))的 DEFER 同样不建单"
417
+ "(同一判据,非各写一份)",
418
+ lambda: (len(_unres(cg)) == n1,
419
+ "%d → %d" % (n1, len(_unres(cg)))))
420
+ cg.close()
421
+ e.close()
422
+
423
+
424
+ # ------------------------------------------------------------------ S 组(H11⑦⑤)
425
+ def group_s():
426
+ print("\n[S] 工单身份=语义 slug(同 slug 即同工单);同题再检出时现场刷新")
427
+ cg = _mk("ticket")
428
+ cg.add("k_x", "# 功能名:对方节点\n# 生效条件:条件A\n", layer="knowledge")
429
+ cg.flush()
430
+ Q = "牛肉面 汤头 隔离级别"
431
+ rep1 = {"query": Q, "expected_state": "ACCEPT", "actual_state": "DEFER",
432
+ "missing": "缺区分条件",
433
+ "detail": [{"type": "same_condition_divergence", "with": "k_x",
434
+ "same_condition": 1.0, "slot_overlap": 1.0,
435
+ "conclusion_overlap": 0.69}]}
436
+ fw1 = cg.flywheel_step(rep1)
437
+ id1 = fw1["unresolved_id"]
438
+ n1 = cg.get(id1) or {}
439
+ fm1, c1 = n1.get("frontmatter") or {}, n1.get("content") or ""
440
+ check_exc("S1a 首次检出建单且带「# 现场:」数值(前置)",
441
+ lambda: (bool(id1) and "# 现场:" in c1 and "0.69" in c1, repr(c1[:80])))
442
+ n_before = len(cg.index["nodes"])
443
+ rep2 = dict(rep1)
444
+ rep2["expected_state"] = "无条件" # 措辞不同(不进身份)
445
+ rep2["detail"] = [{"type": "same_condition_divergence", "with": "k_x",
446
+ "same_condition": 1.0, "slot_overlap": 1.0,
447
+ "conclusion_overlap": 0.12}]
448
+ fw2 = cg.flywheel_step(rep2)
449
+ n2 = cg.get(id1) or {}
450
+ c2, fm2 = n2.get("content") or "", n2.get("frontmatter") or {}
451
+ check_exc("S1 同一主题 + 同一实际态、措辞与现场数值不同的同一问题 → 同一张工单、"
452
+ "零新建(改前按内容哈希各建一张)",
453
+ lambda: (fw2["unresolved_id"] == id1
454
+ and len(cg.index["nodes"]) == n_before,
455
+ "%s vs %s / n=%d→%d" % (fw2["unresolved_id"], id1,
456
+ n_before, len(cg.index["nodes"]))))
457
+ rep3 = dict(rep2)
458
+ rep3["actual_state"] = "REJECT"
459
+ fw3 = cg.flywheel_step(rep3)
460
+ check_exc("S2 不同实际态=不同工单(身份含实际态,不误并)",
461
+ lambda: (fw3["unresolved_id"] != id1, str(fw3["unresolved_id"])))
462
+ check_exc("S3 工单身份可读且可用:id 含主题词、能被公共 API 解析回同一节点",
463
+ lambda: (("隔离级别" in str(id1))
464
+ and (cg.get(id1) or {}).get("frontmatter", {}).get("id")
465
+ == id1, str(id1)))
466
+ check_exc("S4 现场刷新:新数值在、旧数值不残留(同一张工单的正文就地更新)",
467
+ lambda: ("0.12" in c2 and "0.69" not in c2,
468
+ repr([l for l in c2.splitlines() if l.startswith("# 现场")])))
469
+ check_exc("S4b 刷新只动正文:id / created_at 保持建单事实不变",
470
+ lambda: (fm2.get("created_at") == fm1.get("created_at")
471
+ and fm2.get("id") == id1,
472
+ str({k: fm2.get(k) for k in ("id", "created_at")})))
473
+
474
+ def _zero_write():
475
+ ent = cg.index["nodes"].get(id1) or {}
476
+ path = os.path.join(cg.root, ent.get("path") or "")
477
+ st0 = os.stat(path)
478
+ b0 = open(path, "rb").read()
479
+ cg.flywheel_step(rep2)
480
+ st1 = os.stat(path)
481
+ b1 = open(path, "rb").read()
482
+ same = (b0 == b1 and st0.st_mtime_ns == st1.st_mtime_ns
483
+ and st0.st_size == st1.st_size)
484
+ same_c = (cg.get(id1) or {}).get("content") == c2
485
+ return (same and same_c,
486
+ "bytes/mtime 变化=%s content 一致=%s" % (not same, same_c))
487
+
488
+ check_exc("S5 同输入再检出 → 零写盘(正文逐字相同即不制造脏写盘)", _zero_write)
489
+ cg.close()
490
+
491
+
492
+ # ------------------------------------------------------------------ C 组(H11⑧)
493
+ def group_c():
494
+ print("\n[C] CCG 哨兵直连入口同口径(复用同一张哨兵表,未新造第二张)")
495
+ cg = _mk("sent")
496
+ cg.add("z_a", SENT_A, layer="knowledge")
497
+ cg.flush()
498
+ vd_list = consistency.check(cg, SENT_B, layer="knowledge",
499
+ non_applicable_conditions=["无"],
500
+ auto_flywheel=True)
501
+ vd_none = consistency.check(cg, SENT_B, layer="knowledge",
502
+ auto_flywheel=True)
503
+ check_exc("C1 直连 consistency.check:显式哨兵列表「无」与不传同判"
504
+ "(ACCEPT / strength 0.0 / 无 conflicts / 无工单)",
505
+ lambda: (vd_list["verdict"] == vd_none["verdict"] == "ACCEPT"
506
+ and float(vd_list["conflict_strength"]) == 0.0
507
+ and not vd_list.get("unresolved_id")
508
+ and not (vd_list.get("conflicts") or []),
509
+ "%s/%s unr=%s" % (vd_list["verdict"],
510
+ vd_list["conflict_strength"],
511
+ vd_list.get("unresolved_id"))))
512
+ nid = cg.add("z_b", SENT_B, layer="knowledge", consistency=True,
513
+ non_applicable_conditions=["无"])
514
+ fm = (cg.get("z_b") or {}).get("frontmatter") or {}
515
+ check_exc("C2 引擎入口 cg.add(consistency=True) + 哨兵列表 → ACCEPT(无工单)"
516
+ "(改前 DEFER / strength=1.0 / 建单)",
517
+ lambda: (bool(nid)
518
+ and (fm.get("consistency") or {}).get("verdict") == "ACCEPT"
519
+ and not (fm.get("consistency") or {}).get("unresolved_id"),
520
+ str(fm.get("consistency"))))
521
+
522
+ def _family():
523
+ forms = ["无", "(无)", "", " ", "无。", "N/A", "-"]
524
+ hits = [s for s in forms if mdcos._is_null_condition(s)]
525
+ rows, bad = [], []
526
+ for s in hits:
527
+ v = consistency.check(cg, SENT_B, layer="knowledge",
528
+ non_applicable_conditions=[s])
529
+ ok = (v["verdict"] == vd_none["verdict"]
530
+ and abs(float(v["conflict_strength"])
531
+ - float(vd_none["conflict_strength"])) < 1e-9
532
+ and not v.get("unresolved_id") and not (v.get("conflicts") or []))
533
+ rows.append((s, v["verdict"], v["conflict_strength"], ok))
534
+ if not ok:
535
+ bad.append(rows[-1])
536
+ return (len(hits) >= 5 and not bad,
537
+ "哨兵形态 %s | 不同判=%s" % (rows, bad))
538
+
539
+ check_exc("C3 哨兵形态族(≥5 种)在直连入口全部与「不传」同判"
540
+ "(同表同口径,不是只认了字面量「无」)", _family)
541
+ real = ("# 功能名:本地开发约定\n"
542
+ "# 生效条件:本地开发环境\n"
543
+ "# 子功能:登记本地端口\n# 执行:核对本地清单\n"
544
+ "# 验证方式:test\n"
545
+ "# 不适用条件:本地开发环境\n\n本地监听端口 8081/HTTP\n")
546
+ vd_real = consistency.check(cg, real, layer="knowledge",
547
+ non_applicable_conditions=["本地开发环境"],
548
+ auto_flywheel=True)
549
+ check_exc("C4 防放宽:真负条件(自否定形态)不被哨兵剔除吞掉——仍判 REJECT "
550
+ "且仍建单",
551
+ lambda: (mdcos._is_null_condition("本地开发环境") is False
552
+ and vd_real["verdict"] == "REJECT"
553
+ and any(c.get("type") == "self_negation"
554
+ for c in (vd_real.get("conflicts") or []))
555
+ and bool(vd_real.get("unresolved_id")),
556
+ "%s/%s" % (vd_real["verdict"],
557
+ vd_real.get("unresolved_id"))))
558
+ cg.close()
559
+
560
+
561
+ # ------------------------------------------------------------------ H 组(M3)
562
+ def group_h():
563
+ print("\n[H] bucket_health 呼叫点:把节点总数传进去(空库 vs 有节点零分桶可区分)")
564
+ cg = _mk("m3_nobucket")
565
+ for i in range(3):
566
+ cg.add("s_%d" % i, "# 功能名:自我%d\n# 正文:某内容\n" % i,
567
+ layer="self")
568
+ cg.flush()
569
+ h = cg.health()
570
+ check_exc("H1 有节点但分桶表为空(全 self 层 → buckets=={})→ 健康度不得报 ok"
571
+ "(改前报 ok=True/reason=empty 冒充真空库)",
572
+ lambda: (cg.index.get("buckets") == {}
573
+ and h.get("ok") is False
574
+ and h.get("reason") == "empty_buckets"
575
+ and h.get("nodes") == 3 and h.get("total_nodes") == 3,
576
+ "buckets=%s health=%s"
577
+ % (cg.index.get("buckets"),
578
+ {k: h.get(k) for k in ("ok", "reason", "nodes",
579
+ "total_nodes")})))
580
+ cg.close()
581
+ empty = _mk("m3_empty")
582
+ h0 = empty.health()
583
+ check_exc("H2 真空库(零节点零桶)→ ok=True/reason=empty(不误报)",
584
+ lambda: (h0.get("ok") is True and h0.get("reason") == "empty"
585
+ and h0.get("total_nodes") == 0,
586
+ str({k: h0.get(k) for k in ("ok", "reason", "total_nodes")})))
587
+ empty.close()
588
+ check_exc("H3 两态可区分:同一份「空分桶」读数在两种库上给出不同的 ok/reason,"
589
+ "且 nodes 如实为库内节点数",
590
+ lambda: (h0.get("reason") != h.get("reason")
591
+ and h0.get("ok") != h.get("ok")
592
+ and h0.get("nodes") != h.get("nodes"),
593
+ "%s vs %s" % ({k: h0.get(k) for k in ("ok", "reason",
594
+ "nodes")},
595
+ {k: h.get(k) for k in ("ok", "reason",
596
+ "nodes")})))
597
+ check_exc("H4 向后兼容:缺省 total_nodes 的调用(既有调用方只传 counts)"
598
+ "与加参数前逐位一致,且返回键集不因新参数变化",
599
+ lambda: (routing.bucket_health({})
600
+ == {"ok": True, "reason": "empty", "buckets": 0}
601
+ and set(routing.bucket_health({"a": 5, "b": 5,
602
+ "c": 5}).keys())
603
+ == {"ok", "buckets", "nodes", "max_bucket_share",
604
+ "singleton_ratio", "expected_scan", "problems"},
605
+ "%s" % routing.bucket_health({})))
606
+ one = _mk("m3_single")
607
+ one.add("one", "# 功能名:单桶\n# 正文:知识面 内容\n", layer="knowledge")
608
+ one.flush()
609
+ h1 = one.health()
610
+ check_exc("H5 单桶库如实读数:ok 仍为 False(判据未放宽)且不再被说成「巨桶」",
611
+ lambda: (h1.get("buckets") == 1 and h1.get("ok") is False
612
+ and h1.get("problems")
613
+ and not any("巨桶" in p for p in h1["problems"]),
614
+ str(h1.get("problems"))))
615
+ one.close()
616
+ check_exc("H6 防放宽:多桶真巨桶(80%)仍被点名「巨桶」",
617
+ lambda: (any("巨桶" in p for p in
618
+ routing.bucket_health({"a": 8, "b": 1,
619
+ "c": 1})["problems"]),
620
+ str(routing.bucket_health({"a": 8, "b": 1,
621
+ "c": 1})["problems"])))
622
+
623
+
624
+ _GROUPS = (group_n, group_d, group_g, group_f, group_s, group_c, group_h)
625
+
626
+
627
+ def _run_all():
628
+ global PASS, FAIL, FAILS
629
+ _RUN[0] += 1
630
+ PASS, FAIL, FAILS = 0, 0, []
631
+ for g in _GROUPS:
632
+ g()
633
+ return PASS, list(FAILS)
634
+
635
+
636
+ # ------------------------------------------------------------- 定点变异(自证)
637
+ #: 每项 = (文件, 正则, 替换, 期望转红的断言 id, 说明)。正则在**临时副本**的源码文本上
638
+ #: 命中恰好 1 处(命中数 ≠1 视为变异未生效,直接判红),把该处修复抽回改动前语义。
639
+ _MUTATIONS = {
640
+ "H10-score": (
641
+ "mdcg.py", r"(?m)^NEG_COVERAGE_SCORE = 0\.0\s*$",
642
+ "NEG_COVERAGE_SCORE = 1.0", ["N1", "N2", "N2b", "N5"],
643
+ "提示条目分数退回 1.0(改动前:高过任何真实候选)"),
644
+ "H10-kbudget": (
645
+ "mdcg.py", r"(?m)^\s*return max\(0, int\(k\) - int\(n_tail\)\)\s*$",
646
+ " return max(0, int(k))", ["N3"],
647
+ "主结果退回 scored[:k](尾巴在 k 之外 → 结果数 k+3)"),
648
+ "H10-ddenom": (
649
+ "mdcg.py",
650
+ r"(?m)^\s*cands = \[r for r in results if not _is_neg_coverage\(r\[0\]\)\]\s*$",
651
+ " cands = list(results)", ["D1", "D2"],
652
+ "D 分母退回含提示条目(信息差被虚高)"),
653
+ "H7-negroute": (
654
+ "mdcg.py",
655
+ r"(?m)^(\s*)neg_layer_entries\.append\(e\)\n\s*continue\s*$",
656
+ r"\1neg_layer_entries.append(e)", ["G1", "G2", "G3", "G4", "G5"],
657
+ "抽掉循环里的 continue(负节点重新落进正排候选 = 双列)"),
658
+ "H11-flywheel": (
659
+ "consistency.py", r"(?m)^FLYWHEEL_TRIGGERS = \(\"REJECT\",\)\s*$",
660
+ 'FLYWHEEL_TRIGGERS = ("REJECT", "DEFER", "BLINDSPOT")',
661
+ ["F1", "F3", "F4", "F5"], "自动建单面退回三态(DEFER/BLINDSPOT 也建单)"),
662
+ "H11-sentinel": (
663
+ "consistency.py",
664
+ r"(?m)^ if non_applicable_conditions:\n"
665
+ r" from \.mdcos import _is_null_condition\n"
666
+ r" non_applicable_conditions = \[x for x in non_applicable_conditions\n"
667
+ r" if not _is_null_condition\(x\)\]\n",
668
+ " pass # 变异:哨兵漏斗抽掉\n", ["C1", "C2", "C3"],
669
+ "哨兵漏斗抽掉(直连入口把哨兵当真实条件)"),
670
+ "H11-slug": (
671
+ "mdcg.py", r"(?m)^\s*return _tasks\.slugify\(raw\)\s*$",
672
+ " return hashlib.sha1(str(question or \"\").encode()).hexdigest()[:10]",
673
+ ["S1", "S3"], "工单身份退回内容哈希(措辞一变就另建一张)"),
674
+ "H11-refresh": (
675
+ "mdcg.py", r"(?m)^\s*fm, old = self\._read\(entry\)\s*$",
676
+ " return False\n fm, old = self._read(entry)", ["S4"],
677
+ "刷新抽掉(同 id 直接 return,现场永久冻结)"),
678
+ "M3-health": (
679
+ "mdcg.py",
680
+ r"(?m)^\s*h = routing\.bucket_health\(self\.index\.get\(\"buckets\", \{\}\),\n"
681
+ r"\s*total_nodes=len\(self\.index\[\"nodes\"\]\)\)\s*$",
682
+ ' h = routing.bucket_health(self.index.get("buckets", {}))',
683
+ ["H1", "H3"], "呼叫点退回不传 total_nodes(有节点零分桶仍是 ok=True)"),
684
+ }
685
+
686
+ _ASSERT_RE = re.compile(r"^\s*(?:\[FAIL\]|FAIL)\s+(\S+)")
687
+
688
+
689
+ def _reds(stdout):
690
+ out = []
691
+ for line in (stdout or "").splitlines():
692
+ m = _ASSERT_RE.match(line)
693
+ if m:
694
+ out.append(m.group(1))
695
+ return out
696
+
697
+
698
+ def _md5(path):
699
+ with open(path, "rb") as f:
700
+ return hashlib.md5(f.read()).hexdigest()
701
+
702
+
703
+ def _run_guard(cwd):
704
+ env = dict(os.environ)
705
+ env["PYTHONUTF8"] = "1"
706
+ env["PYTHONPATH"] = cwd
707
+ return subprocess.run(
708
+ [sys.executable, "-X", "utf8", "-m", "md_cg.test_recall_face_guards"],
709
+ cwd=cwd, env=env, capture_output=True, text=True,
710
+ encoding="utf-8", errors="replace")
711
+
712
+
713
+ def _mutate_mode(which):
714
+ repo = os.path.dirname(_HERE)
715
+ names = list(_MUTATIONS) if which == "all" else [which]
716
+ files = sorted({_MUTATIONS[n][0] for n in names
717
+ if n in _MUTATIONS})
718
+ for n in names:
719
+ if n not in _MUTATIONS:
720
+ print("未知变异 %r(可选:%s)" % (n, sorted(_MUTATIONS)))
721
+ return 2
722
+ before = {f: _md5(os.path.join(_HERE, f)) for f in files}
723
+ targets = []
724
+ rc = 0
725
+ print("\n=== 定点变异自证(在**临时副本**源码上抽掉修复 → 本守卫必红 → "
726
+ "在役树复跑必绿)===")
727
+ print(" 基准在役树:%s" % ", ".join("%s=%s" % (f, before[f]) for f in files))
728
+ for name in names:
729
+ fname, pat, sub, want, note = _MUTATIONS[name]
730
+ tmp = tempfile.mkdtemp(prefix="recall_face_mut_")
731
+ try:
732
+ shutil.copytree(_HERE, os.path.join(tmp, "md_cg"),
733
+ ignore=shutil.ignore_patterns("__pycache__"))
734
+ path = os.path.join(tmp, "md_cg", fname)
735
+ targets.append(path)
736
+ with open(path, encoding="utf-8") as f:
737
+ src = f.read()
738
+ hits = len(re.findall(pat, src))
739
+ if hits != 1:
740
+ print(" FAIL 变异 %s 未生效:源码命中 %d 处(期望 1)" % (name, hits))
741
+ rc = 1
742
+ continue
743
+ with open(path, "w", encoding="utf-8", newline="") as f:
744
+ f.write(re.sub(pat, sub, src, count=1))
745
+ r = _run_guard(tmp)
746
+ reds = _reds(r.stdout)
747
+ missed = [w for w in want if w not in reds]
748
+ summary = [l for l in (r.stdout or "").splitlines()
749
+ if l.startswith("====")]
750
+ print(" [%s] %s" % (name, note))
751
+ print(" 基准副本 %s(抽掉修复的那份)" % path)
752
+ print(" rc=%d 红项 %d 个:%s" % (r.returncode, len(reds),
753
+ " ".join(reds) or "(无)"))
754
+ print(" %s" % (summary[-1] if summary else "(无汇总行)"))
755
+ if r.returncode != 1 or not reds:
756
+ print(" FAIL 变异 %s 未打红(rc=%d,红项 0)——守卫对该修复无判别力"
757
+ % (name, r.returncode))
758
+ print(" stderr: %s" % (r.stderr or "")[-400:])
759
+ rc = 1
760
+ elif missed:
761
+ print(" FAIL 变异 %s 未打红预期断言:%s" % (name, missed))
762
+ rc = 1
763
+ else:
764
+ print(" ok 变异 %s 打红预期断言 %d 项" % (name, len(want)))
765
+ finally:
766
+ shutil.rmtree(tmp, ignore_errors=True)
767
+ print(" ok 变异落笔点共 %d 处,全部在临时副本内(在役树零写入由构造保证,"
768
+ "并已 rmtree):%s" % (len(targets), "; ".join(targets[:2]) + (" …" if
769
+ len(targets) > 2 else "")))
770
+ after = {f: _md5(os.path.join(_HERE, f)) for f in files}
771
+ if before != after:
772
+ print(" FAIL 在役源码 md5 与基准不同(%s)——本驱动不写在役树,"
773
+ "若确有差异即为**并发改动**落在同一工作树;本轮的变异读数以副本快照为准,"
774
+ "须在稳定树上重跑本模式后再采信。" % after)
775
+ rc = 1
776
+ else:
777
+ print(" ok 在役工作树基准一致(读数为副本快照,落笔面只在临时副本):%s"
778
+ % ", ".join("%s=%s" % (f, after[f]) for f in files))
779
+ r0 = _run_guard(repo)
780
+ reds0 = _reds(r0.stdout)
781
+ tail0 = [l for l in (r0.stdout or "").splitlines() if l.startswith("====")]
782
+ print(" [恢复] 在役树原样复跑:rc=%d 红项=%d %s"
783
+ % (r0.returncode, len(reds0), tail0[-1] if tail0 else ""))
784
+ if r0.returncode != 0 or reds0:
785
+ print(" FAIL 恢复后未转绿,残余红项:%s" % (reds0 or "rc≠0"))
786
+ rc = 1
787
+ else:
788
+ print(" ok 无残余红项")
789
+ return rc
790
+
791
+
792
+ def main(argv):
793
+ which = None
794
+ for a in argv:
795
+ if a.startswith("--source-mutate"):
796
+ which = a.split("=", 1)[1] if "=" in a else "all"
797
+ if which is not None:
798
+ return _mutate_mode(which)
799
+ ok, bad = _run_all()
800
+ print("\n==== 召回面守卫(H10/H7/H11/M3 呼叫点):%d 通过 / %d 失败%s ===="
801
+ % (ok, len(bad), (":" + "; ".join(bad)) if bad else ""))
802
+ return 1 if bad else 0
803
+
804
+
805
+ if __name__ == "__main__":
806
+ if hasattr(sys.stdout, "reconfigure"):
807
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
808
+ try:
809
+ _rc = main(sys.argv[1:])
810
+ finally:
811
+ shutil.rmtree(_SANDBOX, ignore_errors=True)
812
+ sys.exit(_rc)