@furongjun1999/dsh-memory 0.6.1 → 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 (105) hide show
  1. package/README.md +49 -18
  2. package/docs/README.md +1 -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.0.md +223 -0
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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 +32 -2
  23. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +88 -0
  24. 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
  25. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +47 -11
  26. 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
  27. 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
  28. 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
  29. 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
  30. package/md_cg/admission.py +718 -0
  31. package/md_cg/autonomy_modes.py +642 -0
  32. package/md_cg/bench_e2e_locomo_qa.py +11 -4
  33. package/md_cg/chain.py +47 -0
  34. package/md_cg/consistency.py +133 -8
  35. package/md_cg/forgetting.py +38 -6
  36. package/md_cg/freshness.py +527 -0
  37. package/md_cg/generation.py +409 -0
  38. package/md_cg/hotcache.py +4 -1
  39. package/md_cg/lifecycle.py +30 -2
  40. package/md_cg/mcp_server.py +191 -26
  41. package/md_cg/mdcg.py +594 -39
  42. package/md_cg/mdcos.py +869 -38
  43. package/md_cg/nodefile.py +74 -1
  44. package/md_cg/protect.py +79 -4
  45. package/md_cg/provenance.py +1 -1
  46. package/md_cg/review_cli.py +31 -2
  47. package/md_cg/rollback.py +411 -0
  48. package/md_cg/rollback_cli.py +104 -0
  49. package/md_cg/semantic/canonical.py +22 -0
  50. package/md_cg/semantic/unify.py +73 -22
  51. package/md_cg/semantic/unify_fixture.json +25 -0
  52. package/md_cg/sleep.py +1297 -0
  53. package/md_cg/stg.py +400 -50
  54. package/md_cg/stgidx.py +281 -0
  55. package/md_cg/sustain.py +146 -9
  56. package/md_cg/test_auto_defaults.py +424 -0
  57. package/md_cg/test_autonomy_admission.py +1098 -0
  58. package/md_cg/test_autonomy_modes.py +1972 -0
  59. package/md_cg/test_b1_auto_id_multiproc.py +7 -0
  60. package/md_cg/test_b1b2_write_face.py +7 -0
  61. package/md_cg/test_b3_merge_keeps_content.py +7 -0
  62. package/md_cg/test_boundary_hit.py +410 -0
  63. package/md_cg/test_cons200_scan_selection.py +791 -0
  64. package/md_cg/test_en_pipeline.py +22 -13
  65. package/md_cg/test_generation_guard.py +352 -0
  66. package/md_cg/test_h4_sustain_snapshot.py +14 -4
  67. package/md_cg/test_i50a_half_dup_defer.py +408 -0
  68. package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
  69. package/md_cg/test_i50c_meta_passthrough.py +608 -0
  70. package/md_cg/test_i50d_importance_source.py +531 -0
  71. package/md_cg/test_i50e_readside_protection.py +656 -0
  72. package/md_cg/test_issue39_utf8_stdio.py +9 -1
  73. package/md_cg/test_issue52_scan_condition_first.py +677 -0
  74. package/md_cg/test_linkref.py +7 -0
  75. package/md_cg/test_mode_parity.py +1393 -0
  76. package/md_cg/test_mutation_rollback.py +805 -0
  77. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
  78. package/md_cg/test_n212_n213_n224_generation_gates.py +14 -8
  79. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
  80. package/md_cg/test_n230_dirty_replay.py +375 -0
  81. package/md_cg/test_p2_mcp.py +8 -0
  82. package/md_cg/test_p2_six_elements.py +463 -0
  83. package/md_cg/test_p3_legacy_closure.py +433 -0
  84. package/md_cg/test_p4_freshness.py +680 -0
  85. package/md_cg/test_p8_subgraph_chain.py +14 -1
  86. package/md_cg/test_p9_forget_protect.py +7 -0
  87. package/md_cg/test_p9c_dedup_hints.py +7 -0
  88. package/md_cg/test_policy_required_ccg.py +5 -1
  89. package/md_cg/test_protocol.py +7 -0
  90. package/md_cg/test_rank_parity_score_mode.py +6 -0
  91. package/md_cg/test_semantic_canonical.py +5 -3
  92. package/md_cg/test_sleep.py +611 -0
  93. package/md_cg/test_sleep_p1.py +784 -0
  94. package/md_cg/test_stgidx_index_parity.py +1020 -0
  95. package/md_cg/test_time_core_lint.py +968 -0
  96. package/md_cg/test_unify_default_off.py +701 -0
  97. package/md_cg/test_unify_scope.py +182 -0
  98. package/md_cg/test_writelimit.py +36 -9
  99. package/md_cg/test_writepipe.py +5 -2
  100. package/md_cg/weights.py +15 -1
  101. package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
  102. package/md_cg/writepipe.py +101 -2
  103. package/package.json +3 -2
  104. package/skills/plugin.json +1 -1
  105. package/utf8_boot.py +237 -0
@@ -0,0 +1,784 @@
1
+ # -*- coding: utf-8 -*-
2
+ """P1 睡眠周期引擎守卫(九步显式化 + 副本迭代 + git 周期合并 + §4.7 env 表)。
3
+
4
+ 契约(设计稿 `docs/plans/睡眠与自迭代_功能优化设计_v0.4.md` §三 / §四 / §4.7):
5
+
6
+ 【1】九步显式化——`sustain._tick_sleep` 是**第六档 tick**(`_run` 到期链
7
+ 里 `next_sleep` + try/except + `_stop.wait(_POLL)`,形态与既有五档一致);
8
+ 九步逐步留读数;⑤权重 / ⑥索引重建本轮是**显式 no-op 占位**,台账里
9
+ 标 `skipped == "未接线"`(不许悄悄跳过)。
10
+ 【2】四阶段——物化 / 影子迭代 / 对账+四闸 / 语义重放 + `--no-ff` 合并;
11
+ Δ 空 ⇒ 零提交零写入;同批次幂等;冲突项**挂起**且**不进主库**。
12
+ 【3】§4.7 七键 + 缺省关的 `MDCG_SLEEP_SCRUB_APPLY`;缺省**只有一份**
13
+ (`sleep.SLEEP_ENV_DEFAULTS` / `SLEEP_ENV_KEYS`),两入口共用读取器。
14
+ 【4】机械判据(本文件即其守卫):跨午夜窗口三态、Δ 空零提交、幂等、
15
+ 冲突挂起、九步台账在场且 ⑤⑥ 标 skipped、P0-1 判据仍成立。
16
+
17
+ 断言分组:
18
+
19
+ G0 沙箱与真源——四根全落守卫临时目录;§4.7 七键缺省逐项对表;④实改闸缺省关;
20
+ 缺省字面量全仓只有一份(`MDCG_SLEEP*` 带引号字面量只许出现在 sleep.py 的
21
+ SLEEP_ENV_KEYS 里)。
22
+ G1 窗口判定——跨午夜(23:00-07:00)在 23:30 / 03:00 判在窗内、12:00 判窗外;
23
+ 留空 = 全时段恒真;同起止 = 全天;非法形态不误判(回落全时段)。
24
+ G2 九步台账在場——`ledger_steps` 恒九条、顺序 = §3.1 表;⑤⑥ 逐条
25
+ `skipped == "未接线"`;⑨ 只产记录(`auto_apply is False`)。
26
+ G3 第六档 tick——`_run` 原文含 next_sleep 到期链且形态与既有五档一致;
27
+ 真起 `SustainLoop` 跑一次 `_tick_sleep` 后 `last_sleep.steps` 为九步、
28
+ ⑤⑥ 标 skipped,且 `status()` 暴露 sleep_* 字段。
29
+ G4 四阶段端到端——一轮 `run_cycle` 后:影子分支有 1 个提交 + 主库有重放提交
30
+ + `--no-ff` 合并提交;被提升的节点**真的**落在主库新层;两条机械判据
31
+ (历史禁入零命中 / 追踪集合 ⊆ 8 层且 ⊇ 全部 .md)仍全绿。
32
+ G5 Δ 为空 ⇒ 零提交且主库真源面逐字节不变(提交计数前后相同)。
33
+ G6 幂等——同批次 id 再跑一次:`idempotent=True`、零提交、真源面逐字节不变。
34
+ G7 冲突挂起——主库/影子同 id 并发改动 ⇒ 该 id 进 `conflicts`、**未写进主库**、
35
+ 也未进影子分支的树;另一条(未冲突的)正常合并。
36
+ G8 回滚——对合并提交 `revert`:主库真源面回到合并前,历史不丢(只增不减)。
37
+
38
+ 运行:python -X utf8 -m md_cg.test_sleep_p1
39
+ python -X utf8 -m md_cg.test_sleep_p1 --mutate # 定点变异自证
40
+ python -X utf8 -m md_cg.test_sleep_p1 --mutate --list # 只列变异表
41
+
42
+ 退出码(fail-closed):0 = 全绿;1 = 有断言失败 / 变异未按预期转红;
43
+ 2 = ANCHOR-MISS(变异锚点在当前源码里找不到——实现改了却没同步本表)。
44
+
45
+ **基线纪律**:不以 git HEAD 为基线源——「改动前形态」由在当前工作区源码上做
46
+ **定点文本变异**(`_MUTATIONS`)复现,锚点漂移即退出码 2。
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import contextlib
51
+ import io
52
+ import json
53
+ import os
54
+ import shutil
55
+ import subprocess
56
+ import sys
57
+ import tempfile
58
+ import types
59
+
60
+ from . import sleep as SL
61
+ from . import sustain as SUS
62
+
63
+ _PASS = []
64
+ _FAIL = []
65
+ _SKIP = []
66
+ _SRC = {}
67
+ _TMP = tempfile.mkdtemp(prefix="mdcg_sleepp1_")
68
+ _SAVED = {}
69
+ _GEN = [0]
70
+ _REAL_SL = SL
71
+ _REAL_SUS = SUS
72
+ _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
73
+
74
+ #: §4.7 七键(逐字抄设计稿表):短名 → (env 名, 缺省)。
75
+ ENV_TABLE = {
76
+ "sleep": ("MDCG_SLEEP", "1"),
77
+ "interval": ("MDCG_SLEEP_INTERVAL", "3600"),
78
+ "window": ("MDCG_SLEEP_WINDOW", "23:00-07:00"),
79
+ "merge": ("MDCG_SLEEP_MERGE", "auto"),
80
+ "gitdir": ("MDCG_SLEEP_GITDIR", ""),
81
+ "shadow": ("MDCG_SLEEP_SHADOW", ""),
82
+ "scrub_apply": ("MDCG_SLEEP_SCRUB_APPLY", "0"),
83
+ }
84
+ _CCG = ("# 功能名:%s\n# 生效条件:全时窗\n# 子功能:s\n# 执行:e\n"
85
+ "# 验证方式:test\n# 不适用条件:无\n")
86
+
87
+
88
+ def ok(cond, msg, extra=""):
89
+ (_PASS if cond else _FAIL).append(msg)
90
+ print((" PASS " if cond else " FAIL ") + msg
91
+ + ((" ← " + str(extra)) if (extra and not cond) else ""))
92
+
93
+
94
+ def _rel_text(rel: str) -> str:
95
+ if rel in _SRC:
96
+ return _SRC[rel]
97
+ with open(os.path.join(_REPO, rel), encoding="utf-8") as f:
98
+ return f.read()
99
+
100
+
101
+ def _sandbox_env():
102
+ for k in list(ENV_TABLE) + ["MDCG_ROOT", "MDCG_AUX_ROOT", "MDCG_STATE_ROOT",
103
+ "MDCG_DATA_ROOT", "MDCG_MASTER_KEY",
104
+ "MDCG_TOKEN_FILE", "MDCG_SUSTAIN_DIR",
105
+ "MDCG_TENANT", "MDCG_TOKEN", "MDCG_CLEARANCE",
106
+ "MDCG_SESSION", "DSH_SESSION_ID",
107
+ "MDCG_CAN_ADMIN", "MDCG_CAN_WRITE",
108
+ "MDCG_VERIFIER_MODULES", "MDCG_POOLING",
109
+ "MDCG_SEMANTIC", "MDCG_EN_ATOMS",
110
+ "MDCG_UNIFY_QUERY", "MDCG_SPREAD"]:
111
+ _SAVED[k] = os.environ.get(k)
112
+ os.environ.pop(k, None)
113
+
114
+
115
+ def _restore_env():
116
+ for k, v in _SAVED.items():
117
+ if v is None:
118
+ os.environ.pop(k, None)
119
+ else:
120
+ os.environ[k] = v
121
+
122
+
123
+ def _fixture(*, contextual_hot=0, unverified=0, extra=()):
124
+ """沙箱数据面:新 gen 目录 + 8 个 LAYERS + 若干节点。返回 (gen, root, state)。"""
125
+ _GEN[0] += 1
126
+ gen = os.path.join(_TMP, "gen%d" % _GEN[0])
127
+ root = os.path.join(gen, "data", "mdcg")
128
+ state = os.path.join(gen, "state")
129
+ os.environ["MDCG_STATE_ROOT"] = state
130
+ os.environ["MDCG_ROOT"] = root
131
+ os.environ["MDCG_AUX_ROOT"] = os.path.join(gen, "auxroot")
132
+ os.environ["MDCG_MASTER_KEY"] = "ab" * 32
133
+ os.environ["MDCG_TOKEN_FILE"] = os.path.join(gen, "tokens.json")
134
+ os.environ["MDCG_SUSTAIN_DIR"] = os.path.join(gen, "sustain")
135
+ os.makedirs(root, exist_ok=True)
136
+ os.makedirs(state, exist_ok=True)
137
+ from .mdcos import MdCGOS
138
+ cg = MdCGOS(root)
139
+ for i in range(int(contextual_hot)):
140
+ nid = "ctx_hot%d" % i
141
+ cg.add(nid, _CCG % ("情境热记忆%d" % i), layer="contextual",
142
+ verification_basis="test", importance=0.9, merge_count=5,
143
+ non_applicable_conditions=["无"])
144
+ for i in range(int(unverified)):
145
+ cg.add("kn_plain%d" % i, _CCG % ("无基底知识%d" % i), layer="knowledge",
146
+ non_applicable_conditions=["无"])
147
+ cg.add("kn_a", _CCG % "知识甲", layer="knowledge", verification_basis="test",
148
+ non_applicable_conditions=["无"])
149
+ cg.add("kn_b", _CCG % "知识乙", layer="knowledge", verification_basis="test",
150
+ non_applicable_conditions=["无"])
151
+ for nid, layer in extra:
152
+ cg.add(nid, _CCG % nid, layer=layer, verification_basis="test",
153
+ non_applicable_conditions=["无"])
154
+ cg.flush()
155
+ return gen, root, state
156
+
157
+
158
+ def _git(root, state, *args):
159
+ G = os.path.join(state, "sleep", "lib.git")
160
+ return subprocess.run(["git", "--git-dir=" + G, "--work-tree=" + root]
161
+ + list(args), capture_output=True, text=True,
162
+ encoding="utf-8", errors="replace", shell=False)
163
+
164
+
165
+ def _commits(root, state):
166
+ r = _git(root, state, "rev-list", "--count", "HEAD")
167
+ return int(r.stdout.strip() or 0) if r.returncode == 0 else 0
168
+
169
+
170
+ def _node(root, *parts):
171
+ return os.path.join(root, *parts)
172
+
173
+
174
+ # ---------------------------------------------------------------- G0
175
+ def g0():
176
+ print("== G0 沙箱与 §4.7 env 表真源 ==")
177
+ S = globals()["SL"]
178
+ ok(dict(S.SLEEP_ENV_DEFAULTS) == {k: v[1] for k, v in ENV_TABLE.items()},
179
+ "G0a SLEEP_ENV_DEFAULTS 七键缺省逐字等于 §4.7 表", S.SLEEP_ENV_DEFAULTS)
180
+ ok(dict(S.SLEEP_ENV_KEYS) == {k: v[0] for k, v in ENV_TABLE.items()},
181
+ "G0b SLEEP_ENV_KEYS 七键名逐字等于 §4.7 表", S.SLEEP_ENV_KEYS)
182
+ for k, (env_name, want) in ENV_TABLE.items():
183
+ got = S.sleep_env(k, {})
184
+ ok(got == want, "G0d 空环境下 %s 的读数为 %r" % (env_name, want), got)
185
+ ok(S.sleep_enabled() is True, "G0e MDCG_SLEEP 缺省 = 开(True)")
186
+ ok(S.sleep_enabled({"MDCG_SLEEP": "0"}) is False,
187
+ "G0f MDCG_SLEEP=0 → 关(opt-out 可达)")
188
+ ok(S.sleep_interval() == 3600.0, "G0g MDCG_SLEEP_INTERVAL 缺省 = 3600 秒",
189
+ S.sleep_interval())
190
+ ok(S.sleep_interval({"MDCG_SLEEP_INTERVAL": "abc"}) == 3600.0,
191
+ "G0h 非法周期值回落缺省(不抛、不猜测)")
192
+ ok(S.sleep_merge_mode() == "auto", "G0i MDCG_SLEEP_MERGE 缺省 = auto")
193
+ ok(S.sleep_merge_mode({"MDCG_SLEEP_MERGE": "never"}) == "never",
194
+ "G0j MDCG_SLEEP_MERGE=never 可显式取到")
195
+ ok(S.sleep_merge_mode({"MDCG_SLEEP_MERGE": "乱写"}) == "auto",
196
+ "G0k 越界策略回落 auto(不静默降级成 never)")
197
+ ok(S.sleep_scrub_apply() is False,
198
+ "G0l **MDCG_SLEEP_SCRUB_APPLY 缺省关**(第④步缺省只在副本上盘点)")
199
+ ok(S.sleep_scrub_apply({"MDCG_SLEEP_SCRUB_APPLY": "1"}) is True,
200
+ "G0m 显式 =1 才放行第④步实改")
201
+ ok(S.sleep_window() == "23:00-07:00",
202
+ "G0n MDCG_SLEEP_WINDOW 缺省 = 23:00-07:00", S.sleep_window())
203
+
204
+
205
+ def _production_py():
206
+ d = os.path.join(_REPO, "md_cg")
207
+ return {"md_cg/" + fn: _rel_text("md_cg/" + fn)
208
+ for fn in sorted(os.listdir(d)) if fn.endswith(".py")
209
+ and not fn.startswith("test_")}
210
+
211
+
212
+ def g8():
213
+ print("== G8 单一真源(静态)==")
214
+ import re as _re
215
+ want = sorted(v[0] for v in ENV_TABLE.values())
216
+ pat = _re.compile(r'"MDCG_SLEEP[A-Z_]*"')
217
+ bad = {rel: sorted(set(_re.findall(pat, t)))
218
+ for rel, t in _production_py().items() if rel != "md_cg/sleep.py"
219
+ and pat.search(t)}
220
+ ok(not bad,
221
+ "G8 除 sleep.py 外,生产源码里 **零**个带引号的 MDCG_SLEEP* 字面量"
222
+ "(缺省与键名不许有第二份)", bad)
223
+ lit = sorted(set(_re.findall(pat, _rel_text("md_cg/sleep.py"))))
224
+ ok(lit == ['"%s"' % n for n in want],
225
+ "G8a sleep.py 内带引号的 MDCG_SLEEP* 字面量 == §4.7 七键(恰一份,在 "
226
+ "SLEEP_ENV_KEYS)", lit)
227
+ m = _rel_text("md_cg/mcp_server.py")
228
+ ok(m.count("_sleep.sleep_interval()") == 2
229
+ and m.count("_sleep.sleep_enabled()") == 2
230
+ and m.count("_sleep.sleep_merge_mode()") == 2
231
+ and m.count("_sleep.sleep_window()") == 2
232
+ and m.count("_sleep.sleep_scrub_apply()") == 2,
233
+ "G8a 两入口各恰好一处经 sleep 读取器读五个sleep参数"
234
+ "(env 路径 + op 路径,无字面量)")
235
+ s = _rel_text("md_cg/sustain.py")
236
+ ok('"MDCG_SLEEP' not in s,
237
+ "G8b sustain.py 内不写 env 键名字面量(缺省与键名都归 sleep.py 真源)")
238
+ ok("SLEEP_ENV_DEFAULTS" in _rel_text("md_cg/sleep.py")
239
+ and "SLEEP_ENV_KEYS" in _rel_text("md_cg/sleep.py"),
240
+ "G8c 真源表在 sleep.py 定义(SLEEP_ENV_DEFAULTS / SLEEP_ENV_KEYS)")
241
+
242
+
243
+ # ---------------------------------------------------------------- G1
244
+ def g1():
245
+ print("== G1 窗口判定(跨午夜 / 留空)==")
246
+ S = globals()["SL"]
247
+ w = "23:00-07:00"
248
+ ok(S.parse_window(w) == (23 * 60, 7 * 60),
249
+ "G1 解析 23:00-07:00 → (1380, 420)", S.parse_window(w))
250
+ ok(S.in_window(w, "23:30") is True, "G1a 跨午夜:23:30 在窗内")
251
+ ok(S.in_window(w, "03:00") is True, "G1b 跨午夜:03:00 在窗内")
252
+ ok(S.in_window(w, "12:00") is False, "G1c 跨午夜:12:00 在窗外")
253
+ ok(S.in_window(w, "19:00") is False, "G1d 跨午夜:19:00 在窗外")
254
+ ok(S.in_window(w, "07:00") is False and S.in_window(w, "23:00") is True,
255
+ "G1e 边界:左闭右开(23:00 在窗内、07:00 在窗外)")
256
+ ok(S.in_window("", "12:00") is True and S.in_window(None, "12:00") is True,
257
+ "G1f 留空 = 全时段恒真")
258
+ ok(S.in_window("01:00-05:00", "03:00") is True
259
+ and S.in_window("01:00-05:00", "06:00") is False,
260
+ "G1g 非跨午夜窗口照常判定")
261
+ # 裁定⑰(2026-10-01):非法形态**回落缺省窗**(不再是全时段 fail-open)
262
+ ok(S.in_window("乱写", "12:00") is False and S.in_window("乱写", "03:00") is True,
263
+ "G1h 非法形态回落缺省窗 23:00-07:00(12:00 窗外 / 03:00 窗内)")
264
+ ok(S.in_window("23:00-99:99", "12:00") is False
265
+ and S.in_window("23:00-99:99", "03:00") is True,
266
+ "G1h2 打错字窗口(越界时分)同样回落缺省窗——不再白天也迭代")
267
+ ok(S.parse_window("乱写") == S.parse_window("23:00-07:00"),
268
+ "G1h3 非法形态解析结果 == 缺省窗(单一真源 SLEEP_ENV_DEFAULTS['window'])")
269
+ ok(S.in_window("00:00-00:00", "12:00") is True,
270
+ "G1i 同起止 = 全天恒真")
271
+ # 真时间戳形态(_tick_sleep 走的是无参 localtime)
272
+ import time as _t
273
+ ok(S.in_window(w, _t.localtime()) is True
274
+ or S.in_window(w, _t.localtime()) is False,
275
+ "G1j 传入 time.struct_time 不抛(真对象形态可用)")
276
+ # 窗口外 ⇒ 只记账不迭代(端到端)
277
+ gen, root, state = _fixture(unverified=1)
278
+ from .mdcos import MdCGOS
279
+ cg = MdCGOS(root)
280
+ face0 = S.source_face_hashes(root)
281
+ n0 = _commits(root, state)
282
+ ro = S.run_cycle(cg, batch="G1OUT", window="23:00-07:00", now="12:00")
283
+ ok(ro.get("skipped") and "窗口外" in ro["skipped"],
284
+ "G1k 窗口外 ⇒ 整轮只记账(skipped 记窗口外)", ro.get("skipped"))
285
+ ok(all(s.get("skipped") for s in ro["steps"][1:4]) and len(ro["steps"]) == 9,
286
+ "G1l 窗口外时 ②③④ 记 skip、台账仍是九条")
287
+ ok(S.source_face_hashes(root) == face0 and _commits(root, state) == n0,
288
+ "G1m 窗口外**零写入零提交**(真源面与提交计数都不动)")
289
+
290
+
291
+ # ---------------------------------------------------------------- G2
292
+ def g2():
293
+ print("== G2 九步台账在场且 ⑤⑥ 标 skipped ==")
294
+ S = globals()["SL"]
295
+ steps = S.ledger_steps(scan={"candidates": 3, "ccg_backlog": 1,
296
+ "importance_drift": 2},
297
+ iterated={"induce": {"written": 0},
298
+ "promote": {"written": 0},
299
+ "scrub": {"dry_run": True}},
300
+ round_index=7, prev_candidates=5)
301
+ ok([s["step"] for s in steps] == list(S.STEPS),
302
+ "G2 台账恒九条且顺序 = §3.1 表次序", [s["step"] for s in steps])
303
+ ok(all(s.get("name") for s in steps), "G2a 每步都有显式中文名")
304
+ ok(steps[0]["step"] == "recall_scan" and steps[0].get("readonly") is True,
305
+ "G2b ①感知盘点标 readonly=True(只读)")
306
+ ok(steps[4].get("skipped") == "未接线" and steps[5].get("skipped") == "未接线",
307
+ "G2c **⑤权重 / ⑥索引重建 标 skipped=='未接线'**(不许悄悄跳过)",
308
+ (steps[4].get("skipped"), steps[5].get("skipped")))
309
+ ok(steps[4].get("planned") and steps[5].get("planned"),
310
+ "G2d ⑤⑥ 附 planned(接线条目:P4 / P2P3)")
311
+ ok(steps[8]["step"] == "self_check" and steps[8].get("auto_apply") is False,
312
+ "G2e ⑨方向性自检只产记录(auto_apply=False,不自动触发修改)")
313
+ ok(steps[7]["step"] == "feedback"
314
+ and steps[7].get("prev_round_candidates") == 5
315
+ and steps[7].get("candidates") == 3 and steps[7].get("delta") == -2,
316
+ "G2f ⑧反馈 = 候选数前后变化(可判)", steps[7])
317
+ ok(steps[6]["step"] == "record" and steps[6].get("ledger"),
318
+ "G2g ⑦记录带台账落点")
319
+ # 只记账路径:②③④ 记 skip_reason
320
+ st2 = S.ledger_steps(scan={"candidates": 0}, round_index=1,
321
+ skip_reason="稳态:盘点候选数为 0")
322
+ ok(all(s.get("skipped") == "稳态:盘点候选数为 0" for s in st2[1:4]),
323
+ "G2h 只记账时 ②③④ 显式记 skip_reason(不是留空)",
324
+ [s.get("skipped") for s in st2[1:4]])
325
+ ok(len(st2) == 9 and st2[4].get("skipped") == "未接线",
326
+ "G2i 只记账路径仍是九条、⑤⑥ 仍是「未接线」")
327
+
328
+
329
+ # ---------------------------------------------------------------- G3
330
+ def g3():
331
+ print("== G3 第六档 tick(_run 到期链 + 真起循环)==")
332
+ S = globals()["SUS"]
333
+ src = _rel_text("md_cg/sustain.py")
334
+ ok("next_sleep = time.time() + self.sleep_interval" in src,
335
+ "G3 _run 内新增 next_sleep 到期变量")
336
+ ok(" if now >= next_sleep:\n"
337
+ " try:\n"
338
+ " self._tick_sleep()\n"
339
+ " except Exception:\n"
340
+ " pass # 睡眠周期失败不中断常驻\n"
341
+ " next_sleep = now + self.sleep_interval\n"
342
+ " self._stop.wait(_POLL)" in src,
343
+ "G3a 第六档形态与既有五档逐字同构(try/except 吞异常 + 末尾 _stop.wait(_POLL))")
344
+ gen, root, state = _fixture(unverified=1)
345
+ from .mdcos import MdCGOS
346
+ lp = S.SustainLoop(MdCGOS(root), name="g3loop",
347
+ d=os.environ["MDCG_SUSTAIN_DIR"])
348
+ ok(lp.sleep_interval == 3600.0 and lp.auto_sleep is True
349
+ and lp.sleep_merge == "auto" and lp.sleep_window == "23:00-07:00"
350
+ and lp.sleep_scrub_apply is False,
351
+ "G3b SustainLoop 五个 sleep 属性取自 §4.7 真源读取器",
352
+ (lp.sleep_interval, lp.auto_sleep, lp.sleep_merge, lp.sleep_window,
353
+ lp.sleep_scrub_apply))
354
+ lp._tick_sleep()
355
+ rec = lp.last_sleep
356
+ ok(rec is not None and rec["steps"] == list(SL.STEPS),
357
+ "G3c 真跑 _tick_sleep 后 last_sleep.steps = 九步", rec and rec["steps"])
358
+ ok(any(s.startswith("weights:未接线") for s in rec["steps_skipped"])
359
+ and any(s.startswith("reindex:未接线") for s in rec["steps_skipped"]),
360
+ "G3d 台账里 ⑤⑥ 标 skipped=未接线", rec["steps_skipped"])
361
+ ok("sleep_interval" in lp.status() and "last_sleep" in lp.status()
362
+ and "auto_sleep" in lp.status(),
363
+ "G3e status() 暴露睡眠档字段(可观测)")
364
+ ok(rec["skipped"] is None or "窗口" in rec["skipped"],
365
+ "G3f tick 的只记账判据只可能是窗口(缺省 23:00-07:00),不是别的静默跳过",
366
+ rec["skipped"])
367
+ S.stop_all()
368
+ # 无参 tick 走的是 localtime;窗口关掉窗口(留空=全时段)后可确定性跑真迭代
369
+ os.environ["MDCG_SLEEP_WINDOW"] = ""
370
+ lp2 = S.SustainLoop(MdCGOS(root), name="g3loop2",
371
+ d=os.environ["MDCG_SUSTAIN_DIR"])
372
+ ok(lp2.sleep_window == "" and lp2.auto_sleep is True,
373
+ "G3g 窗口留空 = 全时段(可确定性跑真迭代)")
374
+ lp2._tick_sleep()
375
+ rec2 = lp2.last_sleep
376
+ ok(rec2["skipped"] is None and (rec2["candidates"] or 0) > 0,
377
+ "G3h 全时段下 tick 真迭代并留读(非只记账)",
378
+ {k: rec2.get(k) for k in ("skipped", "candidates")})
379
+ ok(len(rec2["steps"]) == 9 and rec2["steps"][:8] == list(SL.STEPS)[:8],
380
+ "G3i tick 台账九步顺序不变")
381
+ os.environ.pop("MDCG_SLEEP_WINDOW", None)
382
+ S.stop_all()
383
+
384
+
385
+ # ---------------------------------------------------------------- G4
386
+ def g4():
387
+ print("== G4 四阶段端到端(物化→迭代→对账→合并)==")
388
+ S = globals()["SL"]
389
+ gen, root, state = _fixture(contextual_hot=1, unverified=1)
390
+ from .mdcos import MdCGOS
391
+ cg = MdCGOS(root)
392
+ before = S.source_face_hashes(root)
393
+ n0 = _commits(root, state)
394
+ r = S.run_cycle(cg, batch="G4B1", round_index=1, now="03:00")
395
+ ok(r.get("skipped") is None and r.get("candidates", 0) > 0,
396
+ "G4 本轮真迭代(非只记账)", {k: r.get(k) for k in
397
+ ("skipped", "candidates")})
398
+ ok(r.get("accepted") and "ctx_hot0" in r["accepted"],
399
+ "G4a 影子上被提升的情境节点进了 accepted", r.get("accepted"))
400
+ after = S.source_face_hashes(root)
401
+ ok("knowledge/orphan/ctx_hot0.md" in after.keys()
402
+ or any("ctx_hot0" in k and k.startswith("knowledge/") for k in after),
403
+ "G4b 合并后主库**真的**多了知识层的 ctx_hot0", sorted(after))
404
+ ok(not any(k.startswith("contextual/") for k in after),
405
+ "G4c 层迁移的旧位置在主库已清掉(不留双份)", sorted(after))
406
+ mg = r["steps"][6]["phases"]
407
+ ok("merge" in mg, "G4d 四阶段都在位(materialize/iterate/reconcile/merge)", mg)
408
+ n1 = _commits(root, state)
409
+ ok(n1 >= n0 + 3,
410
+ "G4e 一轮 = 基线/对账/重放/合并多个提交(实测 %s→%s)" % (n0, n1))
411
+ log = _git(root, state, "log", "--oneline", "--all").stdout
412
+ ok("merge" in log and "sleep/" in log,
413
+ "G4f 历史里留下 sleep/<ts> 分支与合并提交")
414
+ tr = S.audit_tracking(root, git_dir_path=os.path.join(state, "sleep", "lib.git"))
415
+ hi = S.audit_history(root, git_dir_path=os.path.join(state, "sleep", "lib.git"))
416
+ ok(hi["ok"] and not hi["hits"],
417
+ "G4g P0-1 判据一仍成立:历史里 _keys.json/_access.log/_index.json/*.tmp/*.lock 零命中",
418
+ hi["hits"])
419
+ ok(tr["ok"], "G4h P0-1 判据二仍成立:追踪集合 ⊆ 8 层且 ⊇ 全部 .md",
420
+ {"outside": tr["outside_layers"], "missing": tr["missing_md"]})
421
+ ok(set(S.tracked_paths(root, git_dir_path=os.path.join(state, "sleep", "lib.git")))
422
+ == set(after), "G4i 追踪集合 = 主库真源面 .md 全集")
423
+ ok(before != after, "G4j 前置:真源面确实变了(对照 G5 的不变)")
424
+ # 影子目录里那些非白名单件(_index.json 等)不进版本库
425
+ sh = os.path.join(state, "sleep", "shadow")
426
+ ok(os.path.isdir(sh), "G4k 影子工作树落在 state_root()/sleep/shadow", sh)
427
+ sc = r["steps"][3]
428
+ ok(sc.get("step") == "scrub" and sc.get("apply") is False
429
+ and sc.get("dry_run") is True,
430
+ "G4l 第④步缺省只在副本上盘点(未设 MDCG_SLEEP_SCRUB_APPLY ⇒ "
431
+ "dry_run=True、不落盘)", sc)
432
+ # ④ 的正向通路:显式放行时 dry_run=False(闸真的接线,不是恒 dry_run)
433
+ gen3, root3, state3 = _fixture(contextual_hot=1, unverified=1)
434
+ cg3 = MdCGOS(root3)
435
+ r3 = S.run_cycle(cg3, batch="G4SCRUB", round_index=1, now="03:00",
436
+ scrub_apply=True)
437
+ sc3 = r3["steps"][3]
438
+ ok(sc3.get("apply") is True and sc3.get("dry_run") is False,
439
+ "G4l2 显式放行第④步实改时 dry_run=False(闸接线,非恒 dry_run)", sc3)
440
+ # MDCG_SLEEP_MERGE=ask:走完前三段,**合并挂起待人工**(不写主库)
441
+ gen2, root2, state2 = _fixture(contextual_hot=1, unverified=1)
442
+ cg2 = MdCGOS(root2)
443
+ face2 = S.source_face_hashes(root2)
444
+ n02 = _commits(root2, state2)
445
+ ra = S.run_cycle(cg2, batch="G4ASK", round_index=1, now="03:00",
446
+ merge_mode="ask")
447
+ ph = ra["steps"][6]["phases"].get("merge") or {}
448
+ ok(ph.get("deferred") == "ask",
449
+ "G4m MDCG_SLEEP_MERGE=ask:合并挂起留待人工", ph)
450
+ log2 = _git(root2, state2, "log", "--oneline", "--all").stdout
451
+ ok(S.source_face_hashes(root2) == face2,
452
+ "G4n ask 档不写主库(主库真源面逐位不变;物化/对账只动影子分支)")
453
+ ok("into main" not in log2,
454
+ "G4o ask 档不产生合并提交(历史里只有基线与对账)", log2)
455
+
456
+
457
+ # ---------------------------------------------------------------- G5
458
+ def g5():
459
+ print("== G5 Δ 为空 ⇒ 零提交 + 主库逐字节不变 ==")
460
+ S = globals()["SL"]
461
+ gen, root, state = _fixture(contextual_hot=1, unverified=1)
462
+ from .mdcos import MdCGOS
463
+ cg = MdCGOS(root)
464
+ S.run_cycle(cg, batch="G5B1", round_index=1, now="03:00")
465
+ face = S.source_face_hashes(root)
466
+ n0 = _commits(root, state)
467
+ # 第二轮:情境热记忆已被提升 ⇒ 迭代不再产出任何改动(候选数仍 >0)
468
+ r2 = S.run_cycle(cg, batch="G5B2", round_index=2, now="03:00")
469
+ ok(r2.get("candidates", 0) > 0 and r2.get("skipped") is None,
470
+ "G5 前置:第二轮走的是真迭代路径(不是稳态短路)",
471
+ {k: r2.get(k) for k in ("candidates", "skipped")})
472
+ ok(r2["steps"][6]["delta"]["n"] == 0,
473
+ "G5a 第二轮语义差异集 Δ 为空", r2["steps"][6]["delta"])
474
+ ok(not r2.get("accepted"), "G5b 无 accepted(Δ 空 ⇒ 整轮跳过)", r2.get("accepted"))
475
+ ok(_commits(root, state) == n0,
476
+ "G5c **Δ 为空 ⇒ 零提交**(提交计数 %s 不变)" % n0)
477
+ ok(S.source_face_hashes(root) == face,
478
+ "G5d **主库真源面逐字节不变**(内容哈希集合逐位相同)")
479
+
480
+
481
+ # ---------------------------------------------------------------- G6
482
+ def g6():
483
+ print("== G6 幂等:同批次再合并 = 零改动 ==")
484
+ S = globals()["SL"]
485
+ gen, root, state = _fixture(contextual_hot=1, unverified=1)
486
+ from .mdcos import MdCGOS
487
+ cg = MdCGOS(root)
488
+ r1 = S.run_cycle(cg, batch="G6B1", round_index=1, now="03:00")
489
+ ok(r1.get("merged") is True, "G6 第一轮合并成功(前置)", r1.get("merged"))
490
+ face = S.source_face_hashes(root)
491
+ n0 = _commits(root, state)
492
+ r2 = S.run_cycle(cg, batch="G6B1", round_index=2, now="03:00")
493
+ ok(r2.get("idempotent") is True,
494
+ "G6a 同批次 id 第二次 → idempotent=True", r2.get("idempotent"))
495
+ ok(_commits(root, state) == n0,
496
+ "G6b 第二次零提交(提交计数 %s 不变)" % n0)
497
+ ok(S.source_face_hashes(root) == face,
498
+ "G6c 第二次零改动:主库真源面逐字节不变")
499
+ ok(S.batch_merged("G6B1") is True and S.batch_merged("不存在的批次") is False,
500
+ "G6d 批次 id 落台账(幂等判据可查)")
501
+
502
+
503
+ # ---------------------------------------------------------------- G7
504
+ def g7():
505
+ print("== G7 冲突项挂起且不进主库 ==")
506
+ S = globals()["SL"]
507
+ gen, root, state = _fixture()
508
+ from .mdcos import MdCGOS
509
+ cg = MdCGOS(root)
510
+ G = os.path.join(state, "sleep", "lib.git")
511
+ m = S.materialize(root, git_dir_path=G, ts="G7T1")
512
+ ok(m.get("ok") is True, "G7 前置:物化成功", m.get("error"))
513
+ ok(m.get("baseline_face"), "G7a 物化记录**基准水位**(并发闸的判据面)")
514
+ sh = m["shadow"]
515
+ rel_a = "knowledge/orphan/kn_a.md"
516
+ rel_b = "knowledge/orphan/kn_b.md"
517
+ # 影子侧改 A 与 B
518
+ for rel, tag in ((rel_a, "影子改A"), (rel_b, "影子改B")):
519
+ with open(os.path.join(sh, rel), "a", encoding="utf-8", newline="\n") as f:
520
+ f.write(tag + "\n")
521
+ # 主库侧**并发**改同一个 id B(模拟迭代期间另一写方落盘)
522
+ with open(os.path.join(root, rel_b), "a", encoding="utf-8", newline="\n") as f:
523
+ f.write("主库并发改B\n")
524
+ main_b = open(os.path.join(root, rel_b), "rb").read()
525
+ rc = S.reconcile_gated(root, git_dir_path=G, shadow=sh, branch=m["branch"],
526
+ batch="G7B1", baseline_face=m["baseline_face"], cg=cg)
527
+ cids = [c["id"] for c in rc["conflicts"]]
528
+ ok("kn_b" in cids, "G7b 同 id 并发改动 → 进 conflicts 清单", cids)
529
+ ok(all(c.get("gate") for c in rc["conflicts"]) and
530
+ all(c.get("reason") for c in rc["conflicts"]),
531
+ "G7c 冲突项带 gate 与 reason(可展示的清单)")
532
+ ok("kn_b" not in rc["accepted"] and "kn_a" in rc["accepted"],
533
+ "G7d 并发冲突项**不在 accepted**、无关项照常通过", rc["accepted"])
534
+ mg = S.merge_cycle(cg, root, git_dir_path=G, branch=rc["branch"], shadow=sh,
535
+ delta=rc["delta"], accepted=rc["accepted"], batch="G7B1")
536
+ ok(mg.get("ok") is True, "G7e 合并本身成功(冲突项已被挂起,不在合并面)",
537
+ {k: mg.get(k) for k in ("ok", "error", "deferred")})
538
+ ok(open(os.path.join(root, rel_b), "rb").read() == main_b,
539
+ "G7f **冲突项未被写入主库**(主库优先,逐字节不变)")
540
+ ok("影子改B" not in open(os.path.join(root, rel_b), encoding="utf-8").read(),
541
+ "G7g 影子侧的冲突改动没有渗进主库正文")
542
+ ok(mg.get("main_written") == [rel_a],
543
+ "G7h 主库只多了应写的那一项(A)", mg.get("main_written"))
544
+ branch_b = _git(root, state, "show", "%s:%s" % (rc["branch"], rel_b)).stdout
545
+ ok("影子改B" not in branch_b,
546
+ "G7i 冲突项也**未进影子分支的树**(不会借合并回灌)")
547
+ ok(S.audit_history(root, git_dir_path=G)["ok"] and
548
+ S.audit_tracking(root, git_dir_path=G)["ok"],
549
+ "G7j 冲突轮之后两条机械判据仍全绿")
550
+ # 密文项(保护闸 fail-closed 面):影子侧的密文节点不得随分支入册,
551
+ # 否则重放后两侧树不同 ⇒ 合并被 tree_divergence 永久堵死。
552
+ gen2, root2, state2 = _fixture()
553
+ cg2 = MdCGOS(root2)
554
+ G2 = os.path.join(state2, "sleep", "lib.git")
555
+ m2 = S.materialize(root2, git_dir_path=G2, ts="G7T2")
556
+ sh2 = m2["shadow"]
557
+ from . import nodefile as _nf
558
+ _pa = os.path.join(sh2, "knowledge", "orphan", "kn_a.md")
559
+ _fm_a, _ = _nf.loads(open(_pa, encoding="utf-8").read())
560
+ with open(_pa, "w", encoding="utf-8", newline="\n") as f:
561
+ # 密文形态:**整段正文**就是密文标记块(is_encrypted 判正文开头)
562
+ f.write(_nf.dumps(_fm_a, "<!-- mdcg-enc:v1:AAAA -->"))
563
+ with open(os.path.join(sh2, "knowledge", "orphan", "kn_b.md"), "a",
564
+ encoding="utf-8", newline="\n") as f:
565
+ f.write("明文改动\n")
566
+ rc2 = S.reconcile_gated(root2, git_dir_path=G2, shadow=sh2,
567
+ branch=m2["branch"], batch="G7B2",
568
+ baseline_face=m2["baseline_face"], cg=cg2)
569
+ ok("kn_a" not in rc2["accepted"]
570
+ and any(r["id"] == "kn_a" for r in rc2["rejected"]),
571
+ "G7k 影子侧密文节点被闸拦下(fail-closed,不进 accepted)",
572
+ {"accepted": rc2["accepted"], "rejected": rc2["rejected"]})
573
+ ok("kn_b" in rc2["accepted"], "G7l 同轮的明文项照常通过", rc2["accepted"])
574
+ mg2 = S.merge_cycle(cg2, root2, git_dir_path=G2, branch=rc2["branch"],
575
+ shadow=sh2, delta=rc2["delta"],
576
+ accepted=rc2["accepted"], batch="G7B2")
577
+ ok(mg2.get("ok") is True and not mg2.get("deferred"),
578
+ "G7m 密文项不再堵死合并(本轮正常合并)",
579
+ {k: mg2.get(k) for k in ("ok", "deferred", "error")})
580
+ ok(mg2.get("main_written") == ["knowledge/orphan/kn_b.md"],
581
+ "G7n 主库只写明文那一项", mg2.get("main_written"))
582
+
583
+
584
+ # ---------------------------------------------------------------- G8r
585
+ def g8_revert():
586
+ print("== G8 回滚(revert):真源面可回、历史不丢 ==")
587
+ S = globals()["SL"]
588
+ gen, root, state = _fixture(contextual_hot=1, unverified=1)
589
+ from .mdcos import MdCGOS
590
+ cg = MdCGOS(root)
591
+ G = os.path.join(state, "sleep", "lib.git")
592
+ pre = S.source_face_hashes(root)
593
+ r = S.run_cycle(cg, batch="G8B1", round_index=1, now="03:00")
594
+ ok(r.get("merged") is True, "G8 前置:一轮合并成功")
595
+ mid = S.source_face_hashes(root)
596
+ ok(mid != pre, "G8a 前置:合并确实改了真源面")
597
+ mg = r["steps"][6]["phases"].get("merge") or {}
598
+ mc = mg.get("round_commit")
599
+ ok(bool(mc), "G8b 台账里能拿到**本轮内容所在的提交**(round_commit = 回退入口)", mc)
600
+ ok(bool(mg.get("merge_commit")) and mg.get("merge_commit") != mc,
601
+ "G8b2 合并提交与重放提交是两个提交(前者是拓扑记录)",
602
+ (mg.get("merge_commit"), mc))
603
+ hist_before = set(S.history_paths(root, git_dir_path=G))
604
+ rv = S.revert(mc, root, git_dir_path=G)
605
+ ok(rv.get("ok") is True, "G8c revert 成功",
606
+ {k: rv.get(k) for k in ("ok", "error", "conflict", "hint")})
607
+ ok(S.source_face_hashes(root) == pre,
608
+ "G8d 回滚后主库真源面 == 合并前(逐位)",
609
+ S.face_delta(pre, S.source_face_hashes(root)))
610
+ ok(set(S.history_paths(root, git_dir_path=G)) >= hist_before,
611
+ "G8e 历史只增不减(revert 自身也进历史)")
612
+
613
+
614
+ _GROUPS = (g0, g8, g1, g2, g3, g4, g5, g6, g7, g8_revert)
615
+
616
+
617
+ def _run_groups() -> int:
618
+ _GEN[0] += 1
619
+ _PASS.clear()
620
+ _FAIL.clear()
621
+ for g in _GROUPS:
622
+ try:
623
+ g()
624
+ except Exception as exc: # noqa: BLE001
625
+ ok(False, "断言组 %s 抛异常:%s" % (g.__name__, exc))
626
+ import traceback
627
+ print(traceback.format_exc()[-1200:])
628
+ finally:
629
+ try:
630
+ globals()["SUS"].stop_all()
631
+ except Exception: # noqa: BLE001
632
+ pass
633
+ return len(_FAIL)
634
+
635
+
636
+ # ---------------------------------------------------------------- 变异表
637
+ # 锚点 = (名字, rel, old, new)。每次变异必须让套件**转红**。
638
+ _MUTATIONS = (
639
+ ("窗口跨午夜判据写反", "md_cg/sleep.py",
640
+ " return t >= start or t < end",
641
+ " return t >= start and t < end"),
642
+ ("留空/非法窗口不再视作全时段", "md_cg/sleep.py",
643
+ " s = str(spec or \"\").strip()\n if not s or \"-\" not in s:\n return None",
644
+ " s = str(spec or \"\").strip()\n if not s or \"-\" not in s:\n return (0, 1)"),
645
+ ("密文项不再被闸拦下(会随分支入册)", "md_cg/sleep.py",
646
+ ' "reason": ("影子侧为密文节点(无密钥不重放、"\n'
647
+ ' "fail-closed),不入册也不合并")})\n'
648
+ ' continue',
649
+ ' "reason": ("影子侧为密文节点(无密钥不重放、"\n'
650
+ ' "fail-closed),不入册也不合并")})\n'
651
+ ' pass'),
652
+ ("④实改闸不接线(恒 dry_run)", "md_cg/sleep.py",
653
+ " r4 = _sc.sweep(cg, dry_run=not scrub_apply)",
654
+ " r4 = _sc.sweep(cg, dry_run=True)"),
655
+ ("Δ 不看内容哈希(未改动也算差异)", "md_cg/sleep.py",
656
+ " if a and b:\n if ha == hb:\n continue\n kind = \"changed\"",
657
+ " if a and b:\n kind = \"changed\""),
658
+ ("⑤⑥ 不再标「未接线」(悄悄跳过)", "md_cg/sleep.py",
659
+ ' steps.append({"step": key, "name": STEP_NAMES[key],\n'
660
+ ' "skipped": "未接线", "planned": NOT_WIRED[key]})',
661
+ ' steps.append({"step": key, "name": STEP_NAMES[key],\n'
662
+ ' "planned": NOT_WIRED[key]})'),
663
+ ("④实改闸缺省翻成开", "md_cg/sleep.py",
664
+ ' "scrub_apply": "0", # 第④步去污染**实改**闸(缺省关:只盘点不落盘)',
665
+ ' "scrub_apply": "1", # 第④步去污染**实改**闸(缺省关:只盘点不落盘)'),
666
+ ("并发闸不记 conflict(主库不再优先)", "md_cg/sleep.py",
667
+ ' conflicts.append({"id": nid, "gate": "concurrent", "kind": kind,',
668
+ ' accepted.append(nid)\n conflicts.append({"id": nid, "gate": "concurrent", "kind": kind,'),
669
+ ("幂等判据失效(同批次不再拦截)", "md_cg/sleep.py",
670
+ " return any(r.get(\"batch\") == batch and r.get(\"merged\") is True\n"
671
+ " for r in read_ledger())",
672
+ " return False"),
673
+ ("第六档 tick 不进 _run 到期链", "md_cg/sustain.py",
674
+ " if now >= next_sleep:\n try:\n"
675
+ " self._tick_sleep()\n except Exception:\n"
676
+ " pass # 睡眠周期失败不中断常驻\n"
677
+ " next_sleep = now + self.sleep_interval\n",
678
+ ""),
679
+ ("两入口不再共用 sleep 读取器(写死 auto)", "md_cg/mcp_server.py",
680
+ " sleep_interval=_sleep.sleep_interval(),\n"
681
+ " auto_sleep=_sleep.sleep_enabled(),\n"
682
+ " sleep_merge=_sleep.sleep_merge_mode(),\n"
683
+ " sleep_window=_sleep.sleep_window(),\n"
684
+ " sleep_scrub_apply=_sleep.sleep_scrub_apply())",
685
+ " sleep_interval=3600.0, auto_sleep=True, sleep_merge=\"auto\",\n"
686
+ " sleep_window=\"23:00-07:00\", sleep_scrub_apply=False)"),
687
+ )
688
+
689
+
690
+ def _exec_module(name: str, rel: str, text: str):
691
+ ns = {"__name__": name, "__package__": "md_cg",
692
+ "__file__": os.path.join(_REPO, rel)}
693
+ exec(compile(text, rel, "exec"), ns) # noqa: S102 —— 基线自证用
694
+ m = types.ModuleType(name)
695
+ m.__dict__.update(ns)
696
+ return m
697
+
698
+
699
+ _MUT_KIND = {"md_cg/sleep.py": ("SL", "md_cg.sleep"),
700
+ "md_cg/sustain.py": ("SUS", "md_cg.sustain")}
701
+
702
+
703
+ def _mutate_mode(list_only: bool = False) -> int:
704
+ print("!! 定点变异自证:逐条把实现改回「改动前/错误」形态,套件必须转红\n")
705
+ if list_only:
706
+ for name, rel, _o, _n in _MUTATIONS:
707
+ print(" %-40s [%s]" % (name, rel))
708
+ return 0
709
+ anchor_miss, bad = [], []
710
+ with contextlib.redirect_stdout(io.StringIO()):
711
+ clean = _run_groups()
712
+ print(" 未变异基线:失败=%d(必须为 0)" % clean)
713
+ if clean:
714
+ bad.append("未变异基线即失败")
715
+
716
+ for name, rel, old, new in _MUTATIONS:
717
+ src = _rel_text(rel)
718
+ if old not in src:
719
+ print(" ANCHOR-MISS %s —— 锚点在 %s 源码里找不到(实现改了却没同步"
720
+ "本表)" % (name, rel))
721
+ anchor_miss.append(name)
722
+ continue
723
+ mut_src = src.replace(old, new, 1)
724
+ _SRC[rel] = mut_src
725
+ live_sl, live_sus = globals()["SL"], globals()["SUS"]
726
+ swapped = None
727
+ try:
728
+ if rel in _MUT_KIND:
729
+ attr, modname = _MUT_KIND[rel]
730
+ mut = _exec_module(modname.replace("md_cg.", "md_cg._p1mut_"), rel,
731
+ mut_src)
732
+ sys.modules[modname] = mut
733
+ swapped = (attr, mut)
734
+ globals()[attr] = mut
735
+ with contextlib.redirect_stdout(buf := io.StringIO()):
736
+ reds = _run_groups()
737
+ detail = buf.getvalue()
738
+ finally:
739
+ if swapped:
740
+ attr, _m = swapped
741
+ sys.modules[_MUT_KIND[rel][1]] = (live_sl if attr == "SL"
742
+ else live_sus)
743
+ globals()["SL"], globals()["SUS"] = live_sl, live_sus
744
+ _SRC.pop(rel, None)
745
+ red_lines = [l for l in detail.splitlines()
746
+ if l.strip().startswith("FAIL ")]
747
+ verdict = "红" if reds else "**仍全绿 = 该判据空转**"
748
+ print(" %s %-40s 红项=%d %s"
749
+ % ("OK " if reds else "MISS ", name, reds, verdict))
750
+ for l in red_lines[:6]:
751
+ print(" " + l.strip()[5:])
752
+ if not reds:
753
+ bad.append(name)
754
+
755
+ if anchor_miss:
756
+ print("\nANCHOR-MISS:%s" % "、".join(anchor_miss))
757
+ print("退出码 2(fail-closed):变异表锚点漂移即判失败,不得静默跳过")
758
+ return 2
759
+ print("\n定点变异自证:%s"
760
+ % ("PASS(每处判据都有一条断言把它钉死)" if not bad
761
+ else "FAIL —— " + "、".join(bad)))
762
+ return 0 if not bad else 1
763
+
764
+
765
+ def main() -> int:
766
+ _sandbox_env()
767
+ try:
768
+ if "--mutate" in sys.argv:
769
+ return _mutate_mode("--list" in sys.argv)
770
+ n = _run_groups()
771
+ print("\nP1 睡眠周期引擎守卫:%d 通过,%d 失败,%d 跳过"
772
+ % (len(_PASS), n, len(_SKIP)))
773
+ return 0 if not n else 1
774
+ finally:
775
+ try:
776
+ _REAL_SUS.stop_all()
777
+ except Exception: # noqa: BLE001
778
+ pass
779
+ _restore_env()
780
+ shutil.rmtree(_TMP, ignore_errors=True)
781
+
782
+
783
+ if __name__ == "__main__":
784
+ sys.exit(main())