@furongjun1999/dsh-memory 0.7.5 → 0.8.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 (27) hide show
  1. package/README.md +43 -15
  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/issue65_session_recall_/344/274/232/350/257/235/351/232/224/347/246/273_/346/240/270/351/252/214/344/270/216/344/277/256/345/244/215_v1.0.md +329 -0
  4. 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
  5. 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 +160 -0
  6. package/docs/eval//345/217/221/345/270/20315_/344/274/232/350/257/235/351/232/224/347/246/273/344/277/256/345/244/215/344/270/216/350/272/253/344/275/223/345/272/223/345/261/225/347/244/272_v1.0.md +88 -0
  7. 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
  8. 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
  9. 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
  10. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +14 -3
  11. 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
  12. 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
  13. package/md_cg/chain.py +23 -7
  14. package/md_cg/consolidate.py +81 -10
  15. package/md_cg/lifecycle.py +7 -0
  16. package/md_cg/mcp_server.py +22 -0
  17. package/md_cg/mdcg.py +6 -1
  18. package/md_cg/mdcos.py +24 -1
  19. package/md_cg/scrub.py +4 -2
  20. package/md_cg/sleep.py +60 -10
  21. package/md_cg/test_lifecycle_retire_leak.py +90 -0
  22. package/md_cg/test_p45_session_identity.py +250 -3
  23. package/md_cg/test_p6_consolidate.py +389 -7
  24. package/md_cg/test_sleep_gitlock.py +134 -0
  25. package/md_cg/test_spatial_coords3d.py +292 -0
  26. package/package.json +2 -2
  27. package/skills/plugin.json +1 -1
@@ -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:
@@ -0,0 +1,134 @@
1
+ # -*- coding: utf-8 -*-
2
+ """test_sleep_gitlock · 影子仓 git 残留锁**有界自清**守卫(2026-10-06 落地 issue #63 待裁项)。
3
+
4
+ 契约(`md_cg/sleep.py::_clear_git_lock` 三闸口径):
5
+ ① 范围=仅睡眠影子仓的 `index.lock`(本模块单属仓);
6
+ ② `force=True` 仅在本模块刚 kill 掉自己超时的 git 后调用(此刻仓内锁只可能
7
+ 是被 kill 进程的残留);非 force(`_git` 调用前置巡检)只在锁 mtime 距今
8
+ ≥ `_GIT_LOCK_STALE_S`(300s,≫ 全部调用的 120s 超时上界)时清理(崩溃残留);
9
+ ③ 其余一律不动(返回 "left",人工核删指引保留);清理动作记一行
10
+ `git_lock_cleared` 台账。
11
+
12
+ 断言(S1–S8):
13
+ S1 无锁 → "absent";S2 **新锁未超龄 → "left" 且文件完好(防一刀切)**;
14
+ S3 超龄锁 → "cleared" 且文件删除;S4 force 对新锁 → 立清(超时 kill 时机);
15
+ S5 台账两行 `git_lock_cleared` 可读、含 age_s/force;
16
+ S6 `_git` 前置巡检清超龄锁(走公共出口);S7 前置巡检不误删新锁;
17
+ S8 超时路径:`subprocess.run` 抛 `TimeoutExpired`(monkeypatch)→ 抛
18
+ `SleepGitTimeout`、消息带「已自动清理」读数、先前新锁已被 force 清。
19
+
20
+ 隔离:`MDCG_STATE_ROOT`/`MDCG_ROOT`/`MDCG_AUX_ROOT` 全指临时目录(台账亦落
21
+ 临时根,绝不触在役状态面);monkeypatch 在 finally 里还原。
22
+ 运行:python -X utf8 -m md_cg.test_sleep_gitlock
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import shutil
28
+ import subprocess
29
+ import tempfile
30
+ import time
31
+
32
+ from . import sleep as S
33
+
34
+ _saved = {}
35
+ _ok = 0
36
+ _fail = []
37
+
38
+
39
+ def _sandbox():
40
+ for k in ("MDCG_STATE_ROOT", "MDCG_ROOT", "MDCG_AUX_ROOT"):
41
+ _saved[k] = os.environ.get(k)
42
+ gen = tempfile.mkdtemp(prefix="mdcg_gitlock_")
43
+ os.environ["MDCG_STATE_ROOT"] = os.path.join(gen, "state")
44
+ os.environ["MDCG_ROOT"] = os.path.join(gen, "root")
45
+ os.environ["MDCG_AUX_ROOT"] = os.path.join(gen, "aux")
46
+ return gen
47
+
48
+
49
+ def _restore():
50
+ for k, v in _saved.items():
51
+ if v is None:
52
+ os.environ.pop(k, None)
53
+ else:
54
+ os.environ[k] = v
55
+
56
+
57
+ def check(name, cond, detail=""):
58
+ global _ok
59
+ if cond:
60
+ _ok += 1
61
+ print("[ok] " + name)
62
+ else:
63
+ _fail.append(name)
64
+ print("[FAIL] %s · %s" % (name, str(detail)[:240]))
65
+
66
+
67
+ def _put_lock(d, age_s=0.0):
68
+ p = os.path.join(d, S._GIT_LOCK_NAME)
69
+ with open(p, "w", encoding="utf-8") as f:
70
+ f.write("")
71
+ if age_s:
72
+ t = time.time() - age_s
73
+ os.utime(p, (t, t))
74
+ return p
75
+
76
+
77
+ gen = _sandbox()
78
+ gdir = os.path.join(gen, "gitdir")
79
+ os.makedirs(gdir)
80
+ try:
81
+ check("S1 无锁 → absent", S._clear_git_lock(gdir) == "absent")
82
+
83
+ p2 = _put_lock(gdir)
84
+ check("S2 新锁未超龄 → left 且保持在场(防一刀切)",
85
+ S._clear_git_lock(gdir) == "left" and os.path.exists(p2))
86
+
87
+ p3 = _put_lock(gdir, age_s=S._GIT_LOCK_STALE_S + 60)
88
+ check("S3 超龄锁 → cleared 且文件删除",
89
+ S._clear_git_lock(gdir) == "cleared" and not os.path.exists(p3))
90
+
91
+ p4 = _put_lock(gdir)
92
+ check("S4 force 对新锁立清(超时 kill 后时机)",
93
+ S._clear_git_lock(gdir, force=True) == "cleared"
94
+ and not os.path.exists(p4))
95
+
96
+ rows = [r for r in S.read_ledger() if r.get("event") == "git_lock_cleared"]
97
+ check("S5 台账两行 git_lock_cleared(age_s/force 在场)",
98
+ len(rows) == 2 and all("age_s" in r and "force" in r for r in rows),
99
+ rows)
100
+
101
+ p6 = _put_lock(gdir, age_s=S._GIT_LOCK_STALE_S + 60)
102
+ S._git(gdir, gen, "rev-parse", "--git-dir") # rc 不论;重点=前置巡检
103
+ check("S6 _git 前置巡检清超龄锁(公共出口生效)", not os.path.exists(p6))
104
+
105
+ p7 = _put_lock(gdir)
106
+ S._git(gdir, gen, "rev-parse", "--git-dir")
107
+ check("S7 _git 前置巡检不误删新锁(年龄闸)", os.path.exists(p7))
108
+
109
+ real_run = subprocess.run
110
+
111
+ def _boom(*a, **kw):
112
+ raise subprocess.TimeoutExpired(cmd="git boom",
113
+ timeout=S._GIT_TIMEOUT_S)
114
+
115
+ subprocess.run = _boom
116
+ msg = ""
117
+ try:
118
+ S._git(gdir, gen, "status")
119
+ except S.SleepGitTimeout as e:
120
+ msg = str(e)
121
+ finally:
122
+ subprocess.run = real_run
123
+ check("S8 超时→SleepGitTimeout;消息带处置读数且新锁被 force 清",
124
+ "残留锁处置" in msg and "已自动清理" in msg and not os.path.exists(p7),
125
+ msg)
126
+ finally:
127
+ _restore()
128
+ shutil.rmtree(gen, ignore_errors=True)
129
+
130
+ print("=" * 58)
131
+ print("test_sleep_gitlock: %d 通过 / %d 失败" % (_ok, len(_fail)))
132
+ if _fail:
133
+ print("失败项:" + "、".join(_fail))
134
+ raise SystemExit(1 if _fail else 0)