@furongjun1999/dsh-memory 0.7.5 → 0.8.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 (23) hide show
  1. package/README.md +15 -14
  2. package/docs/eval/issue64_replay_check_/346/240/270/351/252/214/344/270/216/344/277/256/345/244/215_v1.0.md +284 -0
  3. package/docs/eval//345/217/221/345/270/20313_/344/270/212/344/270/213/346/226/207/350/207/252/347/256/241/347/220/206/346/234/272/345/210/266_v1.0.md +2 -2
  4. package/docs/eval//345/217/221/345/270/20314_/350/272/253/344/275/223/303/227/350/204/221/347/273/204/345/220/210_v1.0.md +152 -0
  5. package/docs/eval//351/262/270/345/250/230/344/270/203/347/261/273/347/212/266/346/200/201/350/277/275/350/270/252_v1.0.md +176 -0
  6. package/docs/mdcg//344/270/226/347/225/214/346/250/241/345/236/213/345/212/237/350/203/275/347/253/257_/344/275/277/347/224/250/344/270/216/350/277/220/347/273/264_v1.0.md +92 -13
  7. 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 +43 -43
  8. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +14 -3
  9. 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 +9 -4
  10. package/docs/plans//347/201/265/346/236/242/350/272/253/344/275/223/303/227/350/204/221_/344/270/226/347/225/214/346/250/241/345/236/213/345/257/271/346/216/245/350/256/276/350/256/241_v0.1.md +45 -2
  11. package/md_cg/chain.py +23 -7
  12. package/md_cg/consolidate.py +81 -10
  13. package/md_cg/lifecycle.py +7 -0
  14. package/md_cg/mcp_server.py +22 -0
  15. package/md_cg/mdcg.py +6 -1
  16. package/md_cg/scrub.py +4 -2
  17. package/md_cg/sleep.py +60 -10
  18. package/md_cg/test_lifecycle_retire_leak.py +90 -0
  19. package/md_cg/test_p6_consolidate.py +389 -7
  20. package/md_cg/test_sleep_gitlock.py +134 -0
  21. package/md_cg/test_spatial_coords3d.py +292 -0
  22. package/package.json +2 -2
  23. package/skills/plugin.json +1 -1
@@ -35,8 +35,11 @@ import shutil
35
35
  import tempfile
36
36
 
37
37
  from . import lifecycle, stg
38
+ from . import chain as _chain
39
+ from . import scrub as _scrub
38
40
  from . import mcp_server
39
41
  from . import tasks as _tasks
42
+ from .mdcg import MdCG
40
43
  from .mdcos import MdCGOS
41
44
 
42
45
  _root = tempfile.mkdtemp(prefix="mdcg_retire_")
@@ -135,6 +138,18 @@ def _idx(nid):
135
138
  return (cg.index.get("nodes") or {}).get(nid) or {}
136
139
 
137
140
 
141
+ def _subgraph_children():
142
+ """结构面正查表(`subgraph.children_index`:parent_id → [child_id...])。"""
143
+ from . import subgraph as _sg
144
+ return _sg.children_index(cg)
145
+
146
+
147
+ def _subgraph_parents():
148
+ """结构面反查表(`subgraph.parents_index`:child_id → [parent_id...])。"""
149
+ from . import subgraph as _sg
150
+ return _sg.parents_index(cg)
151
+
152
+
138
153
  # ------------------------------------------------------------ R1 复现序列
139
154
  n1 = cg.add("rl_v1", _body("旧事实 v1", "甲地(初始登记)"), layer="knowledge",
140
155
  verification_basis="test", importance=0.4)
@@ -400,6 +415,81 @@ check("R4.6 op=session(action=recall)全链:archived 要点/未解问题
400
415
  _sess_call_ids.isdisjoint({_note1, _unr1}) and _note2 in _sess_call_ids,
401
416
  sorted(_sess_call_ids))
402
417
 
418
+ # ------------------------------------------- R5 基类 MdCG.search(2026-10-06 接线)
419
+ # 边界四条第 1 面(运维文档 §九.5 登记的已知未覆盖面):plain `MdCG` 实例
420
+ # (白箱 KB / 离线脚本 / 测试)的默认检索面此前未接判据——同序列下 MdCG 命中
421
+ # `['a_v1','a_v2']` 而 MdCGOS 只命中 `['a_v1']` 的取证。现按「默认读面必须消费
422
+ # 判据」接线;本腿:在役对照(converged 降权轴仍参与)→ archived 剔除。
423
+ def _base_ids():
424
+ r = MdCG(_root).search(Q, k=50, record=False)
425
+ res = r[0] if isinstance(r, tuple) else r
426
+ return {str(n.get("id")) for n, *_ in (res or [])}
427
+
428
+
429
+ _b5 = _base_ids()
430
+ check("R5.1 基类面:archived(rl_v2)不出现、v1 出现",
431
+ n2 not in _b5 and n1 in _b5, sorted(_b5))
432
+ check("R5.2 基类面不误剔:converged(rl_converged)仍参与(降权轴)",
433
+ n3 in _b5, sorted(_b5))
434
+
435
+ # ------------------------------------------- R6 因果链面(2026-10-06 接线)
436
+ # 边界四条第 2 面:`chain.adjacency` 无 lifecycle 判据 ⇒ `cg(op=causal,
437
+ # action=chain)` 扩散进 archived(取证:链里出现归档 b_tgt)。接线后缺省
438
+ # `skip_archived=True` 剔除退役节点出边与指向退役目标的边;维护面
439
+ # (scrub 去污染抽查)显式 `skip_archived=False` 保留全量(面的判据分工)。
440
+ # 另钉一条本批实证的缓存缺口:归档发生在链**已建**之后(缓存已热)也必须
441
+ # 生效——`lifecycle.set_state` 现在补了 `chain.invalidate_cache` 单点。
442
+ _c6_seed = cg.add("rl_c6_seed", _body("因果链种子", "辛地"), layer="knowledge",
443
+ verification_basis="test", importance=0.4,
444
+ edges=[{"target": "rl_c6_tgt", "relation_type": "causal",
445
+ "confidence": 0.9}])
446
+ _c6_tgt = cg.add("rl_c6_tgt", _body("因果链目标(待退役)", "壬地"),
447
+ layer="knowledge", verification_basis="test", importance=0.4)
448
+
449
+
450
+ def _c6_has(rep):
451
+ return any("rl_c6_tgt" in (c.get("nodes") or []) for c in (rep or []))
452
+
453
+
454
+ check("R6.0 前提:因果链在役时确实扩散到目标(非空断言,防本腿空转)",
455
+ _c6_has(cg.causal_chain("rl_c6_seed")), cg.causal_chain("rl_c6_seed"))
456
+ cg.set_state(_c6_tgt, "demoted", reason="守卫:链面降权", actor="test")
457
+ cg.set_state(_c6_tgt, "archived", reason="守卫:链面归档", actor="test")
458
+ check("R6.1 目标退役后因果链不扩散进它(缓存已热时亦生效——set_state 同点失效)",
459
+ not _c6_has(cg.causal_chain("rl_c6_seed")), cg.causal_chain("rl_c6_seed"))
460
+ check("R6.2 维护面旁路:adjacency(skip_archived=False) 仍含退役边(scrub 全量口径)",
461
+ any(t == "rl_c6_tgt" for t, _e in
462
+ (_chain.adjacency(cg, skip_archived=False).get("rl_c6_seed") or [])))
463
+ check("R6.3 scrub 面经旁路取数同证(维护写面不受退役过滤约束)",
464
+ any(t == "rl_c6_tgt" for t, _e in
465
+ (_scrub._adjacency(cg).get("rl_c6_seed") or [])))
466
+ cg.set_state(_c6_tgt, "active", reason="守卫:链面恢复", actor="test")
467
+ check("R6.4 恢复后因果链重新扩散(退役可逆,同面)",
468
+ _c6_has(cg.causal_chain("rl_c6_seed")), cg.causal_chain("rl_c6_seed"))
469
+ cg.set_state(_c6_tgt, "demoted", reason="守卫:复测降权", actor="test")
470
+ cg.set_state(_c6_tgt, "archived", reason="守卫:复测归档", actor="test")
471
+
472
+ # --------------------------------- R7 结构面(裁定:不接——定向面直读,钉住裁定)
473
+ # 边界四条第 3 面经使用者裁定(2026-10-06)**保持直读**:调用方给显式父/子
474
+ # id、返回 id 映射不回正文,属定向/结构面。本腿把裁定钉住——若未来有人
475
+ # "顺手接线",R7.1 转红即提醒该行为已被裁定(改行为须先改裁定)。
476
+ _c7_parent = cg.add("rl_c7_parent", _body("结构面父节点", "癸地"),
477
+ layer="knowledge", verification_basis="test", importance=0.4,
478
+ subgraph={"nodes": ["rl_c7_child"]})
479
+ _c7_child = cg.add("rl_c7_child", _body("结构面子节点(待退役)", "子地"),
480
+ layer="knowledge", verification_basis="test", importance=0.4)
481
+ check("R7.0 前提:children_index 含该父→子映射(非空断言,防本腿空转)",
482
+ "rl_c7_child" in (_subgraph_children().get("rl_c7_parent") or []),
483
+ _subgraph_children())
484
+ cg.set_state(_c7_child, "demoted", reason="守卫:结构面降权", actor="test")
485
+ cg.set_state(_c7_child, "archived", reason="守卫:结构面归档", actor="test")
486
+ check("R7.1 子节点退役后 children_index 映射仍在(裁定:结构面直读不接判据)",
487
+ "rl_c7_child" in (_subgraph_children().get("rl_c7_parent") or []),
488
+ _subgraph_children())
489
+ check("R7.2 parents_index 反向表同钉(裁定文本两侧对称——仅给一侧接线也会被本腿抓住)",
490
+ "rl_c7_parent" in (_subgraph_parents().get("rl_c7_child") or []),
491
+ _subgraph_parents())
492
+
403
493
  print("=" * 58)
404
494
  print("test_lifecycle_retire_leak: %d 通过 / %d 失败" % (_ok, len(_fail)))
405
495
  if _fail:
@@ -1,7 +1,8 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """md_cg · P6 验收:离线固化(反思单元 → 白箱闸门 → 验证单元 → 固化)
3
3
 
4
- 运行:python -m md_cg.test_p6_consolidate
4
+ 运行:python -X utf8 -m md_cg.test_p6_consolidate # 正常跑(含 R 组)
5
+ python -X utf8 -m md_cg.test_p6_consolidate --mutation-baseline # R 组定点变异自证
5
6
 
6
7
  验证口径(对齐工作纪律第 3/5 条「不猜测、未经验证不固化」):
7
8
  1. 候选解析:JSON 容错 + 字段别名(子内容→子功能)
@@ -14,22 +15,31 @@
14
15
  8. 幂等 + 不覆盖人工既有字段
15
16
  9. 验证单元不可用 → DEFER(不写盘);--no-verify 降级可跑通
16
17
  10. 验证方式补全(零 LLM)+ 与生产 _path_semantic 的一致性回归
18
+ 11. (R 组)issue #64 修复守卫:replay_check 撤销「负条件一票否决」、consolidate
19
+ 主流程「负条件两态拦截」(neg_absent / neg_dropped_all)、CLI 两角色
20
+ max-tokens/timeout 单设——附定点变异自证(`--mutation-baseline`)。
17
21
  """
18
22
  from __future__ import annotations
19
23
 
24
+ import contextlib
25
+ import inspect
26
+ import io
20
27
  import json
21
28
  import os
22
29
  import shutil
30
+ import subprocess
23
31
  import sys
24
32
  import tempfile
25
33
 
34
+ from . import consolidate as _consolidate_mod
26
35
  from . import nodefile
27
- from .consolidate import (BASIS_TEMPLATE, REFLECT_ROLE, VERIFY_ROLE, body_text,
28
- consolidate, existing_fields, fill_verification_basis,
29
- grounding_filter, grounding_score, narrow_by_verdict,
30
- parse_candidate, parse_verdict, replay_check,
31
- role_config)
32
- from .mdcos import MdCGOS
36
+ from .consolidate import (BASIS_TEMPLATE, REFLECT_ROLE, VERIFY_ROLE, _cli,
37
+ body_text, consolidate, existing_fields,
38
+ fill_verification_basis, grounding_filter,
39
+ grounding_score, narrow_by_verdict, parse_candidate,
40
+ parse_verdict, replay_check, role_config)
41
+ from .mdcg import expand_query_terms_weighted
42
+ from .mdcos import MdCGOS, _neg_hit, _weighted_coverage
33
43
 
34
44
  PASS = FAIL = 0
35
45
  FAILS = []
@@ -101,7 +111,375 @@ def new_root(n=1):
101
111
  return root, rel
102
112
 
103
113
 
114
+ # ==========================================================================
115
+ # R 组:issue #64 修复守卫(2026-10-06)
116
+ # ==========================================================================
117
+ #
118
+ # 缺陷(外部报告 angel12538 对 v0.7.5 实测;编排侧隔离库独立复现):
119
+ # A 类① `consolidate.replay_check` 的 pos_recall 带 `and not _neg_hit(tw_pos, neg_terms)`
120
+ # ——「生效条件任一词整词出现在不适用条件里」一票判负。而负条件描述的是
121
+ # **邻近易混情境**,与生效条件共享领域主题词是结构必然(不共享主题词就
122
+ # 谈不上「邻近」)⇒ 合格负条件被大量误杀。自否定职责已由 no_conflict
123
+ # (覆盖率口径,<0.5 才算互相覆盖)承担——两者同意图而前者更严。
124
+ # B 类 `consolidate` 主流程把 grounding 删光后的空 neg 交给 replay_check,命中其
125
+ # `else: neg_separated = True` 短路 ⇒「负条件被删光/未产出」被翻译成「通过」
126
+ # ⇒ 缺「不适用条件」要素的节点落盘并携带 verification_basis 声明。
127
+ #
128
+ # 定点变异自证(`python -X utf8 -m md_cg.test_p6_consolidate --mutation-baseline`,
129
+ # 与 test_neg_condition_hits 同口径):每一处修复判据都必须有变异把它打红,且
130
+ # **红项数恰好等于** _MUTATIONS 登记的预期数(多红说明断言串扰、少红说明该判据
131
+ # 无判别力——两种都判 FAIL)。变异锚点为**本文件内联的源码文本**,不读 git HEAD
132
+ # (基线绑提交即失效,本仓已有两次教训)。锚点漂移(实现改了而本表未同步)→
133
+ # 报 ANCHOR-MISS,退出码 2(fail-closed)——守卫静默失效比守卫失败更危险。
134
+ # R6 走 subprocess 跑**盘上模块**的 `--help`,内存态变异打不到它,故不登记变异腿;
135
+ # 其判别力由「四个参数名逐一断言」承担(删任一参数即红)。
136
+
137
+ # R1/R2 样本(A 类①):读数经编排侧探针独立标定——
138
+ # ② 负条件对正文覆盖率 = 0.402、③ 生效条件对负条件文本覆盖率 = 0.37(均 < 0.5,
139
+ # 未触发),① 一票否决为唯一杀手;撤销前 replay_check 判 {pos_recall: False, ok: False}。
140
+ R_POS = ["海边甜品店 招牌蛋糕 做法"]
141
+ R_NEG = ["海边甜品店 的其它蛋糕 冷藏流程"]
142
+ R_BODY = "海边甜品店招牌蛋糕做法:奶油打发后低温烘焙,成品当日冷藏保存。"
143
+
144
+ # R2 自否定样本:生效条件与不适用条件整条互相覆盖 → no_conflict 必须拦住
145
+ R_POS2 = ["海边甜品店 招牌蛋糕"]
146
+ R_NEG2 = ["海边甜品店 招牌蛋糕"]
147
+
148
+ # R3/R4 打桩候选:负条件域外(与正文零 bigram 支撑 → grounding 全删)
149
+ R_CAND_DROP = {
150
+ "生效条件": ["微分方程", "数值方法"],
151
+ "子功能": ["建模", "求解"],
152
+ "执行": "使用数值方法迭代求解",
153
+ "不适用条件": ["量子色动力学格点规范场论"],
154
+ }
155
+ R_CAND_ABSENT = {k: v for k, v in R_CAND_DROP.items() if k != "不适用条件"}
156
+
157
+
158
+ def _legacy_pos_recall(pos_terms, neg_terms, body):
159
+ """A 类① 撤销**前**的 pos_recall 原文(issue #64 前)——仅用于「样本确实踩中
160
+ 旧缺陷」的红基线自证,不参与生产:生产判据单点在 consolidate.replay_check。"""
161
+ pos_text = " ".join(pos_terms or [])
162
+ tw_pos = expand_query_terms_weighted(pos_text) if pos_text else {}
163
+ return bool(pos_text) and _weighted_coverage(tw_pos, body) > 0.0 \
164
+ and not _neg_hit(tw_pos, neg_terms)
165
+
166
+
167
+ def _const_reflect(cand):
168
+ """打桩反思单元:恒返回同一候选(隔离「负条件两态」这一变量)。"""
169
+ return lambda prompt: json.dumps(cand, ensure_ascii=False)
170
+
171
+
172
+ class _HttpSpy:
173
+ """打桩 http_llm:记录两角色**实收**的 (max_tokens, timeout) 并返回可解析输出。
174
+
175
+ 用于 R6 的透传行为面:CLI 参数是否真被喂到 reflect/verify 两侧(而不只是
176
+ 出现在 --help 文本里)。
177
+ """
178
+
179
+ def __init__(self, cand):
180
+ self.calls = []
181
+ self.cand = cand
182
+
183
+ def __call__(self, prompt, model=None, base=None, key=None, role=None,
184
+ timeout=120, max_tokens=None):
185
+ self.calls.append({"role": role, "max_tokens": max_tokens,
186
+ "timeout": timeout})
187
+ if role == VERIFY_ROLE:
188
+ return "{}"
189
+ return json.dumps(self.cand, ensure_ascii=False)
190
+
191
+
192
+ def _swap_http_llm(fake):
193
+ """把 http_llm 换成打桩替身,返回还原函数。
194
+
195
+ 同时覆盖两个全局面:模块面,以及**当前 _cli**(可能是内存变异版)的
196
+ __globals__——变异版的命名空间是 exec 快照,只 patch 模块属性它看不到,
197
+ 会漏到真网络调用。
198
+ """
199
+ holders = [vars(_consolidate_mod)]
200
+ g = getattr(globals().get("_cli"), "__globals__", None)
201
+ if isinstance(g, dict) and g is not vars(_consolidate_mod):
202
+ holders.append(g)
203
+ saved = [(h, h.get("http_llm")) for h in holders]
204
+ for h in holders:
205
+ h["http_llm"] = fake
206
+
207
+ def restore():
208
+ for h, old in saved:
209
+ h["http_llm"] = old
210
+ return restore
211
+
212
+
213
+ def _r_run(verbose=True):
214
+ """跑 R1-R6,返回失败项名单。verbose=False 时静默(变异自证复用,不污染全局计数)。"""
215
+ fails = []
216
+
217
+ def ck(name, cond, detail=""):
218
+ if not cond:
219
+ fails.append(name)
220
+ if verbose:
221
+ check(name, cond, detail)
222
+
223
+ def sec(title):
224
+ if verbose:
225
+ print(title)
226
+
227
+ # ---- R1 撤销负条件一票否决(A 类①复现样本)----
228
+ sec("\n== R1 撤销负条件一票否决(A 类①复现样本)==")
229
+ r1 = replay_check(R_POS, R_NEG, R_BODY)
230
+ ck("R1a 复现样本 pos_recall=True(恢复一票否决因子即转 False)",
231
+ r1.get("pos_recall") is True, str(r1))
232
+ ck("R1b 复现样本 ok=True(②=0.402 / ③=0.37 均未触发,①原为唯一杀手)",
233
+ r1.get("ok") is True, str(r1))
234
+ ck("R1c 红基线可复现:同一输入在撤销前的 pos_recall 原文下判负(样本有效性)",
235
+ _legacy_pos_recall(R_POS, R_NEG, R_BODY) is False)
236
+
237
+ # ---- R2 自否定职责由 no_conflict 承担(③兜底不退化)----
238
+ sec("\n== R2 自否定由 no_conflict 承担(撤销①不得放宽自否定)==")
239
+ r2 = replay_check(R_POS2, R_NEG2, R_BODY)
240
+ ck("R2a 生效条件整条覆盖不适用条件 → no_conflict=False",
241
+ r2.get("no_conflict") is False, str(r2))
242
+ ck("R2b 自否定样本 ok=False(撤销①不等于放过自否定)",
243
+ r2.get("ok") is False, str(r2))
244
+ ck("R2c 自否定样本 pos_recall 仍成立(召回不再被负条件一票否决,由③兜底)",
245
+ r2.get("pos_recall") is True, str(r2))
246
+
247
+ # ---- R3 态1:负条件被 grounding 删光 → 全链 REJECT ----
248
+ sec("\n== R3 态1(neg_dropped_all):负条件被 grounding 删光 → 全链 REJECT ==")
249
+ root, rel = new_root(1)
250
+ try:
251
+ p = os.path.join(root, rel["n1"])
252
+ before = open(p, encoding="utf-8").read()
253
+ rep = consolidate(root, apply=True, reflect_fn=_const_reflect(R_CAND_DROP),
254
+ verify_fn=lambda prompt: "{}", require_verify=True)
255
+ fm, content = nodefile.loads(open(p, encoding="utf-8").read())
256
+ ck("R3a 删光态 → rejected=1(修复前 accepted=1 直接落盘)",
257
+ rep["rejected"] == 1, str(rep["reasons"]))
258
+ ck("R3b reasons 记 neg_dropped_all=1",
259
+ rep["reasons"].get("neg_dropped_all") == 1, str(rep["reasons"]))
260
+ ck("R3c 不写盘(written=0 且盘上内容逐字未变)",
261
+ rep["written"] == 0 and open(p, encoding="utf-8").read() == before,
262
+ "written=%s" % rep["written"])
263
+ ck("R3d 盘上节点无 llm_consolidation 键(未携带 grounding/replay 声明落盘)",
264
+ "llm_consolidation" not in fm, str(sorted(fm)))
265
+ smp = (rep["samples"] or [{}])[0]
266
+ ck("R3e samples 带 reason=neg_dropped_all 且 replay=None(可观测面)",
267
+ smp.get("reason") == "neg_dropped_all" and smp.get("replay") is None,
268
+ str(smp))
269
+ finally:
270
+ shutil.rmtree(root, ignore_errors=True)
271
+
272
+ # ---- R4 态2:候选未产出负条件 → 全链 REJECT ----
273
+ sec("\n== R4 态2(neg_absent):候选无「不适用条件」键 → 全链 REJECT ==")
274
+ root, rel = new_root(1)
275
+ try:
276
+ p = os.path.join(root, rel["n1"])
277
+ before = open(p, encoding="utf-8").read()
278
+ rep = consolidate(root, apply=True, reflect_fn=_const_reflect(R_CAND_ABSENT),
279
+ verify_fn=lambda prompt: "{}", require_verify=True)
280
+ fm, content = nodefile.loads(open(p, encoding="utf-8").read())
281
+ ck("R4a 未产出态 → rejected=1(修复前 accepted=1 直接落盘)",
282
+ rep["rejected"] == 1, str(rep["reasons"]))
283
+ ck("R4b reasons 记 neg_absent=1",
284
+ rep["reasons"].get("neg_absent") == 1, str(rep["reasons"]))
285
+ ck("R4c 不写盘(written=0 且盘上内容逐字未变)",
286
+ rep["written"] == 0 and open(p, encoding="utf-8").read() == before,
287
+ "written=%s" % rep["written"])
288
+ ck("R4d 盘上节点无 llm_consolidation 键",
289
+ "llm_consolidation" not in fm, str(sorted(fm)))
290
+ smp = (rep["samples"] or [{}])[0]
291
+ ck("R4e samples 带 reason=neg_absent 且 replay=None(可观测面)",
292
+ smp.get("reason") == "neg_absent" and smp.get("replay") is None, str(smp))
293
+ finally:
294
+ shutil.rmtree(root, ignore_errors=True)
295
+
296
+ # ---- R5 不误伤:合法候选(含域外负条件)照常 ACCEPT ----
297
+ sec("\n== R5 不误伤:合法候选全链照常 ACCEPT ==")
298
+ root, rel = new_root(1)
299
+ try:
300
+ p = os.path.join(root, rel["n1"])
301
+ rep = consolidate(root, apply=True, reflect_fn=fake_reflect,
302
+ verify_fn=fake_verify, reflect_model=REFLECT_MODEL,
303
+ verify_model=VERIFY_MODEL, verification_basis=BASIS)
304
+ fm, content = nodefile.loads(open(p, encoding="utf-8").read())
305
+ ck("R5a 合法候选 → accepted=1 / written=1",
306
+ rep["accepted"] == 1 and rep["written"] == 1, str(rep["reasons"]))
307
+ ck("R5b 合法候选 replay.ok=True(两态拦截不误伤合格负条件)",
308
+ ((fm.get("llm_consolidation") or {}).get("replay") or {}).get("ok") is True,
309
+ str((fm.get("llm_consolidation") or {}).get("replay")))
310
+ ck("R5c 负条件照常落盘(正文含「# 不适用条件」行)", "# 不适用条件" in content)
311
+ finally:
312
+ shutil.rmtree(root, ignore_errors=True)
313
+
314
+ # ---- R6 CLI:两角色 max-tokens/timeout 可分别单设 ----
315
+ sec("\n== R6 CLI:--reflect/--verify-max-tokens、--reflect/--verify-timeout ==")
316
+ env = dict(os.environ)
317
+ env["PYTHONUTF8"] = "1"
318
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
319
+ proc = subprocess.run(
320
+ [sys.executable, "-X", "utf8", "-m", "md_cg.consolidate", "--help"],
321
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
322
+ cwd=repo_root, env=env)
323
+ flat = (proc.stdout or "") + (proc.stderr or "")
324
+ ck("R6a `python -m md_cg.consolidate --help` 退出码 0",
325
+ proc.returncode == 0, "rc=%s :: %s" % (proc.returncode, flat[:120]))
326
+ for flag in ("--reflect-max-tokens", "--verify-max-tokens",
327
+ "--reflect-timeout", "--verify-timeout"):
328
+ ck("R6 帮助含参数 %s" % flag, flag in flat)
329
+
330
+ # 透传行为面(打桩 http_llm 记录两角色实收参数):单设优先、缺省回落共用值
331
+ spy = _HttpSpy(CAND)
332
+ restore_http = _swap_http_llm(spy)
333
+ saved_keys = {k: os.environ.get(k)
334
+ for k in ("MDCG_REFLECT_KEY", "MDCG_VERIFY_KEY")}
335
+ root, rel = new_root(1)
336
+ try:
337
+ os.environ["MDCG_REFLECT_KEY"] = "k-reflect"
338
+ os.environ["MDCG_VERIFY_KEY"] = "k-verify"
339
+ with contextlib.redirect_stdout(io.StringIO()):
340
+ _cli(["--root", root, "--max-tokens", "5000", "--timeout", "7",
341
+ "--reflect-max-tokens", "7000", "--reflect-timeout", "3",
342
+ "--verify-max-tokens", "6000", "--verify-timeout", "9"])
343
+ by_role = {c["role"]: c for c in spy.calls}
344
+ ck("R6b 反思单元实收单设预算/超时(--reflect-max-tokens=7000 / --reflect-timeout=3)",
345
+ by_role.get(REFLECT_ROLE, {}).get("max_tokens") == 7000
346
+ and by_role.get(REFLECT_ROLE, {}).get("timeout") == 3.0, str(spy.calls))
347
+ ck("R6c 验证单元实收单设预算/超时(--verify-max-tokens=6000 / --verify-timeout=9)",
348
+ by_role.get(VERIFY_ROLE, {}).get("max_tokens") == 6000
349
+ and by_role.get(VERIFY_ROLE, {}).get("timeout") == 9.0, str(spy.calls))
350
+ spy.calls.clear()
351
+ with contextlib.redirect_stdout(io.StringIO()):
352
+ _cli(["--root", root, "--max-tokens", "5000", "--timeout", "7"])
353
+ fell = {c["role"]: c for c in spy.calls}
354
+ ck("R6d 角色级参数缺省回落共用缺省(两角色都实收 5000 / 7.0)",
355
+ fell.get(REFLECT_ROLE, {}).get("max_tokens") == 5000
356
+ and fell.get(REFLECT_ROLE, {}).get("timeout") == 7.0
357
+ and fell.get(VERIFY_ROLE, {}).get("max_tokens") == 5000
358
+ and fell.get(VERIFY_ROLE, {}).get("timeout") == 7.0, str(spy.calls))
359
+ finally:
360
+ restore_http()
361
+ for k, v in saved_keys.items():
362
+ if v is None:
363
+ os.environ.pop(k, None)
364
+ else:
365
+ os.environ[k] = v
366
+ shutil.rmtree(root, ignore_errors=True)
367
+
368
+ return fails
369
+
370
+
371
+ # 定点变异表:逐处把本轮修复的判据改坏/改回,R 组必须转红且**红项数恰好等于**
372
+ # 登记值(多红 = 断言串扰、少红 = 判据无判别力,两者都判 FAIL)。锚点 = 源码文本
373
+ # (内联基线,不读 git HEAD);锚点缺失/不唯一 → ANCHOR-MISS → 退出码 2。
374
+ # 预期红项数 = 实跑登记(2026-10-06 首跑,R 组子项名):
375
+ # ① → R1a、R1b、R2c(3 项:撤销判据的直接面)
376
+ # ②a → R4b、R4e(2 项:neg_absent 专属计数与可观测面)
377
+ # ②b → R3a、R3b、R3c、R3d、R3e(5 项:neg_dropped_all 全链不落盘)
378
+ # ②ab(两态全删)→ R3 五项 + R4 五项(10 项:两态各自失守)
379
+ # ③a → R6b(1 项:反思侧透传面)
380
+ # ③b → R6c(1 项:验证侧透传面)
381
+ # 登记值是**锁**:实测多红(断言串扰)或少红(判据无判别力)都判 FAIL。
382
+ _MUTATIONS = (
383
+ ("① 恢复负条件一票否决(replay_check.pos_recall)", "replay_check", (
384
+ ("pos_recall = bool(pos_text) and _weighted_coverage(tw_pos, body) > 0.0",
385
+ "pos_recall = bool(pos_text) and _weighted_coverage(tw_pos, body) > 0.0 \\\n"
386
+ " and not _neg_hit(tw_pos, neg_terms)"),
387
+ ), 3),
388
+ ("②a neg_absent 两态拦截失效(consolidate)", "consolidate", (
389
+ ("if not neg_cand:", "if False:"),
390
+ ), 2),
391
+ ("②b neg_dropped_all 两态拦截失效(consolidate)", "consolidate", (
392
+ ("if not neg:", "if False:"),
393
+ ), 5),
394
+ ("②ab 两态拦截整体删除(consolidate)", "consolidate", (
395
+ ("if not neg_cand:", "if False:"),
396
+ ("if not neg:", "if False:"),
397
+ ), 10),
398
+ ("③a 反思单元未透传单设预算/超时(_cli 回落共用值)", "_cli", (
399
+ ("max_tokens=refl_mt, timeout=refl_to",
400
+ "max_tokens=a.max_tokens, timeout=a.timeout"),
401
+ ), 1),
402
+ ("③b 验证单元未透传单设预算/超时(_cli 回落共用值)", "_cli", (
403
+ ("max_tokens=ver_mt, timeout=ver_to",
404
+ "max_tokens=a.max_tokens, timeout=a.timeout"),
405
+ ), 1),
406
+ )
407
+
408
+
409
+ def _anchor_report():
410
+ """检查所有变异锚点在实现源码中是否**唯一**存在;返回问题描述清单(空 = 全在)。
411
+
412
+ 锚点漂移(实现改了而本表未同步)意味着变异腿空转 ⇒ 守卫判别力静默失效,
413
+ 故调用方以退出码 2 fail-closed。
414
+ """
415
+ bad = []
416
+ for name, fn_name, pairs, _expect in _MUTATIONS:
417
+ src = inspect.getsource(getattr(_consolidate_mod, fn_name))
418
+ for old, _new in pairs:
419
+ n = src.count(old)
420
+ if n != 1:
421
+ bad.append("%s :: 锚点出现 %d 次(应为 1):: %r"
422
+ % (name, n, old[:60]))
423
+ return bad
424
+
425
+
426
+ def _mutate(fn_name, pairs):
427
+ """把 fn_name 的源码按 pairs 替换后 exec 出新函数(只在内存里,不落任何文件)。"""
428
+ mod = _consolidate_mod
429
+ src = inspect.getsource(getattr(mod, fn_name))
430
+ for old, new in pairs:
431
+ src = src.replace(old, new, 1)
432
+ ns = dict(vars(mod))
433
+ exec(compile(src, "<mut_%s>" % fn_name, "exec"), ns)
434
+ return ns[fn_name]
435
+
436
+
437
+ def _mutation_baseline() -> int:
438
+ """定点变异自证:逐处变异本轮修复的判据,R 组必须转红且红项数恰等于登记值。"""
439
+ print("!! 定点变异自证:逐处变异本轮修复的判据,R 组应当转红"
440
+ "(红项数须恰好等于登记值)\n")
441
+ with contextlib.redirect_stdout(io.StringIO()):
442
+ base = _r_run(verbose=False)
443
+ print(" 未变异基线:红项=%d%s" % (len(base), ("(须为 0)" + "、".join(base)) if base else "(须为 0)"))
444
+ bad = []
445
+ if base:
446
+ bad.append("未变异基线即红")
447
+ for name, fn_name, pairs, expect in _MUTATIONS:
448
+ old_live = globals()[fn_name]
449
+ globals()[fn_name] = _mutate(fn_name, pairs)
450
+ try:
451
+ with contextlib.redirect_stdout(io.StringIO()):
452
+ fails = _r_run(verbose=False)
453
+ finally:
454
+ globals()[fn_name] = old_live
455
+ if len(fails) == expect:
456
+ verdict = "一致(有判别力)"
457
+ elif not fails:
458
+ verdict = "**仍全绿 = 该判据无判别力**"
459
+ else:
460
+ verdict = "**红项数不符(实测 %d ≠ 登记 %d)**" % (len(fails), expect)
461
+ print(" 变异「%s」→ 红项=%d(登记 %d)%s:%s"
462
+ % (name, len(fails), expect, verdict, "、".join(fails)))
463
+ if len(fails) != expect:
464
+ bad.append(name)
465
+ print("\n定点变异自证:%s"
466
+ % ("PASS(每处判据都有变异把它打红)" if not bad else "FAIL —— " + "、".join(bad)))
467
+ return 0 if not bad else 1
468
+
469
+
104
470
  def main():
471
+ # ANCHOR-MISS 预检(fail-closed):变异锚点是 R 组判别力的底座——实现漂移而
472
+ # 基线表未同步时守卫会静默失效;故先检锚点,漂移即以退出码 2 失败,不静默。
473
+ miss = _anchor_report()
474
+ if miss:
475
+ print("ANCHOR-MISS:守卫变异锚点在实现源码中缺失或不唯一"
476
+ "(实现已漂移,本守卫基线表未同步)——fail-closed,退出码 2:")
477
+ for m in miss:
478
+ print(" · " + m)
479
+ return 2
480
+ if "--mutation-baseline" in sys.argv:
481
+ return _mutation_baseline()
482
+
105
483
  # ---------------- 1. 候选解析 ----------------
106
484
  print("\n【1】候选解析(JSON 容错 + 字段别名)")
107
485
  c = parse_candidate("```json\n" + CAND_JSON + "\n```")
@@ -428,6 +806,10 @@ def main():
428
806
  "message": {"content": "ok"}}]},
429
807
  "m", 999) == "ok")
430
808
 
809
+ # ---------------- R. issue #64 修复守卫 ----------------
810
+ print("\n【R】issue #64 修复守卫(replay_check 撤销一票否决 / 负条件两态拦截 / CLI 参数)")
811
+ _r_run(verbose=True)
812
+
431
813
  print("\n" + "=" * 68)
432
814
  print(f"通过 {PASS} / 失败 {FAIL}")
433
815
  if FAILS: