@furongjun1999/dsh-memory 0.7.0 → 0.7.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 (65) hide show
  1. package/README.md +7 -6
  2. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +223 -0
  3. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.1.md +340 -0
  4. package/docs/eval/issue50_/345/205/203/346/225/260/346/215/256/351/200/217/344/274/240/344/270/216/345/205/234/345/272/225_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +437 -0
  5. package/docs/eval/issue50_/345/215/212/351/207/215/345/244/215/345/276/205/345/256/232/345/244/215/346/240/270_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +488 -0
  6. package/docs/eval/issue50_/345/276/205/345/256/232/345/244/215/346/240/270/345/205/245/351/230/237_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +428 -0
  7. package/docs/eval/issue50_/350/257/273/351/235/242/344/277/235/346/212/244/345/217/252/350/256/244/346/230/276/345/274/217/346/235/245/346/272/220_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +271 -0
  8. package/docs/eval/issue50_/351/207/215/350/246/201/345/272/246/345/220/214/346/272/220/344/270/216/344/277/235/346/212/244/350/257/255/344/271/211_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +306 -0
  9. package/docs/eval/issue51_/344/270/200/351/224/256/345/256/211/350/243/205/345/244/261/350/264/245_/345/275/222/345/261/236/345/210/244/345/256/232_v1.0.md +58 -0
  10. package/docs/eval/issue52_/346/235/241/344/273/266/345/205/210/350/241/214/344/270/216/346/210/252/346/226/255/345/217/257/350/247/202/346/265/213_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +360 -0
  11. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/241/346/241/243/344/275/215/345/215/225/344/270/200/345/205/245/345/217/243_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +651 -0
  12. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/242/345/217/230/346/233/264/345/215/225/344/270/216/345/233/236/346/273/232/345/216/237/350/257/255_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +439 -0
  13. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/243/345/207/206/345/205/245/350/257/273/346/225/260_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1337 -0
  14. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/244/346/224/266/345/256/230/344/270/216/345/205/250/351/223/276/351/252/214/346/224/266_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1817 -0
  15. package/docs/eval//345/217/221/345/270/20309_/346/243/200/347/264/242/351/235/242/344/270/211/346/211/271/346/224/266/345/217/243_v1.0.md +66 -0
  16. package/docs/eval//347/254/2543/345/261/202stg/347/273/223/346/236/204/347/264/242/345/274/225_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +250 -0
  17. 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 +25 -25
  18. package/docs/plans/stg/346/235/241/344/273/266/345/214/226/344/270/216/347/273/223/346/236/204/347/264/242/345/274/225_/350/256/276/350/256/241_v0.1.md +124 -0
  19. package/docs/plans//350/256/260/345/277/206/350/207/252/345/244/204/347/220/206/344/270/211/346/241/243/350/207/252/346/262/273_/350/256/276/350/256/241_v0.2.md +207 -0
  20. package/md_cg/admission.py +718 -0
  21. package/md_cg/autonomy_modes.py +642 -0
  22. package/md_cg/consistency.py +133 -8
  23. package/md_cg/forgetting.py +38 -6
  24. package/md_cg/freshness.py +17 -1
  25. package/md_cg/lifecycle.py +30 -2
  26. package/md_cg/mcp_server.py +63 -7
  27. package/md_cg/mdcg.py +85 -17
  28. package/md_cg/mdcos.py +666 -16
  29. package/md_cg/protect.py +79 -4
  30. package/md_cg/provenance.py +1 -1
  31. package/md_cg/review_cli.py +31 -2
  32. package/md_cg/rollback.py +411 -0
  33. package/md_cg/rollback_cli.py +104 -0
  34. package/md_cg/stg.py +400 -50
  35. package/md_cg/stgidx.py +281 -0
  36. package/md_cg/test_autonomy_admission.py +1098 -0
  37. package/md_cg/test_autonomy_modes.py +1972 -0
  38. package/md_cg/test_b1_auto_id_multiproc.py +7 -0
  39. package/md_cg/test_b1b2_write_face.py +7 -0
  40. package/md_cg/test_b3_merge_keeps_content.py +7 -0
  41. package/md_cg/test_cons200_scan_selection.py +791 -0
  42. package/md_cg/test_i50a_half_dup_defer.py +408 -0
  43. package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
  44. package/md_cg/test_i50c_meta_passthrough.py +608 -0
  45. package/md_cg/test_i50d_importance_source.py +531 -0
  46. package/md_cg/test_i50e_readside_protection.py +656 -0
  47. package/md_cg/test_issue39_utf8_stdio.py +9 -1
  48. package/md_cg/test_issue52_scan_condition_first.py +677 -0
  49. package/md_cg/test_linkref.py +7 -0
  50. package/md_cg/test_mode_parity.py +1393 -0
  51. package/md_cg/test_mutation_rollback.py +805 -0
  52. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
  53. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
  54. package/md_cg/test_p2_mcp.py +8 -0
  55. package/md_cg/test_p9_forget_protect.py +7 -0
  56. package/md_cg/test_p9c_dedup_hints.py +7 -0
  57. package/md_cg/test_policy_required_ccg.py +5 -1
  58. package/md_cg/test_protocol.py +7 -0
  59. package/md_cg/test_stgidx_index_parity.py +1020 -0
  60. package/md_cg/test_writelimit.py +36 -9
  61. package/md_cg/test_writepipe.py +5 -2
  62. package/md_cg/weights.py +15 -1
  63. package/md_cg/writepipe.py +101 -2
  64. package/package.json +1 -1
  65. package/skills/plugin.json +1 -1
@@ -0,0 +1,531 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · issue50-d 守卫:落盘=裁决值 + 保护位只认显式声明 + 文案分叉
3
+
4
+ 缺陷(编排侧改前基线实测,非推断)
5
+ ----------------------------------
6
+ 遗忘闸门 `forgetting.assess` 的重要度一问存在一对同源缺陷:
7
+ ① 审计面文案声称未发生的事——启发式路径(不传 hint)裁决走
8
+ 「imp≥0.70 → ACCEPT,reason=『重要度 0.84≥0.7(触发不可遗忘保护)』」,
9
+ 但落盘面 importance=0.5(默认)且无保护位(隔离沙箱直读 frontmatter
10
+ 实证:u_heur_new 裁决 0.837/落盘 0.5;t_heur 裁决 0.711/落盘 0.5)。
11
+ ② 落盘重要度是常数——启发式路径全落 0.5、插件通道(hint=0.6 透传)全落
12
+ 0.6 ⇒ 落盘重要度不携带任何区分信息;门禁算出的 0.84/0.71/0.52 在落盘
13
+ 时全部丢弃。
14
+
15
+ 本批修复(使用者裁定 2026-10-02,三件套一次收口)
16
+ --------------------------------------------------
17
+ 1. 落盘=裁决值:remember_gated ACCEPT 分支无条件落 `imp["score"]`(mdcos),
18
+ 并落 `importance_source`("hint"|"heuristic")进 fm 与索引条目(_node_entry
19
+ 与 add 的 _stage 两处同口径,免读文件可判)。
20
+ 2. 保护位只认显式声明:add 的自动保护(importance≥0.7 → protected 位)加
21
+ source 闸——`importance_source != "heuristic"` 才打位;启发式过线只落分、
22
+ 不打位。add 缺省 None ⇒ 全部既有调用方(约 40 处生产点,逐一面核对均
23
+ 不传该键)保护行为逐位不变,零回归由构造保证。
24
+ 3. 文案分叉:assess 保护分支 reason 按 `imp["from"]` 分叉——hint 维持
25
+ 「触发不可遗忘保护」;heuristic 改「启发式 …(未落保护——保护须显式声明)」。
26
+
27
+ 改前/改后对拍表(E 组;同一组用例两棵树各跑一遍的实测读数,2026-10-02)
28
+ ------------------------------------------------------------------------
29
+ 本表是**实施期一次性实测的字面量**,不是 git HEAD 引用(基线绑提交即失效,
30
+ 本仓已有两次教训)——改前树用临时探针跑出,改后由本守卫的 E 组断言钉住。
31
+
32
+ | 用例 | verdict | 裁决imp | fm.importance | importance_source | protected | reason |
33
+ |----------------------------|--------------|----------------|--------------------|-------------------|-----------|-------------------------------------------|
34
+ | A role=tool-output 无hint | ACCEPT→不变 | 0.775/heuristic→不变 | 0.5→0.775【变】| 无→heuristic【新增】| 无→无不变 | 「重要度…触发不可遗忘保护」→「启发式…未落保护」【变】|
35
+ | A2 role=None 无hint | ACCEPT→不变 | 0.88/heuristic→不变 | 0.5→0.88【变】 | 无→heuristic【新增】| 无→无不变 | 同上【变】 |
36
+ | B hint=0.9 | ACCEPT→不变 | 0.9/hint→不变 | 0.9→0.9【不变】| 无→hint【新增】 | True→True不变 | 「触发不可遗忘保护」不变 |
37
+ | C hint=0.6 | ACCEPT→不变 | 0.6/hint→不变 | 0.6→0.6【不变】| 无→hint【新增】 | 无→无不变 | 「重要度 0.60≥0.3」不变 |
38
+ (source 列改前四行恒无——键不存在;「变」以 fm.importance/protected/reason 三列为判。)
39
+
40
+ F 组(待验证假设的只读验证,只取事实、不改检索面、不许诺改善)
41
+ --------------------------------------------------------------
42
+ 假设:重要度→排序→下游使用是一条功能链。事实结论(代码读数 + 隔离库实测):
43
+ · 代码读数:`MdCG._score` 的主分**不消费** importance(词法 sim + tag_bonus
44
+ ± 语义 max / 池乘数 / freshness 乘子 / S4 层加成);importance 的消费面在
45
+ **排序的次级键**,共三处——`cut_by_relevance`(候选截断+排序,键=(-score,
46
+ -importance,-created_at,nid))、S3 扩散合并排序、`_emit` 终排(键=(-score,
47
+ -importance,id))。即:**排序消费落盘 importance,但只在相关度平分时作
48
+ 破缺**。改前落盘恒 0.5/0.6 ⇒ 平分破缺键全库常数、重要度轴死;改后启发式
49
+ 真分落盘 ⇒ 该轴带电。下游另有一个消费面:`protect.is_protected` 的读时
50
+ 「重要性保护」(importance≥0.7 → 覆写/遗忘闸拦),启发式节点落 0.7x 分后
51
+ 会被它按分拦截——这是「只落分」的直接后果,protect.py 本批未改。
52
+ · 实测读数:隔离库同内容两节点(importance 0.3 vs 0.9)→ 主分同(1.0),
53
+ 两种写入序下 0.9 者都排前(F2/F3)——排序消费落盘 importance 成立。
54
+
55
+ 定点变异自证(--mutate)
56
+ ------------------------
57
+ 每一处判据都配一个定点变异把它打红,且**恰好**命中预期红项数
58
+ (_SRC_MUTATIONS 的 expected 列;实测计数不符即判 FAIL——照
59
+ test_i50a_half_dup_defer.py / test_neg_condition_hits.py 同口径)。锚点漂移
60
+ (实现改了却没同步本表)→ 报 ANCHOR-MISS 并 **exit 2**(fail-closed);默认
61
+ 模式同样先做锚点自检。**不以 git HEAD 为基线源**——变异一律作用在当前盘的
62
+ 实现上(inspect.getsource + 字面替换 + exec,不落盘不改源文件)。
63
+
64
+ 运行:python -X utf8 -m md_cg.test_i50d_importance_source
65
+ python -X utf8 -m md_cg.test_i50d_importance_source --mutate
66
+ """
67
+ from __future__ import annotations
68
+
69
+ import contextlib
70
+ import inspect
71
+ import io
72
+ import os
73
+ import shutil
74
+ import subprocess
75
+ import sys
76
+ import tempfile
77
+ import textwrap
78
+
79
+ # 三档自治批次②(2026-10-02)**夹具隔离**:本守卫考的不是档位面(档位守卫 =
80
+ # md_cg/test_autonomy_modes.py),故显式置 full 档——回到改动前「动作直落」的
81
+ # 行为,使本文件的断言意图(合并/覆写/软删真的发生)逐条不变;env 键名从唯一
82
+ # 真源表取(本文件不构成第二处字面量)。
83
+ from . import autonomy_modes as _autonomy_modes # noqa: E402
84
+ os.environ[_autonomy_modes.AUTONOMY_ENV_KEYS["mode"]] = "full"
85
+
86
+ from . import forgetting, mdcg, mdcos, nodefile
87
+ from .mdcg import MdCG
88
+ from .mdcos import MdCGOS
89
+
90
+ _PASS = []
91
+ _FAIL = []
92
+
93
+
94
+ def ok(cond, msg, extra=""):
95
+ (_PASS if cond else _FAIL).append(msg)
96
+ print((" PASS " if cond else " FAIL ") + msg
97
+ + ((" ← " + str(extra)) if (extra and not cond) else ""))
98
+
99
+
100
+ # ---------------------------------------------------------------- 夹具
101
+ # 正文长度 >200 字 ⇒ lf=1:role=tool-output(internal_deterministic,权 0.25)
102
+ # 的启发式 = 0.5·1 + 0.3·0.25 + 0.2·1 = 0.775 —— 恰好过保护线(≥0.7)且落在
103
+ # 「0.7x 量级」;role=None(unknown,权 0.6)= 0.88。两路都走保护分支,
104
+ # 文案分叉与「只落分不打位」都在这条线上验。
105
+ BODY = ("写入闸门重要度轴守卫正文。" + "这是一段足够长的全新合成正文," * 12
106
+ + "结尾标记 i50dGUARD。")
107
+
108
+
109
+ def _doc(title, body):
110
+ return ("# 功能名:%s\n# 生效条件:任意情境\n# 子功能:验收\n"
111
+ "# 执行:直接调用\n# 验证方式:test\n# 不适用条件:无\n%s\n"
112
+ % (title, body))
113
+
114
+
115
+ def _run_case(nid, **kw):
116
+ """隔离临时根上真跑 remember_gated,返回 (返回体, fm, 盘面fm, 索引条目)。
117
+
118
+ 三层读数缺一不可:返回体(裁决面)/ 索引条目(免读判面)/ 盘面直读
119
+ (单一真相源——防索引遮蔽)。临时根 finally 删除,绝不触在役数据根。
120
+ """
121
+ root = tempfile.mkdtemp(prefix="i50d_")
122
+ try:
123
+ cg = MdCGOS(root)
124
+ out = cg.remember_gated(nid, _doc("守卫节点", BODY),
125
+ layer="contextual", **kw)
126
+ gate = out.get("gate") or {}
127
+ fm = (cg.get(nid) or {}).get("frontmatter") or {}
128
+ entry = cg.index["nodes"].get(nid) or {}
129
+ path = os.path.join(root, entry.get("path") or "")
130
+ disk = None
131
+ if path and os.path.isfile(path):
132
+ with open(path, encoding="utf-8") as f:
133
+ disk, _c = nodefile.loads(f.read())
134
+ return {"out": out, "gate": gate, "fm": fm, "disk": disk or {},
135
+ "entry": entry}
136
+ finally:
137
+ try:
138
+ cg.close()
139
+ except Exception:
140
+ pass
141
+ shutil.rmtree(root, ignore_errors=True)
142
+
143
+
144
+ # ---------------------------------------------------------------- F0 夹具自检
145
+ def g_f0():
146
+ print("== F0 夹具自检(用例确实落在保护分支上)==")
147
+ r = _run_case("f0_a", role="tool-output")
148
+ sc = (r["gate"].get("importance") or {}).get("score")
149
+ ok(r["gate"].get("importance", {}).get("from") == "heuristic"
150
+ and 0.7 <= (sc or 0) < 0.8,
151
+ "F0-1 A 用例裁决 from=heuristic 且 0.7x 量级(%.4f,确实走保护分支)"
152
+ % (sc or -1), r["gate"])
153
+ r2 = _run_case("f0_b", importance_hint=0.9)
154
+ ok((r2["gate"].get("importance") or {}).get("from") == "hint"
155
+ and (r2["gate"].get("importance") or {}).get("score") == 0.9,
156
+ "F0-2 B 用例裁决 from=hint 且 score=0.9", r2["gate"])
157
+
158
+
159
+ # ---------------------------------------------------------------- A 启发式面
160
+ def g_a():
161
+ print("== A 启发式全新:落盘=裁决值、source=heuristic、无保护位、文案如实 ==")
162
+ r = _run_case("a_heur", role="tool-output")
163
+ sc = (r["gate"].get("importance") or {}).get("score")
164
+ ok(r["out"].get("verdict") == "ACCEPT",
165
+ "A1 全新启发式过保护线 → verdict ACCEPT(裁决面不变)",
166
+ r["out"].get("verdict"))
167
+ ok(r["fm"].get("importance") == sc,
168
+ "A2 落盘 fm.importance == 裁决值 %s(改前恒 0.5)" % sc,
169
+ r["fm"].get("importance"))
170
+ ok(r["entry"].get("importance") == sc,
171
+ "A3 索引条目 importance == 裁决值(免读文件可判)",
172
+ r["entry"].get("importance"))
173
+ ok(r["disk"].get("importance") == sc and r["disk"].get("importance_source") == "heuristic",
174
+ "A4 盘面直读 importance==裁决值 且 source==heuristic(单一真相源)",
175
+ (r["disk"].get("importance"), r["disk"].get("importance_source")))
176
+ ok(r["fm"].get("importance_source") == "heuristic",
177
+ "A5 fm.importance_source == 'heuristic'", r["fm"].get("importance_source"))
178
+ ok(r["entry"].get("importance_source") == "heuristic",
179
+ "A6 索引条目 importance_source == 'heuristic'",
180
+ r["entry"].get("importance_source"))
181
+ ok(r["fm"].get("protected") is None and r["disk"].get("protected") is None,
182
+ "A7 启发式过线不打保护位(fm 与盘面均无 protected)",
183
+ (r["fm"].get("protected"), r["disk"].get("protected")))
184
+ why = r["gate"].get("reason") or ""
185
+ ok("触发不可遗忘保护" not in why,
186
+ "A8 文案不再声称「触发不可遗忘保护」(改前启发式也这么声称)", why)
187
+ ok("启发式" in why and "未落保护" in why and "显式声明" in why,
188
+ "A9 文案如实:启发式/未落保护/保护须显式声明", why)
189
+ # 对照:role=None(unknown 来源)启发式 0.88 量级——同族第二路
190
+ r2 = _run_case("a_heur_none")
191
+ sc2 = (r2["gate"].get("importance") or {}).get("score")
192
+ ok(r2["fm"].get("importance") == sc2
193
+ and r2["fm"].get("importance_source") == "heuristic"
194
+ and r2["fm"].get("protected") is None
195
+ and "未落保护" in (r2["gate"].get("reason") or ""),
196
+ "A10 对照 role=None:0.5→裁决值 %s、source=heuristic、无保护、文案如实"
197
+ % sc2, (r2["fm"].get("importance"), r2["fm"].get("importance_source"),
198
+ r2["fm"].get("protected"), r2["gate"].get("reason")))
199
+
200
+
201
+ # ---------------------------------------------------------------- B 显式面
202
+ def g_b():
203
+ print("== B 显式 hint=0.9:保护位与文案零回归 ==")
204
+ r = _run_case("b_hint09", importance_hint=0.9)
205
+ ok(r["fm"].get("importance") == 0.9,
206
+ "B1 落盘 importance == 0.9(显式面零回归)", r["fm"].get("importance"))
207
+ ok(r["fm"].get("importance_source") == "hint",
208
+ "B2 fm.importance_source == 'hint'", r["fm"].get("importance_source"))
209
+ ok(r["fm"].get("protected") is True,
210
+ "B3 显式声明过线 → protected=True(自动保护位保留)",
211
+ r["fm"].get("protected"))
212
+ ok("importance=0.90" in str(r["fm"].get("protection_reason") or ""),
213
+ "B4 protection_reason 逐字维持(importance=0.90≥0.7)",
214
+ r["fm"].get("protection_reason"))
215
+ ok("触发不可遗忘保护" in (r["gate"].get("reason") or ""),
216
+ "B5 保护文案维持(「触发不可遗忘保护」不因分叉而丢失)",
217
+ r["gate"].get("reason"))
218
+ ok(r["disk"].get("protected") is True
219
+ and r["entry"].get("protected") is True,
220
+ "B6 盘面与索引条目同步 protected=True",
221
+ (r["disk"].get("protected"), r["entry"].get("protected")))
222
+
223
+
224
+ # ---------------------------------------------------------------- C 插件缺省面
225
+ def g_c():
226
+ print("== C 插件缺省 hint=0.6:落盘值与无保护状态零回归 ==")
227
+ r = _run_case("c_hint06", importance_hint=0.6)
228
+ ok(r["fm"].get("importance") == 0.6,
229
+ "C1 落盘 importance == 0.6(与改前一致)", r["fm"].get("importance"))
230
+ ok(r["fm"].get("importance_source") == "hint",
231
+ "C2 source == 'hint'(0.6 是插件显式缺省声明)",
232
+ r["fm"].get("importance_source"))
233
+ ok(r["fm"].get("protected") is None,
234
+ "C3 0.6<0.7 无保护位(与改前一致)", r["fm"].get("protected"))
235
+ ok((r["gate"].get("reason") or "") == "重要度 0.60≥0.3",
236
+ "C4 reason 逐字同改前(走⑤重要度分支,不经保护分支)",
237
+ r["gate"].get("reason"))
238
+
239
+
240
+ # ---------------------------------------------------------------- E 对拍锚
241
+ _BEFORE_TABLE = {
242
+ # 改前树实测(2026-10-02 临时探针;docstring 对拍表的字面量形态)
243
+ "A": {"verdict": "ACCEPT", "gate_score": 0.775, "gate_from": "heuristic",
244
+ "fm_importance": 0.5, "fm_protected": None,
245
+ "reason": "重要度 0.78≥0.7(触发不可遗忘保护)"},
246
+ "B": {"fm_importance": 0.9, "fm_protected": True,
247
+ "reason": "重要度 0.90≥0.7(触发不可遗忘保护)"},
248
+ "C": {"fm_importance": 0.6, "fm_protected": None,
249
+ "reason": "重要度 0.60≥0.3"},
250
+ }
251
+
252
+
253
+ def g_e():
254
+ print("== E 改前/改后对拍锚(docstring 表的「不变」列逐列回归)==")
255
+ r = _run_case("e_a", role="tool-output")
256
+ b = _BEFORE_TABLE["A"]
257
+ ok(r["out"].get("verdict") == b["verdict"]
258
+ and (r["gate"].get("importance") or {}).get("score") == b["gate_score"]
259
+ and r["fm"].get("protected") == b["fm_protected"],
260
+ "E1 A 行不变列:verdict/裁决分/protected 与改前逐列相同",
261
+ (r["out"].get("verdict"), (r["gate"].get("importance") or {}).get("score"),
262
+ r["fm"].get("protected")))
263
+ ok(r["fm"].get("importance") != b["fm_importance"],
264
+ "E2 A 行变列:fm.importance 0.5→裁决值(确实变了,不是改前常数)",
265
+ r["fm"].get("importance"))
266
+ r2 = _run_case("e_b", importance_hint=0.9)
267
+ b2 = _BEFORE_TABLE["B"]
268
+ ok(r2["fm"].get("importance") == b2["fm_importance"]
269
+ and r2["fm"].get("protected") == b2["fm_protected"]
270
+ and (r2["gate"].get("reason") or "") == b2["reason"],
271
+ "E3 B 行显式面逐列不变:importance/protected/reason 与改前相同",
272
+ (r2["fm"].get("importance"), r2["fm"].get("protected"),
273
+ r2["gate"].get("reason")))
274
+ r3 = _run_case("e_c", importance_hint=0.6)
275
+ b3 = _BEFORE_TABLE["C"]
276
+ ok(r3["fm"].get("importance") == b3["fm_importance"]
277
+ and r3["fm"].get("protected") == b3["fm_protected"]
278
+ and (r3["gate"].get("reason") or "") == b3["reason"],
279
+ "E4 C 行显式面逐列不变:importance/protected/reason 与改前相同",
280
+ (r3["fm"].get("importance"), r3["fm"].get("protected"),
281
+ r3["gate"].get("reason")))
282
+
283
+
284
+ # ---------------------------------------------------------------- F 假设链(只读)
285
+ def g_f():
286
+ print("== F 假设链只读验证:排序是否消费落盘 importance(只取事实)==")
287
+ # 代码读数①:主分面 _score 不消费 importance
288
+ score_src = inspect.getsource(MdCG._score)
289
+ ok("importance" not in score_src,
290
+ "F1 代码读数:_score 主分不消费 importance(词法+标签±语义/乘子)")
291
+ # 代码读数②:消费面在排序次级键(三处,钉两处代表 + 终排)
292
+ cut_src = inspect.getsource(mdcg.cut_by_relevance)
293
+ ok('frontmatter"].get("importance"' in cut_src,
294
+ "F2 代码读数:cut_by_relevance 的排序键含 importance(次级破缺)")
295
+ emit_src = inspect.getsource(MdCG._emit)
296
+ ok('frontmatter"].get("importance"' in emit_src,
297
+ "F3 代码读数:_emit 终排的排序键含 importance")
298
+ # 实测读数:隔离库同内容 0.3 vs 0.9,两种写入序
299
+ def _rank(low_first):
300
+ root = tempfile.mkdtemp(prefix="i50d_rank_")
301
+ try:
302
+ cg = MdCGOS(root)
303
+ doc = _doc("排序验证", "芙蓉峰下灵枢阁的排序消费面独特词 XQV700。")
304
+ first_imp, second_imp = (0.3, 0.9) if low_first else (0.9, 0.3)
305
+ with contextlib.redirect_stdout(io.StringIO()):
306
+ cg.add("n_first", doc, layer="knowledge",
307
+ importance=first_imp, verification_basis="test")
308
+ cg.add("n_second", doc, layer="knowledge",
309
+ importance=second_imp, verification_basis="test")
310
+ results, stat = cg.search("排序消费面独特词 XQV700", k=5)
311
+ rows = [(r[0]["id"], float(r[1]),
312
+ (r[0]["frontmatter"] or {}).get("importance"))
313
+ for r in results]
314
+ return {"rows": rows, "tier": stat.get("tier"),
315
+ "cut": stat.get("cut_order")}
316
+ finally:
317
+ try:
318
+ cg.close()
319
+ except Exception:
320
+ pass
321
+ shutil.rmtree(root, ignore_errors=True)
322
+
323
+ a = _rank(True)
324
+ b = _rank(False)
325
+ ok(len(a["rows"]) == 2 and a["rows"][0][1] == a["rows"][1][1],
326
+ "F4 实测:同内容两节点主分相等(%.4f==%.4f,tier=%s cut=%s)"
327
+ % (a["rows"][0][1], a["rows"][1][1], a["tier"], a["cut"]), a["rows"])
328
+ ok(a["rows"][0][2] == 0.9 and b["rows"][0][2] == 0.9,
329
+ "F5 实测:平分时 0.9 者排前(两种写入序一致——排除 id/写入序效应)",
330
+ (a["rows"], b["rows"]))
331
+ print(" (事实结论:排序消费落盘 importance,作同分破缺次级键;"
332
+ "只取事实,不改检索面、不许诺改善。)")
333
+
334
+
335
+ # ---------------------------------------------------------------- D 既有守卫
336
+ def g_d():
337
+ print("== D 既有守卫不改断言跑通(子进程实跑,退出码判据)==")
338
+ env = dict(os.environ)
339
+ env["PYTHONUTF8"] = "1"
340
+ proc = subprocess.run(
341
+ [sys.executable, "-X", "utf8", "-m", "md_cg.test_p9_forget_protect"],
342
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
343
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
344
+ env=env, timeout=300)
345
+ ok(proc.returncode == 0,
346
+ "D1 test_p9_forget_protect 不改断言跑通(importance_hint≥0.7→ACCEPT"
347
+ " 等既有用例 rc=0)", proc.stdout[-400:] + proc.stderr[-200:])
348
+
349
+
350
+ _GROUPS = (g_f0, g_a, g_b, g_c, g_e, g_f, g_d)
351
+
352
+
353
+ def _run_groups():
354
+ """跑全部断言组(静默),返回失败数——供变异自证复用。"""
355
+ _PASS.clear()
356
+ _FAIL.clear()
357
+ for g in _GROUPS:
358
+ g()
359
+ return len(_FAIL)
360
+
361
+
362
+ # ---------------------------------------------------------------- 定点变异自证
363
+ # 表内每项 = (说明, 目标, 锚点原文, 替换文, 预期红项数)。锚点必须**逐字**出现
364
+ # 在当前实现的对应函数源码里;漂移即 ANCHOR-MISS(fail-closed,exit 2)。
365
+ # 目标 "remember_gated" → patch MdCGOS.remember_gated(类方法,exec 于
366
+ # mdcos 模块 globals 副本);"add" → patch MdCG.add;"assess" → 换
367
+ # forgetting.assess(i50a 同款)。
368
+ _SRC_MUTATIONS = (
369
+ # 契约 G①:去掉 source 落盘 → A 组 source 断言红。红 8:A4/A5/A6/A10
370
+ # (source 面)+ A7/E1(source 缺席 ⇒ add 的保护闸看不见 heuristic ⇒
371
+ # 启发式 0.775 也被打保护位——恰是缺陷形态的联动复现)+ B2/C2(显式面
372
+ # 的 source 键同样消失)。
373
+ ("source 落盘整条删除(m1)", "remember_gated",
374
+ 'kw["importance_source"] = verdict["importance"]["from"]',
375
+ 'pass',
376
+ 8),
377
+ # 契约 G 之外的本批核心判据增强:落盘改回「hint 非 None 才透传」的缺陷
378
+ # 形态 → 启发式 A 用例落盘回 0.5。红 5:A2/A3/A4/A10(importance 面)
379
+ # + E2(A 行变列锚——「确实变了」被改回常数即红)。
380
+ ("落盘改回 hint-only 透传(m2,缺陷形态)", "remember_gated",
381
+ 'kw["importance"] = verdict["importance"]["score"]',
382
+ 'kw["importance"] = (0.5 if hint is None else hint)',
383
+ 5),
384
+ # 契约 G②:启发式也打保护(删掉 source 闸)→ A 组无保护位断言红。
385
+ # 红 3:A7 + A10 + E1(改前对拍锚的 protected 不变列)。
386
+ ("启发式也打保护位(m3)", "add",
387
+ ' and importance_source != "heuristic"):',
388
+ ' ):',
389
+ 3),
390
+ # 契约 G③:文案不分叉(heuristic 分支换回旧声称)→ A 组文案断言红。
391
+ # 红 3:A8 + A9 + A10(对照行文案半边)。
392
+ ("文案不分叉(m4)", "assess",
393
+ '(未落保护——保护须显式声明)',
394
+ '(触发不可遗忘保护)',
395
+ 3),
396
+ )
397
+
398
+ # 静态锚点(默认模式也自检,fail-closed):
399
+ # · 改前缺陷形态**不得**回归出现在对应函数的可执行源码里
400
+ # · 本批三个新判据的锚点必须在位
401
+ _ANCHORS_BANNED = (
402
+ ("remember_gated", 'if hint is not None and "importance" not in kw'),
403
+ )
404
+ _ANCHORS_REQUIRED = (
405
+ ("remember_gated", 'kw["importance"] = verdict["importance"]["score"]'),
406
+ ("remember_gated", 'kw["importance_source"] = verdict["importance"]["from"]'),
407
+ ("add", 'and importance_source != "heuristic"'),
408
+ ("assess", '未落保护——保护须显式声明'),
409
+ )
410
+
411
+
412
+ def _fn_src(target):
413
+ if target == "assess":
414
+ return inspect.getsource(forgetting.assess)
415
+ if target == "remember_gated":
416
+ return inspect.getsource(MdCGOS.remember_gated)
417
+ if target == "add":
418
+ return inspect.getsource(MdCG.add)
419
+ raise KeyError(target)
420
+
421
+
422
+ def _anchor_check():
423
+ """返回 ANCHOR-MISS 说明列表(空 = 全部在位)。"""
424
+ bad = []
425
+ for _name, target, old, _new, _n in _SRC_MUTATIONS:
426
+ if old not in _fn_src(target):
427
+ bad.append("变异锚点缺失:%r @%s" % (old, target))
428
+ for target, s in _ANCHORS_BANNED:
429
+ if s in _fn_src(target):
430
+ bad.append("缺陷形态回归:%s 可执行源码里仍有 %r" % (target, s))
431
+ for target, s in _ANCHORS_REQUIRED:
432
+ if s not in _fn_src(target):
433
+ bad.append("新判据锚点缺失:%s 找不到 %r" % (target, s))
434
+ return bad
435
+
436
+
437
+ def _patched(target, old, new):
438
+ """把目标函数按字面替换变异后返回「安装/还原」对(不落盘不改源文件)。"""
439
+ src = _fn_src(target)
440
+ mutated = src.replace(old, new)
441
+ if target == "assess":
442
+ ns = dict(vars(forgetting))
443
+ exec(compile(mutated, "i50d_mut.py", "exec"), ns)
444
+ fn = ns["assess"]
445
+ return ("forgetting.assess", fn)
446
+ if target == "remember_gated":
447
+ ns = dict(vars(mdcos))
448
+ exec(compile(textwrap.dedent(mutated), "i50d_mut.py", "exec"), ns)
449
+ return ("MdCGOS.remember_gated", ns["remember_gated"])
450
+ ns = dict(vars(mdcg))
451
+ exec(compile(textwrap.dedent(mutated), "i50d_mut.py", "exec"), ns)
452
+ return ("MdCG.add", ns["add"])
453
+
454
+
455
+ @contextlib.contextmanager
456
+ def _patched_ctx(target, old, new):
457
+ attr, fn = _patched(target, old, new)
458
+ if attr == "forgetting.assess":
459
+ live = forgetting.assess
460
+ forgetting.assess = fn
461
+ try:
462
+ yield
463
+ finally:
464
+ forgetting.assess = live
465
+ elif attr == "MdCGOS.remember_gated":
466
+ live = MdCGOS.remember_gated
467
+ MdCGOS.remember_gated = fn
468
+ try:
469
+ yield
470
+ finally:
471
+ MdCGOS.remember_gated = live
472
+ else:
473
+ live = MdCG.add
474
+ MdCG.add = fn
475
+ try:
476
+ yield
477
+ finally:
478
+ MdCG.add = live
479
+
480
+
481
+ def _mutate_mode():
482
+ bad = []
483
+ anchor_bad = _anchor_check()
484
+ if anchor_bad:
485
+ for b in anchor_bad:
486
+ print(" ANCHOR-MISS " + b)
487
+ print("\n锚点自检:FAIL(fail-closed,exit 2)")
488
+ return 2
489
+ with contextlib.redirect_stdout(io.StringIO()):
490
+ clean = _run_groups()
491
+ print(" 未变异基线:红项=%d%s"
492
+ % (clean, "" if clean == 0 else " ← 基线即红,变异核验无意义"))
493
+ if clean:
494
+ bad.append("未变异基线即失败")
495
+ for name, target, old, new, expect in _SRC_MUTATIONS:
496
+ with _patched_ctx(target, old, new), \
497
+ contextlib.redirect_stdout(io.StringIO()):
498
+ fails = _run_groups()
499
+ verdict = ("命中预期" if fails == expect
500
+ else "**红项数不符(预期 %d)**" % expect)
501
+ print(" 变异「%s」→ 红项=%d %s" % (name, fails, verdict))
502
+ if fails != expect:
503
+ bad.append(name)
504
+ print("\n定点变异自证:%s"
505
+ % ("PASS(每处判据都有变异钉死,且红项数逐处吻合)" if not bad
506
+ else "FAIL —— " + "、".join(bad)))
507
+ return 0 if not bad else 1
508
+
509
+
510
+ def main() -> int:
511
+ src = os.path.basename(os.path.abspath(__file__))
512
+ if "--mutate" in sys.argv:
513
+ print("!! 定点变异模式:逐个变异判据,套件应转红且红项数吻合\n")
514
+ return _mutate_mode()
515
+ anchor_bad = _anchor_check()
516
+ if anchor_bad:
517
+ for b in anchor_bad:
518
+ print(" ANCHOR-MISS " + b)
519
+ print("\n锚点自检:FAIL(fail-closed,exit 2)——实现改了请同步变异表/文案")
520
+ return 2
521
+ print("锚点自检:PASS(%s;不以 git HEAD 为基线源)" % src)
522
+ for g in _GROUPS:
523
+ g()
524
+ print("\nissue50-d 守卫:%d 通过,%d 失败" % (len(_PASS), len(_FAIL)))
525
+ if _FAIL:
526
+ print("失败项:" + "、".join(_FAIL))
527
+ return 0 if not _FAIL else 1
528
+
529
+
530
+ if __name__ == "__main__":
531
+ sys.exit(main())