@furongjun1999/dsh-memory 0.6.0 → 0.7.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 (202) hide show
  1. package/README.md +618 -572
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/README.md +1 -0
  5. package/docs/discipline/harnesses.yaml +31 -0
  6. package/docs/discipline/templates/full.md.tmpl +1 -1
  7. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. package/docs/eval//345/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +460 -0
  15. 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
  16. package/docs/eval//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +97 -0
  17. package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +458 -0
  18. 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
  19. 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
  20. 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
  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_v20.md +283 -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_v21.md +224 -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_v22.md +223 -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_v23.md +293 -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_v24.md +303 -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_v25.md +230 -0
  27. package/docs/hive//346/243/200/347/264/242/347/256/227/346/263/225/345/217/243/345/276/204/345/257/271/347/205/247_v0.1.md +136 -11
  28. 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 +52 -2
  29. 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
  30. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +100 -0
  31. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
  32. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +57 -9
  33. 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
  34. package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +183 -0
  35. 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
  36. 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
  37. 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
  38. package/docs/plans//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +547 -0
  39. 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
  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 +460 -460
  41. package/lib/bridge.js +24 -2
  42. package/lib/hooks.d.ts +31 -2
  43. package/lib/hooks.js +219 -16
  44. package/lib/init.js +16 -1
  45. package/lib/lib/prompt_safety.d.ts +52 -1
  46. package/lib/lib/prompt_safety.js +76 -1
  47. package/md_cg/audit.py +652 -379
  48. package/md_cg/bench6_arms.py +1 -1
  49. package/md_cg/bench_e2e_locomo_qa.py +11 -4
  50. package/md_cg/bench_p0.py +1 -1
  51. package/md_cg/branches.py +2 -2
  52. package/md_cg/ccgc.py +1071 -1006
  53. package/md_cg/chain.py +48 -1
  54. package/md_cg/consistency.py +97 -9
  55. package/md_cg/consolidate.py +34 -9
  56. package/md_cg/crypto.py +59 -26
  57. package/md_cg/docindex.py +16 -1
  58. package/md_cg/evolution.py +17 -1
  59. package/md_cg/export.py +1 -1
  60. package/md_cg/forgetting.py +309 -13
  61. package/md_cg/freshness.py +511 -0
  62. package/md_cg/fsutil.py +454 -8
  63. package/md_cg/generation.py +409 -0
  64. package/md_cg/hotcache.py +4 -1
  65. package/md_cg/identity.py +3 -3
  66. package/md_cg/insight.py +24 -3
  67. package/md_cg/linkref.py +1 -1
  68. package/md_cg/logref.py +327 -0
  69. package/md_cg/mcp_server.py +4261 -3818
  70. package/md_cg/mdcg.py +4695 -3547
  71. package/md_cg/mdcos.py +4672 -4159
  72. package/md_cg/mreview/pipeline.py +15 -1
  73. package/md_cg/nodefile.py +713 -576
  74. package/md_cg/protect.py +158 -11
  75. package/md_cg/protocol.py +41 -2
  76. package/md_cg/provenance.py +22 -4
  77. package/md_cg/reach.py +4 -4
  78. package/md_cg/readcache.py +76 -15
  79. package/md_cg/reconcile.py +1 -1
  80. package/md_cg/refindex.py +246 -37
  81. package/md_cg/refine.py +1 -1
  82. package/md_cg/routing.py +32 -5
  83. package/md_cg/run_tests.py +17 -0
  84. package/md_cg/scrub.py +30 -3
  85. package/md_cg/security.py +47 -1
  86. package/md_cg/self_state.py +5 -5
  87. package/md_cg/semantic/canonical.py +22 -0
  88. package/md_cg/semantic/unify.py +73 -22
  89. package/md_cg/semantic/unify_fixture.json +25 -0
  90. package/md_cg/sleep.py +1297 -0
  91. package/md_cg/sources.py +23 -6
  92. package/md_cg/srcindex.py +352 -0
  93. package/md_cg/stg.py +47 -3
  94. package/md_cg/subgraph.py +2 -2
  95. package/md_cg/sustain.py +1436 -1168
  96. package/md_cg/tasks.py +469 -470
  97. package/md_cg/test_auto_defaults.py +424 -0
  98. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  99. package/md_cg/test_b1b2_write_face.py +417 -0
  100. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  101. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  102. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  103. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  104. package/md_cg/test_boundary_hit.py +410 -0
  105. package/md_cg/test_c3_transient_read_negative.py +793 -0
  106. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  107. package/md_cg/test_ccg_form_parity.py +188 -0
  108. package/md_cg/test_ccgc.py +28 -3
  109. package/md_cg/test_en_pipeline.py +22 -13
  110. package/md_cg/test_generation_guard.py +352 -0
  111. package/md_cg/test_govern_directread.py +17 -4
  112. package/md_cg/test_h2_session_view_norm.py +233 -0
  113. package/md_cg/test_h4_sustain_snapshot.py +1387 -0
  114. package/md_cg/test_hive_ingest.py +285 -285
  115. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  116. package/md_cg/test_issue43_default_policy.py +865 -0
  117. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  118. package/md_cg/test_linkref.py +10 -3
  119. package/md_cg/test_lock.py +2 -2
  120. package/md_cg/test_logref.py +1109 -0
  121. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  122. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  123. package/md_cg/test_mr_m2.py +15 -3
  124. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  125. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  126. package/md_cg/test_n176_link_trust.py +221 -0
  127. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  128. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  129. package/md_cg/test_n195_writepath_reload.py +283 -0
  130. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  131. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  132. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  133. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  134. package/md_cg/test_n201_proposal_visibility.py +382 -0
  135. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  136. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  137. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  138. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  139. package/md_cg/test_n212_n213_n224_generation_gates.py +519 -0
  140. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  141. package/md_cg/test_n225_nonobject_load.py +1110 -0
  142. package/md_cg/test_n230_dirty_replay.py +375 -0
  143. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  144. package/md_cg/test_neg_condition_hits.py +333 -0
  145. package/md_cg/test_neg_tail_honesty.py +663 -0
  146. package/md_cg/test_none_id_write_guard.py +4 -3
  147. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  148. package/md_cg/test_p1.py +5 -5
  149. package/md_cg/test_p11_consistency.py +161 -12
  150. package/md_cg/test_p26_refindex.py +2 -2
  151. package/md_cg/test_p27_docindex.py +777 -774
  152. package/md_cg/test_p28_refcheck.py +740 -13
  153. package/md_cg/test_p29_session_ingest_export.py +2 -2
  154. package/md_cg/test_p2_mcp.py +12 -1
  155. package/md_cg/test_p2_six_elements.py +463 -0
  156. package/md_cg/test_p30_maintain.py +33 -2
  157. package/md_cg/test_p31_insight.py +1 -0
  158. package/md_cg/test_p3_legacy_closure.py +433 -0
  159. package/md_cg/test_p4_freshness.py +680 -0
  160. package/md_cg/test_p8_subgraph_chain.py +14 -1
  161. package/md_cg/test_p9c_dedup_hints.py +276 -0
  162. package/md_cg/test_policy_required_ccg.py +426 -0
  163. package/md_cg/test_protocol.py +22 -0
  164. package/md_cg/test_rank_parity_score_mode.py +522 -0
  165. package/md_cg/test_read_face_input_gates.py +363 -0
  166. package/md_cg/test_read_face_semantics.py +489 -0
  167. package/md_cg/test_recall_face_guards.py +812 -0
  168. package/md_cg/test_rejected_credential_forms.py +188 -0
  169. package/md_cg/test_rejected_redact.py +146 -0
  170. package/md_cg/test_retr_s1b.py +2 -2
  171. package/md_cg/test_retr_s5.py +20 -7
  172. package/md_cg/test_review_conformance.py +21 -3
  173. package/md_cg/test_security_audit_v21.py +2 -2
  174. package/md_cg/test_semantic_canonical.py +5 -3
  175. package/md_cg/test_server_version.py +67 -0
  176. package/md_cg/test_sleep.py +611 -0
  177. package/md_cg/test_sleep_p1.py +784 -0
  178. package/md_cg/test_srcindex.py +171 -0
  179. package/md_cg/test_tenant_env_override_warn.py +63 -50
  180. package/md_cg/test_time_core_lint.py +968 -0
  181. package/md_cg/test_token_lowercase_form.py +315 -0
  182. package/md_cg/test_unify_default_off.py +701 -0
  183. package/md_cg/test_unify_scope.py +182 -0
  184. package/md_cg/test_v21r1_package_version.py +310 -0
  185. package/md_cg/test_writepipe.py +10 -4
  186. package/md_cg/tokens.py +225 -95
  187. package/md_cg/tool_face.py +4 -4
  188. package/md_cg/trust.py +49 -5
  189. package/md_cg/units.py +204 -6
  190. package/md_cg/weights.py +4 -4
  191. package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
  192. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  193. package/md_cg/writelimit.py +42 -8
  194. package/md_cg/writepipe.py +651 -554
  195. package/package.json +4 -2
  196. package/skills/plugin.json +1 -1
  197. package/src/bridge.ts +25 -2
  198. package/src/hooks.ts +229 -16
  199. package/src/init.ts +15 -1
  200. package/src/lib/prompt_safety.ts +88 -1
  201. package/utf8_boot.py +237 -0
  202. package/zcode/AGENTS.md +10 -10
@@ -0,0 +1,238 @@
1
+ # -*- coding: utf-8 -*-
2
+ # 功能名:B4 分片目录缺失自愈的**独立**判别力守卫(长驻进程内 `_index_log/` 被删后 append 必自愈、必留痕、read_all 不回归)
3
+ # 生效条件:`md_cg/fsutil.py` 的 `ShardedLog`(`__init__` 首建 / `append` 打开前复查)经唯一实现点 `ensure_shard_dir` 保证分片目录存在时;本守卫以真实 `MdCG` 实例在沙箱库根上端到端跑,**不读在役库**。
4
+ # 子功能:①端到端自愈(写 1 条 → rmtree `_index_log/` → 再写 3 条全成功 + 目录重建 + 分片记录与读面可见);②可观测(记账 + 有界样本 + stderr 告警,重建失败也记账);③`read_all` 既有语义(非目录 → `[]`);④单点(AST:全文件 `ensure_shard_dir` 只有 1 份定义,`ShardedLog` 内 2 处调用点全是委托、无裸 makedirs 副本)
5
+ # 执行:python -X utf8 -m md_cg.test_b4_shard_dir_selfheal_guard(`scripts/run_tests.py md_cg` 按 `md_cg/test_*.py` 自动收集)
6
+ # 验证方式:本文件三段(A 自愈 / B 留痕 / C 既有语义 + D 单点 + E 失败结构化);定点变异自证 = `%TEMP%\mutate_b4_guard.py`(M1 抽掉 `append` 的 `ensure_shard_dir` 调用 → 必红;M2 抽掉 `note_shard_dir_rebuild` 调用 → 必红;逐变异 finally 回写原文)
7
+ # 不适用条件:①只在「句柄为 None 的重开点」复查——已持有打开句柄期间目录被删不在本守卫面内(Windows 上被打开的文件无法删除,生产写点 `MdCG.flush` 每批写完即 close ⇒ 必经重开点);②跨进程并发唯一性/自愈不在本面;③不覆盖 MCP 出口渲染(另有 test_b4_shard_dir_selfheal.py 的 E 组)
8
+ """B4 守卫(独立复核版):分片目录缺失自愈 —— 判别力守卫 + 定点变异自证。
9
+
10
+ 病灶(修复前,长驻进程内 `_index_log/` 被删):
11
+ ``ShardedLog`` 只在 ``__init__`` 里 ``makedirs``,``append`` 首次 ``open`` 前不
12
+ 复查目录 ⇒ 目录被删后**每次** append 都在 ``open`` 抛 ``FileNotFoundError``、
13
+ 目录永不重建、索引静默落后于已落盘的正文 ``.md``。
14
+
15
+ 修复(单点):``md_cg/fsutil.py:663`` ``ensure_shard_dir`` 是分片目录存在性的
16
+ **唯一**实现,两处调用点均为委托——``fsutil.py:706``(``__init__``, "init",首建
17
+ 不记账)与 ``fsutil.py:724``(``append``, "append",打开前复查、记账 + 告警)。
18
+
19
+ 本文件的三条硬断言(对应派单 ①②③)与两条加固(单点 / 失败结构化):
20
+ A 自愈端到端:删目录后再写三条全成功,目录重建、分片记录与读面都可见
21
+ B 容忍 ≠ 静默:自愈被记账(+1)、样本指向本库、stderr 有告警
22
+ C 既有语义:``read_all`` 对不存在的目录 / 非目录路径仍返回 ``[]``
23
+ D 单点:``ensure_shard_dir`` 只有一份定义,``ShardedLog`` 内只有两处委托调用
24
+ E 重建失败:结构化 ``ShardDirError``(code 可机读)且失败也记账 + 告警
25
+
26
+ 沙箱(派单硬约束 1):临时根 ``tempfile.mkdtemp``;``MDCG_ROOT`` /
27
+ ``MDCG_AUX_ROOT`` / ``MDCG_MASTER_KEY`` 全在沙箱内(且必须在任何 md_cg 子模块
28
+ import 之前设好——``crypto`` 的密钥路径在 import 期求值);跑完 rmtree,
29
+ **绝不碰在役库与在役 serve**。
30
+ """
31
+ import ast
32
+ import contextlib
33
+ import io
34
+ import os
35
+ import shutil
36
+ import sys
37
+ import tempfile
38
+
39
+ # 沙箱必须在**任何** md_cg 子模块 import 之前设好。
40
+ _SANDBOX = tempfile.mkdtemp(prefix="b4_guard_sandbox_")
41
+ os.environ["MDCG_AUX_ROOT"] = _SANDBOX
42
+ os.environ["MDCG_ROOT"] = os.path.join(_SANDBOX, "root")
43
+ os.environ["MDCG_MASTER_KEY"] = os.urandom(32).hex()
44
+ os.environ.pop("MDCG_POLICY_FILE", None)
45
+
46
+ from . import fsutil, mdcg # noqa: E402
47
+
48
+ _FSUTIL_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)),
49
+ "fsutil.py")
50
+
51
+ _OK = []
52
+ _BAD = []
53
+
54
+
55
+ def _check(name, cond, detail=""):
56
+ """一条断言 = 一个可定点变异的判据;失红时原样打印断言名与读数。"""
57
+ if cond:
58
+ _OK.append(name)
59
+ print(" ok %s" % name)
60
+ else:
61
+ _BAD.append(name)
62
+ print(" RED %s [%s]" % (name, detail))
63
+
64
+
65
+ def main():
66
+ log_dir = None
67
+ try:
68
+ # ================= A 自愈端到端(派单 ①) =================
69
+ print("== A 自愈端到端:删掉库根 _index_log/ 后再写三条 ==")
70
+ root = os.path.join(_SANDBOX, "lib_a")
71
+ cg = mdcg.MdCG(root, autoflush=1) # autoflush=1 ⇒ 每写必 flush
72
+ log_dir = os.path.join(root, "_index_log")
73
+
74
+ fsutil.reset_shard_dir_stats()
75
+ err1 = None
76
+ try:
77
+ cg.add("b4g_n1", "B4G 第一条(删目录前)", layer="knowledge")
78
+ except Exception as exc: # noqa: BLE001
79
+ err1 = "%s: %s" % (type(exc).__name__, exc)
80
+ _check("B4G-A1 首建:写第一条成功且分片目录由 __init__ 建出",
81
+ err1 is None and os.path.isdir(log_dir),
82
+ repr(err1) + " isdir=%s" % os.path.isdir(log_dir))
83
+ # 首建是正常路径,不该记账(记账面要能区分「病态自愈」与「建库」)
84
+ _check("B4G-A2 首建不记账(where=init ⇒ 重建计数仍 0)",
85
+ fsutil.shard_dir_stats()[0] == 0,
86
+ "rebuilds=%d" % fsutil.shard_dir_stats()[0])
87
+ # 长驻进程形态:flush 已 close 句柄但**保留** ShardedLog 实例
88
+ shard_before = getattr(getattr(cg, "_log", None), "path", None)
89
+ _check("B4G-A3 前置:ShardedLog 实例在(句柄已 close、实例仍在)",
90
+ shard_before is not None
91
+ and os.path.isfile(shard_before)
92
+ and getattr(cg._log, "_fh", "x") is None,
93
+ "path=%r" % (shard_before,))
94
+
95
+ shutil.rmtree(log_dir)
96
+ _check("B4G-A4 前置:库根 _index_log/ 确已删除",
97
+ not os.path.isdir(log_dir), "isdir=%s" % os.path.isdir(log_dir))
98
+
99
+ fsutil.reset_shard_dir_stats()
100
+ stderr_buf = io.StringIO()
101
+ errs = []
102
+ with contextlib.redirect_stderr(stderr_buf):
103
+ for i, nid in enumerate(("b4g_n2", "b4g_n3", "b4g_n4"), 1):
104
+ try:
105
+ cg.add(nid, "B4G 删目录后第%d条(标记%d)" % (i, i),
106
+ layer="knowledge")
107
+ except Exception as exc: # noqa: BLE001
108
+ errs.append("%s → %s: %s" % (nid, type(exc).__name__, exc))
109
+ _check("B4G-A5 删目录后再写三条:全部成功(无异常)",
110
+ not errs, "; ".join(errs))
111
+ _check("B4G-A6 分片目录被重建",
112
+ os.path.isdir(log_dir), "isdir=%s" % os.path.isdir(log_dir))
113
+ _check("B4G-A7 重建后分片路径照常(仍是 self._log.path 同一分片名)",
114
+ bool(shard_before) and os.path.isfile(shard_before),
115
+ "path=%r isfile=%s" % (shard_before,
116
+ os.path.isfile(shard_before)))
117
+
118
+ # ---- 读面:分片重放面(read_all)与节点读面(新实例 get) ----
119
+ recs = fsutil.ShardedLog.read_all(log_dir)
120
+ got_ids = set(r.get("id") for r in recs if isinstance(r, dict))
121
+ want = {"b4g_n2", "b4g_n3", "b4g_n4"}
122
+ _check("B4G-A8 删目录后三条记录真落进重建的分片(read_all 含 n2/n3/n4)",
123
+ want.issubset(got_ids), "ids=%s" % sorted(got_ids))
124
+
125
+ cg2 = mdcg.MdCG(root, autoflush=1) # 全新实例 = 后续读面
126
+ got = cg2.get("b4g_n4")
127
+ _check("B4G-A9 后续读面读到新节点(新实例 get 返回正文含标记3)",
128
+ isinstance(got, dict) and "标记3" in (got.get("content") or ""),
129
+ repr(got and got.get("content"))[:80])
130
+
131
+ # ================= B 容忍 ≠ 静默(派单 ②) =================
132
+ print("== B 自愈可观测:记账 / 样本 / stderr 告警 ==")
133
+ rebuilds, samples, failures, _fsamples = fsutil.shard_dir_stats()
134
+ _check("B4G-B1 自愈被记账(reset 后重建计数恰 +1)",
135
+ rebuilds == 1, "rebuilds=%d failures=%d" % (rebuilds, failures))
136
+ abs_dir = os.path.abspath(log_dir)
137
+ _check("B4G-B2 记账样本指向本库分片目录且调用点标签为 append",
138
+ any(p == abs_dir and w == "append" for p, w in samples),
139
+ "samples=%r" % (samples,))
140
+ err_text = stderr_buf.getvalue()
141
+ _check("B4G-B3 自愈有 stderr 告警(不是静默吞掉)",
142
+ "分片目录缺失,已重建" in err_text
143
+ and "调用点 append" in err_text
144
+ and os.path.basename(log_dir) in err_text,
145
+ repr(err_text[:120]))
146
+
147
+ # ================= C read_all 既有语义不回归(派单 ③) =================
148
+ print("== C read_all 对非目录仍返回空列表(既有语义) ==")
149
+ missing = os.path.join(_SANDBOX, "no_such_shard_dir")
150
+ _check("B4G-C1 read_all(不存在的目录) == []",
151
+ fsutil.ShardedLog.read_all(missing) == [],
152
+ repr(fsutil.ShardedLog.read_all(missing))[:60])
153
+ fpath = os.path.join(_SANDBOX, "shard_is_a_file.txt")
154
+ with open(fpath, "w", encoding="utf-8") as fh:
155
+ fh.write("占位:路径是文件不是目录\n")
156
+ _check("B4G-C2 read_all(路径是文件) == []",
157
+ fsutil.ShardedLog.read_all(fpath) == [],
158
+ repr(fsutil.ShardedLog.read_all(fpath))[:60])
159
+
160
+ # ================= D 单点(判据只有一份实现,第二处必须委托) =================
161
+ print("== D 单点:ensure_shard_dir 只有一份定义 + 两处委托调用 ==")
162
+ with open(_FSUTIL_SRC, encoding="utf-8") as fh:
163
+ src = fh.read()
164
+ tree = ast.parse(src)
165
+ defs = [n for n in tree.body
166
+ if isinstance(n, ast.FunctionDef) and n.name == "ensure_shard_dir"]
167
+ _check("B4G-D1 全 fsutil.py 内 `def ensure_shard_dir` 只有 1 份",
168
+ len(defs) == 1, "defs=%d" % len(defs))
169
+
170
+ cls = [n for n in tree.body
171
+ if isinstance(n, ast.ClassDef) and n.name == "ShardedLog"]
172
+ calls = {}
173
+ naked_mkdir = []
174
+ if cls:
175
+ for m in cls[0].body:
176
+ if not isinstance(m, ast.FunctionDef):
177
+ continue
178
+ for node in ast.walk(m):
179
+ if isinstance(node, ast.Call):
180
+ fn = node.func
181
+ if isinstance(fn, ast.Name) and fn.id == "ensure_shard_dir":
182
+ where = None
183
+ if len(node.args) >= 2 and isinstance(
184
+ node.args[1], ast.Constant):
185
+ where = node.args[1].value
186
+ calls.setdefault(m.name, []).append(where)
187
+ if isinstance(fn, ast.Attribute) and fn.attr in (
188
+ "makedirs", "mkdir"):
189
+ naked_mkdir.append("%s:%s" % (m.name, fn.attr))
190
+ _check("B4G-D2 ShardedLog.__init__ 委托 ensure_shard_dir(...,\"init\")",
191
+ "init" in calls.get("__init__", []), "calls=%r" % (calls,))
192
+ _check("B4G-D3 ShardedLog.append 委托 ensure_shard_dir(...,\"append\")",
193
+ "append" in calls.get("append", []), "calls=%r" % (calls,))
194
+ _check("B4G-D4 ShardedLog 内无裸 makedirs/mkdir 副本",
195
+ not naked_mkdir, "naked=%r" % (naked_mkdir,))
196
+
197
+ # ================= E 重建失败:结构化 + 也留痕(加固) =================
198
+ print("== E 重建失败:结构化 ShardDirError(code 可机读)+ 失败也记账 ==")
199
+ e_dir = os.path.join(_SANDBOX, "lib_e", "_index_log")
200
+ os.makedirs(os.path.dirname(e_dir), exist_ok=True)
201
+ elog = fsutil.ShardedLog(e_dir) # init 首建
202
+ elog.append({"id": "b4g_e1"})
203
+ elog.close() # 复现「句柄为 None 的重开点」
204
+ shutil.rmtree(e_dir)
205
+ with open(e_dir, "w", encoding="utf-8") as fh:
206
+ fh.write("占位:用文件占住分片目录路径\n") # makedirs 必失败
207
+ f_before = fsutil.shard_dir_stats()[2]
208
+ ebuf = io.StringIO()
209
+ eerr = None
210
+ with contextlib.redirect_stderr(ebuf):
211
+ try:
212
+ elog.append({"id": "b4g_e2"})
213
+ except Exception as exc: # noqa: BLE001
214
+ eerr = exc
215
+ _check("B4G-E1 重建失败抛结构化 ShardDirError(不是裸 OSError 族)",
216
+ isinstance(eerr, fsutil.ShardDirError),
217
+ "%s: %s" % (type(eerr).__name__, str(eerr)[:80]))
218
+ _check("B4G-E2 仍是 OSError 子类 + code 可机读 + message 首字段含 [code]",
219
+ isinstance(eerr, OSError)
220
+ and getattr(eerr, "code", None) == fsutil.SHARD_DIR_ERR_CODE
221
+ and str(eerr).startswith("[%s]" % fsutil.SHARD_DIR_ERR_CODE),
222
+ "code=%r" % (getattr(eerr, "code", None),))
223
+ f_after = fsutil.shard_dir_stats()[2]
224
+ _check("B4G-E3 重建失败也记账 + 告警(异常可能被上层兜底吞掉)",
225
+ f_after == f_before + 1 and "重建失败" in ebuf.getvalue(),
226
+ "failures %d→%d stderr=%r" % (f_before, f_after,
227
+ ebuf.getvalue()[:100]))
228
+
229
+ finally:
230
+ shutil.rmtree(_SANDBOX, ignore_errors=True)
231
+
232
+ print("\n==== B4 独立守卫:%d 通过%s ====" % (
233
+ len(_OK), ",%d 失败:%s" % (len(_BAD), "; ".join(_BAD)) if _BAD else ""))
234
+ return 1 if _BAD else 0
235
+
236
+
237
+ if __name__ == "__main__":
238
+ sys.exit(main())
@@ -0,0 +1,410 @@
1
+ # -*- coding: utf-8 -*-
2
+ """拒绝域双语义判据守卫(P0-4 / §5.2)——`boundary_hit` 与资格 REJECT **分开**。
3
+
4
+ 契约(设计稿 `docs/plans/睡眠与自迭代_功能优化设计_v0.4.md` §5.2):
5
+
6
+ 拒绝域命中**必须标记为「边界命中」**,与资格判定的 REJECT **分开计数、分开
7
+ 呈现**——即:检索「什么条件下不适用」→ 该节点以 `boundary_hit` 命中(可召回、
8
+ 可展示);该节点的**正例资格不受影响**(不因边界命中就 REJECT 它)。
9
+
10
+ 探针给的现状(`md_cg/**` 全仓 grep):
11
+
12
+ `boundary_hit` 字面量**零命中**(只出现在设计稿与本工作流草稿里);
13
+ `不适用条件` 现在**只在一处**被当负条件用——`MdCG.judge_qualification`
14
+ (`mdcg.py` :2997 取 `non_applicable_conditions` → :2998 调单点
15
+ `neg_condition_hits` → :3000-3002 命中即 `REJECT`)。
16
+ ⇒「同一字段只承载一条语义(负条件→REJECT)」,P0-4 要的两件都**尚未存在**。
17
+
18
+ P0-4 当轮**不接检索面**(接线在 P2):只落判据函数与守卫,检索读数必须零位移。
19
+ **(P2-2 已接线,2026-10-01)**:接线点两处——检索面 `MdCGOS.search_rrf`
20
+ (`md_cg/mdcos.py:1754`)与默认打分收口 `MdCG._emit`(`md_cg/mdcg.py:4191`);
21
+ 两处都调**同一个** `MdCG.judge_with_boundary`,边界标记一律经同族的 `boundary_mark`
22
+ 取(其体内复用 P0-4 的 `boundary_hit`)——**单点复用、不另写第二份判据**。
23
+ 上面那段「零命中」是 **P0-4 当轮的探针快照**,已被本文件 B5 组的接线断言取代。
24
+
25
+ 断言分组:
26
+
27
+ B0 判据在位与形状——`boundary_hit` / `is_boundary_query` / `judge_with_boundary`
28
+ / `tally_boundary` / `BOUNDARY_MARKER` 齐备;返回体的**键集逐项钉死**
29
+ (P2 直接依赖的形状)。
30
+ B1 单点结构(静态)——`boundary_hit` 的命中走**既有负条件判据单点**
31
+ `neg_condition_hits`;`judge_with_boundary` 的资格腿走
32
+ `MdCG.judge_qualification`(不重判、不抄第二份)。
33
+ B2 双语义可分(端到端,真建临时库)——边界问句 ⇒ `boundary_hit` 且
34
+ `qualification.state != REJECT`(**不因边界命中而 REJECT**);
35
+ 非边界问句而情境真落在拒绝域 ⇒ 照旧 `REJECT`(**灵敏度不变**);
36
+ 两者 `counts.reject` 与 `counts.boundary_hit` **分开计数**。
37
+ B3 分开呈现——返回体两个顶层键 `qualification` / `boundary` 各自独立;
38
+ 标记只出现在 `boundary` 侧,`qualification` 侧不得出现 `marker`。
39
+ B4 聚合计数——`tally_boundary` 的 `self_negation` 恒 0(非 0 = 自否定残留);
40
+ reject 与 boundary_hit 分别计数、互不掩盖。
41
+ B5 **接线已落(P2-2 改判,2026-10-01)**——P0-4 时的「本轮零接线」断言
42
+ 已被 P2 的**接线契约**取代(§5.4:六要素进默认检索后拒绝域必须双语义可分):
43
+ B5 检索面(`md_cg/mdcos.py`)与默认打分收口(`MdCG._emit`)**均已接线**;
44
+ B5a `boundary_mark` 仍是**单点复用**(体内调 P0-4 的 `boundary_hit`,
45
+ 不另写第二份判据);
46
+ B5b **默认口径零位移**:非边界问句下 `judge_with_boundary` 的 qualification
47
+ ≡ `judge_qualification`(逐例对拍),且无边界命中时 `_emit` 不落
48
+ 边界键(默认链路的 meta 键集合不变)。
49
+
50
+ 运行:python -X utf8 -m md_cg.test_boundary_hit
51
+ python -X utf8 -m md_cg.test_boundary_hit --head-baseline # 红基线自证
52
+ python -X utf8 -m md_cg.test_boundary_hit --mutate # 定点变异自证
53
+
54
+ 退出码(fail-closed):0 = 全绿 / 红基线确有红项;1 = 断言失败 / 变异未按预期转红;
55
+ 2 = ANCHOR-MISS(变异锚点在当前源码里找不到)。
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import contextlib
60
+ import io
61
+ import os
62
+ import shutil
63
+ import sys
64
+ import tempfile
65
+
66
+ from . import mdcg as M
67
+ from .mdcg import (BOUNDARY_MARKER, MdCG, boundary_hit, is_boundary_query,
68
+ strip_boundary_terms, tally_boundary)
69
+
70
+ _PASS = []
71
+ _FAIL = []
72
+ _SKIP = []
73
+ _SRC = {}
74
+ _TMP = tempfile.mkdtemp(prefix="mdcg_boundary_")
75
+ _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
76
+
77
+ #: 拒绝域条目(语料口径,与既有负条件守卫的 G7 同形)
78
+ NEG = "本条不覆盖 tags 字段的扫描面,闸门只扫 content"
79
+ DOC = ("# 功能名:写入闸门对 tags 的扫描面\n"
80
+ "# 生效条件:问写入闸门内容闸门的判据时\n"
81
+ "# 子功能:md_cg/writepipe.py\n# 执行:读闸门代码\n"
82
+ "# 验证方式:编译器/静态检查通过\n"
83
+ "# 不适用条件:%s\n" % NEG)
84
+
85
+ _SHAPE_JWB = {"qualification", "boundary", "counts", "recallable", "reason"}
86
+ _SHAPE_QUAL = {"state", "reason", "boundary_suppressed"}
87
+ _SHAPE_BND = {"hit", "terms", "marker", "reason"}
88
+ _SHAPE_CNT = {"accept", "reject", "defer", "blindspot", "boundary_hit"}
89
+
90
+
91
+ def ok(cond, msg, extra=""):
92
+ (_PASS if cond else _FAIL).append(msg)
93
+ print((" PASS " if cond else " FAIL ") + msg
94
+ + ((" ← " + str(extra)) if (extra and not cond) else ""))
95
+
96
+
97
+ def _rel_text(rel: str) -> str:
98
+ if rel in _SRC:
99
+ return _SRC[rel]
100
+ with open(os.path.join(_REPO, rel), encoding="utf-8") as f:
101
+ return f.read()
102
+
103
+
104
+ def _mk_applicable():
105
+ """真建临时库:一个六要素齐全、带拒绝域的节点(走完整写入面)。"""
106
+ from .mdcos import MdCGOS
107
+ r = tempfile.mkdtemp(prefix="bh_", dir=_TMP)
108
+ cg = MdCGOS(r)
109
+ cg.add("bh1", DOC, layer="knowledge", verification_basis="test",
110
+ non_applicable_conditions=[NEG])
111
+ cg.flush()
112
+ got = cg.get("bh1")
113
+ return cg, {"frontmatter": got["frontmatter"], "content": got["content"]}
114
+
115
+
116
+ # ---------------------------------------------------------------- B0
117
+ def b0():
118
+ print("== B0 判据在位与返回形状 ==")
119
+ ok(BOUNDARY_MARKER == "boundary_hit",
120
+ "B0 展示标记字面量 = boundary_hit(§5.2 的判据名)")
121
+ for name in ("boundary_hit", "is_boundary_query", "strip_boundary_terms",
122
+ "tally_boundary"):
123
+ ok(callable(globals().get(name) or getattr(M, name, None)),
124
+ "B0a 模块级判据 `%s` 在位" % name)
125
+ ok(hasattr(MdCG, "judge_with_boundary")
126
+ and isinstance(MdCG.__dict__.get("judge_with_boundary"), staticmethod),
127
+ "B0b MdCG.judge_with_boundary 在位且为 staticmethod(与 judge_qualification 同形)")
128
+ cg, node = _mk_applicable()
129
+ r = MdCG.judge_with_boundary(node, "问:什么条件下不适用?" + NEG)
130
+ ok(set(r) == _SHAPE_JWB, "B0c 顶层键集逐项一致", sorted(set(r)))
131
+ ok(set(r["qualification"]) == _SHAPE_QUAL, "B0d qualification 键集",
132
+ sorted(r["qualification"]))
133
+ ok(set(r["boundary"]) == _SHAPE_BND, "B0e boundary 键集",
134
+ sorted(r["boundary"]))
135
+ ok(set(r["counts"]) == _SHAPE_CNT, "B0f counts 键集", sorted(r["counts"]))
136
+ ok(isinstance(r["recallable"], bool), "B0g recallable 为 bool")
137
+ bh = boundary_hit(node, NEG)
138
+ ok(set(bh) == {"hit", "terms", "marker", "reason", "scene"},
139
+ "B0h boundary_hit 的键集", sorted(bh))
140
+ cg.close()
141
+
142
+
143
+ # ---------------------------------------------------------------- B1
144
+ def b1():
145
+ print("== B1 单点结构(静态)==")
146
+ src = _rel_text("md_cg/mdcg.py")
147
+ i = src.index("def boundary_hit(")
148
+ body = src[i:i + 1600]
149
+ ok("neg_condition_hits(terms, scene_str," in body,
150
+ "B1 boundary_hit 的命中走**既有负条件判据单点** neg_condition_hits")
151
+ code = "\n".join(l for l in body.splitlines()
152
+ if not l.strip().startswith("#"))
153
+ ok("any(w in scene" not in code and "for w in str(" not in code,
154
+ "B1a 不重判、不抄第二份切词实现")
155
+ j = src.index("def judge_with_boundary(")
156
+ jb = src[j:j + 2600]
157
+ ok("MdCG.judge_qualification(" in jb,
158
+ "B1b 资格腿走 MdCG.judge_qualification(不重判)")
159
+ ok(jb.count("MdCG.judge_qualification(") == 2,
160
+ "B1c 两条分支(边界问句摘词面重跑 / 其余原样)各调一次",
161
+ jb.count("MdCG.judge_qualification("))
162
+
163
+
164
+ # ---------------------------------------------------------------- B2
165
+ def b2():
166
+ print("== B2 双语义可分(端到端)==")
167
+ cg, node = _mk_applicable()
168
+ # ① 边界问句:问的就是该节点的拒绝域
169
+ q1 = "这条记忆在什么条件下不适用?" + NEG
170
+ ok(is_boundary_query(q1) is True, "B2 前置:q1 被识别为边界问句", q1)
171
+ r1 = MdCG.judge_with_boundary(node, q1)
172
+ ok(r1["boundary"]["hit"] is True,
173
+ "B2a 边界命中(hit=True)", r1["boundary"])
174
+ ok(r1["boundary"]["marker"] == "boundary_hit",
175
+ "B2b 命中即带展示标记 boundary_hit", r1["boundary"]["marker"])
176
+ ok(r1["qualification"]["state"] != "REJECT",
177
+ "B2c **不因边界命中而 REJECT**:资格态 = %s" % r1["qualification"]["state"],
178
+ r1["qualification"])
179
+ ok(r1["counts"]["boundary_hit"] == 1 and r1["counts"]["reject"] == 0,
180
+ "B2d 分开计数:boundary_hit=1 / reject=0", r1["counts"])
181
+ ok(r1["recallable"] is True, "B2e 可召回可展示(recallable=True)")
182
+ # ② 非边界问句 + 情境真落在拒绝域:灵敏度不变 ⇒ 照旧 REJECT
183
+ r2 = MdCG.judge_with_boundary(node, NEG)
184
+ ok(is_boundary_query(NEG) is False, "B2f 前置:q2 不是边界问句")
185
+ ok(r2["qualification"]["state"] == "REJECT",
186
+ "B2g **灵敏度不变**:非边界问句下情境落在拒绝域 ⇒ 照旧 REJECT",
187
+ r2["qualification"])
188
+ ok(r2["counts"]["reject"] == 1,
189
+ "B2h 该 REJECT 计入 counts.reject(不与边界命中混计)", r2["counts"])
190
+ # ③ 边界问句但拒绝域未命中 ⇒ 无边界命中
191
+ r3 = MdCG.judge_with_boundary(node, "这条记忆在什么条件下不适用?")
192
+ ok(r3["boundary"]["hit"] is False and r3["boundary"]["marker"] is None,
193
+ "B2i 边界问句但词面未命中 ⇒ 无边界标记", r3["boundary"])
194
+ # ④ 不变量批量:任取边界问句组合,(boundary.hit ∧ state==REJECT) 恒假
195
+ bad = []
196
+ for q in ("什么条件下不适用 " + NEG, "何时不适用:" + NEG,
197
+ "拒绝域 " + NEG, "边界条件?" + NEG, NEG + " 什么情况下不适用"):
198
+ rr = MdCG.judge_with_boundary(node, q)
199
+ if rr["boundary"]["hit"] and rr["qualification"]["state"] == "REJECT":
200
+ bad.append(q)
201
+ ok(not bad, "B2j **不变量**:边界命中 ∧ 资格 REJECT 的组合恒不出现", bad)
202
+ cg.close()
203
+
204
+
205
+ # ---------------------------------------------------------------- B3
206
+ def b3():
207
+ print("== B3 分开呈现 ==")
208
+ cg, node = _mk_applicable()
209
+ r = MdCG.judge_with_boundary(node, "什么条件下不适用 " + NEG)
210
+ ok("marker" not in r["qualification"],
211
+ "B3 资格面**不含**展示标记(两语义各管各的呈现)",
212
+ sorted(r["qualification"]))
213
+ ok("marker" in r["boundary"],
214
+ "B3a 展示标记只在边界面出现")
215
+ ok(r["qualification"]["boundary_suppressed"] is True,
216
+ "B3b 边界问句下资格判定走了「摘词面重跑」并如实留痕")
217
+ r2 = MdCG.judge_with_boundary(node, NEG)
218
+ ok(r2["qualification"]["boundary_suppressed"] is False,
219
+ "B3c 非边界问句下不摘词面(boundary_suppressed=False,灵敏度不变)")
220
+ cg.close()
221
+
222
+
223
+ # ---------------------------------------------------------------- B4
224
+ def b4():
225
+ print("== B4 聚合计数 ==")
226
+ cg, node = _mk_applicable()
227
+ recs = [MdCG.judge_with_boundary(node, "什么条件下不适用 " + NEG),
228
+ MdCG.judge_with_boundary(node, NEG),
229
+ MdCG.judge_with_boundary(node, "写入闸门怎么配"),
230
+ MdCG.judge_with_boundary(node, "何时不适用 " + NEG)]
231
+ t = tally_boundary(recs)
232
+ ok(t["self_negation"] == 0,
233
+ "B4 self_negation=0(边界问句下无自否定残留)", t)
234
+ ok(t["boundary_hit"] == 3 and t["reject"] == 1,
235
+ "B4a 边界命中 3 条 / 资格 REJECT 1 条——**分开计数、互不掩盖**", t)
236
+ ok(t["total"] == 4 and t["reject"] + t["accept"] + t["defer"]
237
+ + t["blindspot"] == 4,
238
+ "B4b 资格四态计数自洽(相加 = total)", t)
239
+ ok(t["recallable"] == 3, "B4c 可召回 3 条(REJECT 那条不可召回)", t)
240
+ cg.close()
241
+
242
+
243
+ # ---------------------------------------------------------------- B5
244
+ def b5():
245
+ print("== B5 接线已落(P2-2 改判):检索面引用 + 单点复用 + 默认口径零位移 ==")
246
+ src = _rel_text("md_cg/mdcg.py")
247
+ mdcos = _rel_text("md_cg/mdcos.py")
248
+ ok("boundary_mark" in mdcos and "judge_with_boundary" in mdcos,
249
+ "B5 检索面(mdcos.py)已引用本组判据(P2-2 接线)")
250
+ i = src.index(" def _emit(self, scored")
251
+ body = src[i:i + 12000]
252
+ ok("boundary_mark(" in body,
253
+ "B5 默认打分收口 `MdCG._emit` 已接线(结果卡带 boundary_hit 标记)")
254
+ # 单点复用:boundary_mark 体内必须调 P0-4 的 boundary_hit,不得另写一份
255
+ j = src.index("def boundary_mark(")
256
+ bm = src[j:j + 2200]
257
+ ok("boundary_hit(" in bm and "rejection_index_terms(" in bm,
258
+ "B5a boundary_mark 单点复用 P0-4 判据(不另写第二份切词/命中实现)")
259
+ # 默认口径零位移:非边界问句下 judge_with_boundary ≡ judge_qualification
260
+ doc = ("# 功能名:x\n# 生效条件:无条件\n# 子功能:无\n"
261
+ "# 执行:无\n# 验证方式:test\n# 不适用条件:其它会话\n")
262
+ node = {"frontmatter": {"non_applicable_conditions": ["其它会话"]},
263
+ "content": doc}
264
+ same = True
265
+ for q, ctx in (("苹果 章节", None), ("其它会话", None),
266
+ ("刚才在干什么", {"scene": "其它会话"}),
267
+ ("怎么验证的", {"scene": "编译"})):
268
+ a = M.MdCG.judge_with_boundary(node, q, ctx)["qualification"]
269
+ b = dict(M.MdCG.judge_qualification(node, q, ctx))
270
+ b.setdefault("boundary_suppressed", False)
271
+ if a.get("state") != b.get("state"):
272
+ same = False
273
+ ok(same, "B5b 非边界问句下资格态 ≡ judge_qualification(默认口径零位移)")
274
+ ok('if _bnd["hit"]:' in body or "if _bnd[\"hit\"]:" in body,
275
+ "B5b 边界读数只在确有边界命中时落键(meta 键集合默认不变)")
276
+
277
+
278
+ _GROUPS = (b0, b1, b2, b3, b4, b5)
279
+
280
+
281
+ def _run_groups() -> int:
282
+ _PASS.clear()
283
+ _FAIL.clear()
284
+ for g in _GROUPS:
285
+ try:
286
+ g()
287
+ except Exception as exc: # noqa: BLE001
288
+ ok(False, "断言组 %s 抛异常:%s" % (g.__name__, exc))
289
+ import traceback
290
+ print(traceback.format_exc()[-900:])
291
+ return len(_FAIL)
292
+
293
+
294
+ # ---------------------------------------------------------------- 变异表
295
+ # 锚点 = (名字, rel, old, new)。每次变异必须让套件**转红**:
296
+ # M1 边界问句不做抑制(= P0-4 之前的形态:命中即 REJECT)→ B2c 红
297
+ # M2 不设展示标记(= 边界不可展示)→ B2b/B2i/B3a/B4 红
298
+ # M3 判据不复用单点(抄第二份切词实现)→ B1/B1a 红
299
+ _MUTATIONS = (
300
+ ("边界问句不做抑制(改动前形态)", "md_cg/mdcg.py",
301
+ ' if bh["hit"] and bq:', " if False:"),
302
+ # 注:锚点带尾换行——`boundary_mark` 与 `boundary_hit` 各有一行同前缀,
303
+ # 只有 `boundary_hit` 那行以换行收尾(`boundary_mark` 那行后还接了 "why")
304
+ ("不设展示标记", "md_cg/mdcg.py",
305
+ ' "marker": BOUNDARY_MARKER if hit else None,\n',
306
+ ' "marker": None,\n'),
307
+ ("判据不复用单点(抄第二份实现)", "md_cg/mdcg.py",
308
+ " hit = neg_condition_hits(terms, scene_str, MdCG._self_topic_text(fm, content))",
309
+ " hit = [x for x in terms if any(w in scene_str for w in str(x).split())]"),
310
+ )
311
+
312
+
313
+ def _mutate_mode(list_only: bool = False) -> int:
314
+ print("!! 定点变异自证:逐条把双语义判据改回「改动前/失效」形态,套件必须转红\n")
315
+ if list_only:
316
+ for name, rel, _o, _n in _MUTATIONS:
317
+ print(" %-34s [%s]" % (name, rel))
318
+ return 0
319
+ anchor_miss, bad = [], []
320
+ with contextlib.redirect_stdout(io.StringIO()):
321
+ clean = _run_groups()
322
+ print(" 未变异基线:失败=%d(必须为 0)" % clean)
323
+ if clean:
324
+ bad.append("未变异基线即失败")
325
+
326
+ for name, rel, old, new in _MUTATIONS:
327
+ src = _rel_text(rel)
328
+ if old not in src:
329
+ print(" ANCHOR-MISS %s —— 锚点在 %s 源码里找不到(实现改了却没同步"
330
+ "本表)" % (name, rel))
331
+ anchor_miss.append(name)
332
+ continue
333
+ mut_src = src.replace(old, new, 1)
334
+ _SRC[rel] = mut_src
335
+ try:
336
+ mut = _exec_module("md_cg._bh_mut", rel, mut_src)
337
+ _install(mut)
338
+ with contextlib.redirect_stdout(buf := io.StringIO()):
339
+ reds = _run_groups()
340
+ detail = buf.getvalue()
341
+ finally:
342
+ _install(M)
343
+ _SRC.pop(rel, None)
344
+ red_lines = [l for l in detail.splitlines()
345
+ if l.strip().startswith("FAIL ")]
346
+ verdict = "红" if reds else "**仍全绿 = 该判据空转**"
347
+ print(" %s %-34s 红项=%d %s"
348
+ % ("OK " if reds else "MISS ", name, reds, verdict))
349
+ for l in red_lines[:5]:
350
+ print(" " + l.strip()[5:])
351
+ if not reds:
352
+ bad.append(name)
353
+
354
+ if anchor_miss:
355
+ print("\nANCHOR-MISS:%s" % "、".join(anchor_miss))
356
+ print("退出码 2(fail-closed):变异表锚点漂移即判失败,不得静默跳过")
357
+ return 2
358
+ print("\n定点变异自证:%s"
359
+ % ("PASS(每处判据都被打红)" if not bad else "FAIL —— " + "、".join(bad)))
360
+ return 0 if not bad else 1
361
+
362
+
363
+ def _install(mod) -> None:
364
+ """把某个 mdcg 模块的判据装进守卫的全局名(变异/还原共用)。"""
365
+ global boundary_hit, is_boundary_query, strip_boundary_terms, tally_boundary
366
+ global MdCG, BOUNDARY_MARKER
367
+ boundary_hit = mod.boundary_hit
368
+ is_boundary_query = mod.is_boundary_query
369
+ strip_boundary_terms = mod.strip_boundary_terms
370
+ tally_boundary = mod.tally_boundary
371
+ MdCG = mod.MdCG
372
+ BOUNDARY_MARKER = mod.BOUNDARY_MARKER
373
+ globals()["M"] = mod
374
+
375
+
376
+ def _exec_module(name: str, rel: str, text: str):
377
+ import types
378
+ ns = {"__name__": name, "__package__": "md_cg",
379
+ "__file__": os.path.join(_REPO, rel)}
380
+ exec(compile(text, rel, "exec"), ns) # noqa: S102 —— 基线自证用
381
+ m = types.ModuleType(name)
382
+ m.__dict__.update(ns)
383
+ return m
384
+
385
+
386
+ def main() -> int:
387
+ try:
388
+ if "--mutate" in sys.argv:
389
+ return _mutate_mode("--list" in sys.argv)
390
+ if "--head-baseline" in sys.argv:
391
+ print("!! 红基线模式:`is_boundary_query` 换回「恒 False」"
392
+ "(= P0-4 之前的形态:边界词面命中即 REJECT),应当转红\n")
393
+ M.is_boundary_query = lambda q: False
394
+ globals()["is_boundary_query"] = lambda q: False
395
+ n = _run_groups()
396
+ print("\n== 红基线判定:应有失败 ==")
397
+ print("红基线失败数 = %d(>0 才算断言有判别力)" % n)
398
+ for f in _FAIL:
399
+ print(" 红:", f)
400
+ return 0 if n else 1
401
+ n = _run_groups()
402
+ print("\n拒绝域双语义判据守卫:%d 通过,%d 失败,%d 跳过"
403
+ % (len(_PASS), n, len(_SKIP)))
404
+ return 0 if not n else 1
405
+ finally:
406
+ shutil.rmtree(_TMP, ignore_errors=True)
407
+
408
+
409
+ if __name__ == "__main__":
410
+ sys.exit(main())