@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,1020 @@
1
+ # -*- coding: utf-8 -*-
2
+ """stgidx 守卫:第 3 层结构索引(装载期内存倒排 + 写钩增量 + 读侧直取)。
3
+
4
+ 契约(设计稿 `docs/plans/stg条件化与结构索引_设计_v0.1.md`,2026-10-03 签收):
5
+ · 等价性为**唯一准入**:`MDCG_STG_INDEX` 关/开两臂,三接口**业务字段逐位一致**
6
+ (除 `scanned` 与新增 `index` 读数——设计稿 §四「除 meta 计数」);
7
+ · 索引维护不变量:三张表恒为快照 keys 的**划分**(写/删/rebuild/compact/flush 后);
8
+ · 回退可观测:表缺失/代际不符/旧快照条目/no-condition ⇒ 回退第 1 层全量遍历并带
9
+ reason(**禁止静默**);
10
+ · 收敛可观测:开臂 `scanned`(遍历面)收窄、`index.size`/`full_nodes` 读数在场;
11
+ · 截断语义继承第 1/2 层(truncated/kept/近期优先逐位不变);
12
+ · `unassigned`(无会话节点)语义保持现状(只在跨会话视图可见);
13
+ · 条件资格首验(`MDCG_STG_QUALIFY`)**只上报不过滤**。
14
+
15
+ 断言分组:
16
+ G1 两臂等价(业务字段逐位 + 形态读数钉住,含 >5000 节点在役形态级合成库)
17
+ G2 维护不变量(写/删/层迁移/会话改写/rebuild/compact/flush 后表与快照一致)
18
+ G3 unassigned 语义回归
19
+ G4 截断语义继承
20
+ G5 meta 可观测(诸回退 reason + 表规模 + 自愈 + flag 关不构建)
21
+ G6 资格首验(只上报不过滤;同口径 = read 面单点)
22
+ G7 结构/单点(资格判据唯一调用点、_scan 无切片)
23
+
24
+ 判别力自证(`--branch-baseline`,与 `md_cg/test_issue52_scan_condition_first.py` 同口径):
25
+ · 逐处**定点变异**(源码级替换 + 运行期装配):每处必须**恰好**打红预期条数的
26
+ 断言(多一条少一条都报红);
27
+ · **空转断言检查**:任何断言若一处变异都打不红 ⇒ 直接判 FAIL(禁空转);
28
+ · **变异表完整性自检**(`_table_gaps`,先例 `test_autonomy_modes`):编号无缺口 +
29
+ 表长与显式声明一致——删条目(或注释与元组同删)即机械报错;
30
+ · 锚点漂移报 ANCHOR-MISS 且**退出码 2**(fail-closed,不静默失效);
31
+ · 基线源 = **运行时源码**(`inspect.getsource`),**禁止以 git HEAD 为基线源**
32
+ (基线绑提交即失效,本仓已有两次教训)。
33
+
34
+ 设计说明(为什么等价性判据同时钉形态读数):候选集是**超集**时结果不变(逐条精确
35
+ 判定仍在 `_scan` 侧),故「取数面没收窄/取错序」这类缺陷只改 `scanned` 与条目次序
36
+ ——只看两臂结果相等的断言抓不住它们。故每个用例的判据 = 「两臂业务字段逐位一致」
37
+ **且**「开臂形态读数 == 校准值(path/fallback/count/scanned/index_hit)」。
38
+
39
+ 运行:
40
+ python -X utf8 -m md_cg.test_stgidx_index_parity
41
+ python -X utf8 -m md_cg.test_stgidx_index_parity --branch-baseline
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import ast
46
+ import contextlib
47
+ import inspect
48
+ import io
49
+ import os
50
+ import re
51
+ import shutil
52
+ import sys
53
+ import tempfile
54
+
55
+ from . import mdcg as _mdcg
56
+ from . import stg as _stg
57
+ from . import stgidx as _stgidx
58
+ from .mdcos import MdCGOS, MdCGSecure
59
+ from .security import Principal
60
+
61
+ S = _stg # 被测 STG 模块(变异模式下 = 变异体命名空间)
62
+ _CUR_SRC = None # 变异模式下 stg 模块的**变异体源码**(G7 的 AST 判据取它)
63
+ INDEX_ENV = "MDCG_STG_INDEX"
64
+ QUALIFY_ENV = "MDCG_STG_QUALIFY"
65
+
66
+ _PASS = []
67
+ _FAIL = []
68
+
69
+
70
+ def ok(cond, label, extra=""):
71
+ (_PASS if cond else _FAIL).append(label)
72
+ print((" PASS " if cond else " FAIL ") + label +
73
+ ((" ← " + str(extra)[:240]) if (extra and not cond) else ""))
74
+
75
+
76
+ @contextlib.contextmanager
77
+ def _quiet():
78
+ with contextlib.redirect_stdout(io.StringIO()):
79
+ yield
80
+
81
+
82
+ @contextlib.contextmanager
83
+ def _env(**kv):
84
+ """临时设/清环境变量(两臂开关;退出即还原)。"""
85
+ saved = {}
86
+ try:
87
+ for k, v in kv.items():
88
+ saved[k] = os.environ.get(k)
89
+ if v is None:
90
+ os.environ.pop(k, None)
91
+ else:
92
+ os.environ[k] = v
93
+ yield
94
+ finally:
95
+ for k, v in saved.items():
96
+ if v is None:
97
+ os.environ.pop(k, None)
98
+ else:
99
+ os.environ[k] = v
100
+
101
+
102
+ @contextlib.contextmanager
103
+ def _clean_env():
104
+ with _env(**{INDEX_ENV: None, QUALIFY_ENV: None}):
105
+ yield
106
+
107
+
108
+ def _biz(body):
109
+ """业务字段面:除 `scanned`(遍历面,索引臂按设计收窄)与 `index` 读数外全部。"""
110
+ return {k: v for k, v in body.items() if k not in ("scanned", "index")}
111
+
112
+
113
+ def _ids(body):
114
+ return [i["id"] for i in (body.get("items") or [])]
115
+
116
+
117
+ def _two_arm(cg, fn):
118
+ """同一 cg 上跑关/开两臂;返回 (off_body, on_body)。
119
+
120
+ 开臂前把 `_stg_index` 置 None ⇒ **每轮从零构建**:夹具跨变异轮复用,若沿用
121
+ 上一轮缓存的表,「构建路径」的定点变异(by_time 排序键/分类口径)会被旧表
122
+ 遮住而判不出来。
123
+ """
124
+ with _clean_env():
125
+ cg._stg_index = None
126
+ off = fn(cg)
127
+ with _env(**{INDEX_ENV: "1"}):
128
+ cg._stg_index = None
129
+ on = fn(cg)
130
+ return off, on
131
+
132
+
133
+ def _verify(cg):
134
+ ix = getattr(cg, "_stg_index", None)
135
+ if ix is None:
136
+ return None
137
+ return _stgidx.verify(ix, (cg.index or {}).get("nodes") or {})
138
+
139
+
140
+ # ---------------------------------------------------------------- 合成库(系统临时目录)
141
+ # 硬边界:不动任何在役数据根——全部库建在 tempfile.mkdtemp 下,用完即删。
142
+
143
+ def _add(cg, nid, i, layer="knowledge", sess="s0", t=1000.0, window=None, **kw):
144
+ """统一入口:显式落 `condition_space.time_window`(否则 add 以**写入时刻**自动
145
+ 填充,库内容将随运行时刻漂移——等价性判据必须可复现)。"""
146
+ tw = [t, t + 10.0] if window is None else window
147
+ cg.add(nid, "# 功能名:节点 %d\n# 生效条件:本地合成库\n# 子功能:守卫夹具\n"
148
+ "# 执行:无\n# 验证方式:test\n# 不适用条件:无\n# 正文:节点 %d"
149
+ % (i, i),
150
+ layer=layer, temporal=t, session=sess,
151
+ condition_space={"time_window": tw,
152
+ "observation_position": "D%d" % (i % 3)},
153
+ **kw)
154
+
155
+
156
+ def _mk_big(root, name="big"):
157
+ """>5000 节点在役形态:多会话交错 + 无会话节点 + id 字典序与时间序错位 +
158
+ 目标会话在索引尾部 + 同刻并列。"""
159
+ cg = MdCGOS(os.path.join(root, name))
160
+ for i in range(5200):
161
+ nid = "k%05d" % i
162
+ sess = None if (i % 37 == 0) else ("s%d" % (i % 4))
163
+ t = 900000.0 - i # id 递增、时间递减(错位)
164
+ if i % 100 == 7: # 同刻并列
165
+ t = 900000.0 - (i - (i % 100))
166
+ _add(cg, nid, i, sess=sess, t=t)
167
+ for i in range(120):
168
+ _add(cg, "zz_t%03d" % i, i, sess="sess_target", t=900100.0 + i)
169
+ cg.flush()
170
+ return cg
171
+
172
+
173
+ def _mk_mixed(root, name="mixed"):
174
+ """层/无时间/无会话/倒置窗/NaN/并列/仅窗/资格面——小库。"""
175
+ cg = MdCGOS(os.path.join(root, name))
176
+ for i in range(3):
177
+ _add(cg, "anc_%d" % i, i, layer="anchor", sess="sess_L", t=3000.0 + i)
178
+ for i in range(2):
179
+ _add(cg, "kno_%d" % i, i, sess="sess_L", t=2000.0 + i,
180
+ spatial={"bbox": [1, 1, 9, 9]})
181
+ _add(cg, "non_sess_0", 0, sess=None, t=2500.0) # unassigned
182
+ _add(cg, "notime_0", 0, sess="sess_L", t=2600.0, spatial={"bbox": [1, 1, 9, 9]})
183
+ _add(cg, "inv_0", 0, sess="sess_L", t=2700.0, window=[9000.0, 8000.0])
184
+ _add(cg, "outside_0", 0, sess="sess_L", t=2800.0, window=[50000.0, 51000.0])
185
+ _add(cg, "nan_0", 0, sess="sess_L", t=2900.0)
186
+ _add(cg, "tie_0", 0, sess="sess_L", t=2950.0)
187
+ _add(cg, "tie_1", 1, sess="sess_L", t=2950.0)
188
+ # 仅有时窗、无 temporal 的节点:观察轴区间**走 time_window 回退**——
189
+ # `stgidx.time_of` 若只认 temporal,本条会在时间窗查询里**漏召回**。
190
+ cg.add("win_only_0",
191
+ "# 功能名:仅窗 0\n# 生效条件:本地合成库\n# 子功能:守卫夹具\n"
192
+ "# 执行:无\n# 验证方式:test\n# 不适用条件:无\n# 正文:仅窗节点",
193
+ layer="knowledge", session="sess_L",
194
+ condition_space={"time_window": [2100.0, 2110.0]})
195
+ # 资格面:本条的不适用条件命中 stg 情境(op 名 timeline)⇒ read 面判 REJECT;
196
+ # **只上报不过滤**的判据靠它取证(被打回也仍须出现在返回条目里)。
197
+ _add(cg, "rej_0", 0, sess="sess_L", t=2960.0,
198
+ non_applicable_conditions=["timeline"], verification_basis="test")
199
+ cg.flush()
200
+ cg.index["nodes"]["notime_0"].pop("temporal", None)
201
+ cg.index["nodes"]["notime_0"].pop("time_window", None)
202
+ cg.index["nodes"]["nan_0"]["temporal"] = float("nan")
203
+ return cg
204
+
205
+
206
+ def _mk_legacy(root, name="legacy"):
207
+ """旧快照形态:条目无 temporal/spatial 键 ⇒ `_scan` 走回退读文件分支。"""
208
+ cg = MdCGOS(os.path.join(root, name))
209
+ for i in range(6):
210
+ _add(cg, "a%02d" % i, i, sess="other-sess", t=1000.0 + i)
211
+ for i in range(2):
212
+ _add(cg, "zz_t_%d" % i, i, sess="target-sess", t=5000.0 + i)
213
+ cg.flush()
214
+ for e in list(cg.index["nodes"].values()):
215
+ e.pop("temporal", None)
216
+ e.pop("spatial", None)
217
+ return cg
218
+
219
+
220
+ def _mk_secure(root, name="secure"):
221
+ """可见性红线:3 条 internal(跨会话共享)+ 2 条 private(绑定 sess_A)。"""
222
+ root = os.path.join(root, name)
223
+ wr = Principal(actor="idxwr", clearance="private", can_write=True,
224
+ can_admin=False, role="designer", session="sess_A",
225
+ harness="test-harness")
226
+ w = MdCGSecure(root, principal=wr)
227
+ for i in range(3):
228
+ _add(w, "v_pub_%d" % i, i, sess="sess_A", t=4000.0 + i)
229
+ for i in range(2):
230
+ _add(w, "v_priv_%d" % i, i, sess="sess_A", t=4500.0 + i,
231
+ sensitivity="private")
232
+ w.flush()
233
+ rd = Principal(actor="idxrd", clearance="internal", can_write=False,
234
+ can_admin=False, role="designer", session="sess_B",
235
+ harness="test-harness")
236
+ return MdCGSecure(root, principal=rd)
237
+
238
+
239
+ class _Fixtures:
240
+ """一次构建、多轮复用(变异核验要重跑 N 轮)——库面只读,变异只改源码。"""
241
+
242
+ def __init__(self):
243
+ self.tmp = tempfile.mkdtemp(prefix="mdcg_stgidx_")
244
+ self.big = _mk_big(self.tmp)
245
+ self.mixed = _mk_mixed(self.tmp)
246
+ self.legacy = _mk_legacy(self.tmp)
247
+ self.secure = _mk_secure(self.tmp)
248
+ self._n = 0
249
+
250
+ def scratch(self, name=None):
251
+ """G2/G5 专用:每轮现建的小库(组内自毁自建,跨变异轮不串味)。"""
252
+ self._n += 1
253
+ return MdCGOS(os.path.join(self.tmp, name or ("g2_%d" % self._n)))
254
+
255
+ def drop(self, cg):
256
+ shutil.rmtree(os.path.join(self.tmp, os.path.basename(cg.root)),
257
+ ignore_errors=True)
258
+
259
+ def close(self):
260
+ shutil.rmtree(self.tmp, ignore_errors=True)
261
+
262
+
263
+ # 等价性用例面:(键, 标签, 库, 调用)。
264
+ # 预期形态(`_EXPECT`)=**在该确定性夹具上实测校准**的
265
+ # (path, fallback, count, scanned_on, index_hit):
266
+ # path = 开臂取数面(index=索引直取 / full=回退全量)
267
+ # count = 该接口的 count 读数(consistency 无 count ⇒ None)
268
+ # scanned_on = 开臂实际遍历/触碰数(收敛读数)
269
+ # index_hit = 经索引服务的条件维数
270
+ # 夹具改动(增删节点/改时刻)须同步本表——它是「收敛读数」判据的字面量。
271
+ _EXPECT = {
272
+ "A01": ("index", None, 120, 120, 1),
273
+ "A02": ("index", None, 1265, 1265, 1),
274
+ "A03": ("full", "no_condition_dimension", 5320, 5320, 0),
275
+ "A04": ("full", "no_condition_dimension", 5320, 5320, 0),
276
+ "A05": ("index", None, 0, 0, 1),
277
+ "A06": ("index", None, 120, 120, 1),
278
+ "A07": ("index", None, 120, 120, 1),
279
+ "A08": ("index", None, 120, 120, 1),
280
+ "A09": ("index", None, 51, 51, 1),
281
+ "A10": ("index", None, 1002, 1002, 2),
282
+ "A11": ("index", None, 1002, 1002, 1),
283
+ "A12": ("full", "no_condition_dimension", None, 5320, 0),
284
+ "A13": ("index", None, None, 11, 1),
285
+ "A14": ("index", None, 3, 3, 1),
286
+ "A15": ("index", None, 12, 13, 1),
287
+ "A16": ("index", None, 13, 13, 1),
288
+ "A17": ("full", "no_condition_dimension", 3, 14, 0),
289
+ "A18": ("full", "entry_file_read", 2, 8, 0),
290
+ "A19": ("full", "no_condition_dimension", None, 8, 0),
291
+ "A20": ("full", "no_condition_dimension", 3, 3, 0),
292
+ "A21": ("index", None, 3, 3, 1),
293
+ }
294
+
295
+
296
+ def _cases(fx):
297
+ stg = S
298
+ return [
299
+ ("A01", "timeline(session=目标会话·索引尾部)", fx.big,
300
+ lambda c: stg.timeline(c, session="sess_target", limit=50)),
301
+ ("A02", "timeline(session=多会话之一)", fx.big,
302
+ lambda c: stg.timeline(c, session="s1", limit=10)),
303
+ ("A03", "timeline(缺省跨会话)", fx.big, lambda c: stg.timeline(c, limit=10)),
304
+ ("A04", "timeline(session=\"*\")", fx.big,
305
+ lambda c: stg.timeline(c, session="*", limit=10)),
306
+ ("A05", "timeline(不存在的会话)", fx.big,
307
+ lambda c: stg.timeline(c, session="no-such", limit=10)),
308
+ ("A06", "timeline(截断 max_scan=7)", fx.big,
309
+ lambda c: stg.timeline(c, session="sess_target", max_scan=7, limit=5)),
310
+ ("A07", "timeline(asc)", fx.big,
311
+ lambda c: stg.timeline(c, session="sess_target", limit=5, desc=False)),
312
+ ("A08", "timeline(limit=0)", fx.big,
313
+ lambda c: stg.timeline(c, session="sess_target", limit=0)),
314
+ ("A09", "anchors(时间窗)", fx.big,
315
+ lambda c: stg.anchors(c, time_window=[899950.0, 900050.0], limit=10)),
316
+ ("A10", "anchors(时间窗+层)", fx.big,
317
+ lambda c: stg.anchors(c, time_window=[899000.0, 900050.0],
318
+ layer="knowledge", limit=3)),
319
+ ("A11", "anchors(时间窗·截断)", fx.big,
320
+ lambda c: stg.anchors(c, time_window=[899000.0, 900050.0], max_scan=5,
321
+ limit=3)),
322
+ ("A12", "consistency(全量)", fx.big, lambda c: stg.consistency(c, limit=10)),
323
+ ("A13", "consistency(层)", fx.mixed,
324
+ lambda c: stg.consistency(c, layer="knowledge", limit=20)),
325
+ ("A14", "timeline(层·混库)", fx.mixed,
326
+ lambda c: stg.timeline(c, layer="anchor", limit=20)),
327
+ ("A15", "timeline(会话·混库·含无会话节点)", fx.mixed,
328
+ lambda c: stg.timeline(c, session="sess_L", limit=20)),
329
+ ("A16", "anchors(时间窗·混库·NaN/并列/仅窗)", fx.mixed,
330
+ lambda c: stg.anchors(c, time_window=[1900.0, 3200.0], limit=20)),
331
+ ("A17", "anchors(仅 bbox)", fx.mixed,
332
+ lambda c: stg.anchors(c, bbox=[0, 0, 100, 100], limit=20)),
333
+ ("A18", "旧快照库(回退读文件分支·会话)", fx.legacy,
334
+ lambda c: stg.timeline(c, session="target-sess", limit=10)),
335
+ ("A19", "旧快照库(回退分支·一致性)", fx.legacy,
336
+ lambda c: stg.consistency(c, limit=10)),
337
+ ("A20", "Secure(可见性面)", fx.secure, lambda c: stg.timeline(c, limit=10)),
338
+ ("A21", "anchors(同刻并列窗 2950–2950)", fx.mixed,
339
+ lambda c: stg.anchors(c, time_window=[2950.0, 2950.0], limit=20)),
340
+ ]
341
+
342
+
343
+ # ---------------------------------------------------------------- G1 两臂等价
344
+ def g1_parity(fx):
345
+ print("== G1 等价性:关/开两臂业务字段逐位一致 + 形态读数钉住 ==")
346
+ for key, label, cg, fn in _cases(fx):
347
+ off, on = _two_arm(cg, fn)
348
+ ix = on.get("index") or {}
349
+ shape = (ix.get("path"), ix.get("fallback"), on.get("count"),
350
+ on.get("scanned"), ix.get("index_hit"))
351
+ ok(_biz(off) == _biz(on) and shape == _EXPECT[key],
352
+ "G1 %s %s" % (key, label),
353
+ {"biz_eq": _biz(off) == _biz(on), "shape": shape,
354
+ "want": _EXPECT[key]})
355
+ off, on = _two_arm(fx.big, lambda c: S.timeline(c, session="sess_target", limit=5))
356
+ ok("index" not in off,
357
+ "G1b 关臂不落 index 键(与第 1/2 层返回体逐位一致)", sorted(off))
358
+ ok(on.get("scanned") == 120 and off.get("scanned") == 5320,
359
+ "G1c 开臂遍历面收敛(120 < 全量 5320)",
360
+ (on.get("scanned"), off.get("scanned")))
361
+
362
+
363
+ # ---------------------------------------------------------------- G2 维护不变量
364
+ def g2_invariants(fx):
365
+ print("== G2 索引维护:写/删/迁移/rebuild/compact/flush 后表与快照一致 ==")
366
+ cg = fx.scratch()
367
+ for i in range(11):
368
+ _add(cg, "m%02d" % i, i, sess="s0" if i % 2 else None, t=1000.0 + i)
369
+ cg.flush()
370
+ seen = {"set": 0, "rem": 0}
371
+ live_set, live_rem = _mdcg.MdCG._set_index_entry, _mdcg.MdCG._remove_index_entry
372
+
373
+ def _spy_set(self, nid, entry):
374
+ seen["set"] += 1
375
+ return live_set(self, nid, entry)
376
+
377
+ def _spy_rem(self, nid):
378
+ seen["rem"] += 1
379
+ return live_rem(self, nid)
380
+
381
+ try:
382
+ with _env(**{INDEX_ENV: "1"}), \
383
+ contextlib.ExitStack() as _st:
384
+ _st.enter_context(_patch_attr(_mdcg.MdCG, "_set_index_entry", _spy_set))
385
+ _st.enter_context(_patch_attr(_mdcg.MdCG, "_remove_index_entry", _spy_rem))
386
+ S.timeline(cg, limit=1) # 首次访问 ⇒ 惰性构建
387
+ ix = cg._stg_index
388
+ ok(ix is not None and ix.index_obj is cg.index
389
+ and _verify(cg) == [],
390
+ "G2a 首次 stg 访问惰性构建(挂在活快照上),三表恒为快照划分",
391
+ _verify(cg))
392
+
393
+ _add(cg, "new_zz", 99, sess="s0", t=5000.0)
394
+ bad = _verify(cg)
395
+ body = S.timeline(cg, session="s0", limit=50)
396
+ ok(cg._stg_index is ix and not bad and seen["set"] >= 1,
397
+ "G2b 写路径同钩增量增(经 _set_index_entry;表未整表重建且仍为划分)",
398
+ (bad, seen))
399
+ ok(_ids(body)[0] == "new_zz"
400
+ and (body.get("index") or {}).get("path") == "index"
401
+ and body.get("scanned") == 6,
402
+ "G2c 增量增后的新节点在索引臂立即可见(子集=该会话 6 条)",
403
+ (_ids(body)[:3], body.get("index"), body.get("scanned")))
404
+
405
+ _add(cg, "m00", 0, layer="anchor", sess="sX", t=6000.0) # 层+会话双迁移
406
+ bad = _verify(cg)
407
+ ok(not bad and _ids(S.timeline(cg, layer="anchor", limit=50)) == ["m00"]
408
+ and "m00" not in _ids(S.timeline(cg, session="s0", limit=50)),
409
+ "G2d 覆写(层迁移+会话改写)后 by_layer/by_session 同步搬桶", bad)
410
+
411
+ cg._unstage("m01")
412
+ bad = _verify(cg)
413
+ ok(not bad and seen["rem"] >= 1
414
+ and "m01" not in _ids(S.timeline(cg, limit=50))
415
+ and len(ix.by_session.get("s0", [])) == 5,
416
+ "G2e 删除路径同钩增量摘(经 _remove_index_entry,表仍为划分)", bad)
417
+
418
+ cg.flush()
419
+ ok(cg._stg_index is ix and _verify(cg) == [],
420
+ "G2f flush 不动内存索引、不失效表(写读交替下不被反复重扫)",
421
+ (cg._stg_index is ix, _verify(cg)))
422
+
423
+ cg.rebuild_index()
424
+ stale_rb = cg._stg_index
425
+ S.timeline(cg, limit=1)
426
+ ok(stale_rb is None and cg._stg_index is not None and _verify(cg) == [],
427
+ "G2g rebuild_index 显式失效表并随后按新快照重建(划分不变量成立)",
428
+ (stale_rb, _verify(cg)))
429
+
430
+ cg.compact_index()
431
+ stale_cp = cg._stg_index
432
+ S.timeline(cg, limit=1)
433
+ ok(stale_cp is None and _verify(cg) == [],
434
+ "G2h compact_index 显式失效表并随后重建(划分不变量成立)",
435
+ (stale_cp, _verify(cg)))
436
+
437
+ off_a, on_a = _two_arm(cg, lambda c: S.timeline(c, session="s0", limit=50))
438
+ off_b, on_b = _two_arm(cg, lambda c: S.timeline(c, layer="anchor", limit=50))
439
+ ok(_biz(off_a) == _biz(on_a) and _biz(off_b) == _biz(on_b)
440
+ and off_a["count"] == 6 and off_b["count"] == 1
441
+ and (on_a.get("index") or {}).get("path") == "index",
442
+ "G2i 一轮增删迁移+rebuild+compact 后仍走直取且两臂逐位等价",
443
+ {"a": (off_a.get("count"), on_a.get("index")),
444
+ "b": (off_b.get("count"), on_b.get("index"))})
445
+ finally:
446
+ assert (_mdcg.MdCG._set_index_entry is live_set
447
+ and _mdcg.MdCG._remove_index_entry is live_rem)
448
+ fx.drop(cg)
449
+
450
+
451
+ @contextlib.contextmanager
452
+ def _patch_attr(owner, attr, value):
453
+ live = getattr(owner, attr)
454
+ setattr(owner, attr, value)
455
+ try:
456
+ yield
457
+ finally:
458
+ setattr(owner, attr, live)
459
+
460
+
461
+ # ---------------------------------------------------------------- G3 unassigned
462
+ def g3_unassigned(fx):
463
+ print("== G3 unassigned(无会话节点)语义保持现状 ==")
464
+ cg = fx.mixed
465
+ off_v, on_v = _two_arm(cg, lambda c: S.timeline(c, session="sess_L", limit=50))
466
+ off_x, on_x = _two_arm(cg, lambda c: S.timeline(c, session="*", limit=50))
467
+ with _env(**{INDEX_ENV: "1"}):
468
+ S.timeline(cg, limit=1)
469
+ bucket = sorted(cg._stg_index.by_session.get(None, []))
470
+ fb = ((S.timeline(cg, limit=1).get("index") or {}).get("fallback"))
471
+ ok(bucket == ["non_sess_0"] # None 桶归属
472
+ and "non_sess_0" not in _ids(on_v) # 具体会话视图不放大
473
+ and "non_sess_0" not in _ids(off_v)
474
+ and "non_sess_0" in _ids(on_x) # 跨会话视图可见
475
+ and _biz(off_v) == _biz(on_v) and _biz(off_x) == _biz(on_x)
476
+ and fb == "no_condition_dimension", # 跨会话无维可索引 ⇒ 如实回退
477
+ "G3 unassigned 四判据:None 桶归属 / 会话视图不放大 / 跨会话可见 / 如实回退",
478
+ (bucket, _ids(on_v), fb))
479
+
480
+
481
+ # ---------------------------------------------------------------- G4 截断继承
482
+ def g4_truncation(fx):
483
+ print("== G4 截断语义继承第 1/2 层 ==")
484
+ off, on = _two_arm(fx.big,
485
+ lambda c: S.timeline(c, session="sess_target", max_scan=4,
486
+ limit=3))
487
+ sigs = [inspect.signature(f).parameters["max_scan"].default
488
+ for f in (S.timeline, S.anchors, S.consistency)]
489
+ ok(off.get("truncated") is True and on.get("truncated") is True
490
+ and off.get("kept") == on.get("kept") == 4
491
+ and off.get("hint") == on.get("hint") == on.get("hint")
492
+ and _ids(off) == _ids(on) == ["zz_t119", "zz_t118", "zz_t117"]
493
+ and off.get("count") == on.get("count") == 120
494
+ and sigs == [5000, 5000, 5000],
495
+ "G4 截断继承:truncated/kept/hint/近期优先/count + max_scan 默认 5000 不动",
496
+ {"off": {k: off.get(k) for k in ("truncated", "kept", "count")},
497
+ "on": {k: on.get(k) for k in ("truncated", "kept", "count")},
498
+ "ids": _ids(on), "sigs": sigs})
499
+
500
+
501
+ # ---------------------------------------------------------------- G5 meta 可观测
502
+ def g5_meta(fx):
503
+ print("== G5 可观测:回退 reason / 表规模 / 自愈 / 关臂不建表 ==")
504
+ with _env(**{INDEX_ENV: "1"}):
505
+ cg = fx.scratch("g5")
506
+ for i in range(9):
507
+ _add(cg, "x%02d" % i, i, sess="s0", t=1000.0 + i)
508
+ for i in range(4): # 异会话填充:让 s0 成为**真子集**
509
+ _add(cg, "y%02d" % i, i, sess="sX", t=500.0 + i)
510
+ cg.flush()
511
+ S.timeline(cg, limit=1)
512
+ ix = cg._stg_index
513
+ ents = list(cg.index["nodes"].values())
514
+ want = {"nodes": len(ents),
515
+ "sessions": len({e.get("session") for e in ents}),
516
+ "layers": len({e.get("layer") for e in ents}),
517
+ "timed": sum(1 for e in ents if _stgidx.time_of(e) is not None),
518
+ "untimed": sum(1 for e in ents if _stgidx.time_of(e) is None),
519
+ "nonfinite": 0}
520
+ body = S.timeline(cg, session="s0", limit=1)
521
+ ok(ix.size() == want and _verify(cg) == []
522
+ and body["index"]["size"] == want
523
+ and body["index"]["full_nodes"] == len(ents),
524
+ "G5a 表规模读数 == 快照实况(size/full_nodes)且划分不变量成立",
525
+ (ix.size(), want, _verify(cg), body.get("index")))
526
+
527
+ # ① tables_missing:构建不可用
528
+ cg._stg_index = None
529
+ live_build = _stgidx.build
530
+ _stgidx.build = lambda *a, **k: None
531
+ try:
532
+ b = S.timeline(cg, session="s0", limit=1)
533
+ finally:
534
+ _stgidx.build = live_build
535
+ ok((b.get("index") or {}).get("fallback") == "tables_missing"
536
+ and b["index"]["path"] == "full" and b["index"]["index_miss"] == 1,
537
+ "G5b 表缺失 ⇒ 回退全量且 reason=tables_missing(禁止静默)",
538
+ b.get("index"))
539
+
540
+ # ② generation_mismatch:整体换过快照却没走失效点
541
+ with _clean_env():
542
+ base = S.timeline(cg, session="s0", limit=50)
543
+ S.timeline(cg, session="s0", limit=1) # 先构建
544
+ live_index = cg.index
545
+ cg.index = dict(live_index) # 身份变的同一内容快照
546
+ try:
547
+ b = S.timeline(cg, session="s0", limit=50)
548
+ ok((b.get("index") or {}).get("fallback") == "generation_mismatch"
549
+ and _biz(b) == _biz(base),
550
+ "G5c 代际不符 ⇒ 本次回退全量(结果与基线一致)且 reason 在场",
551
+ b.get("index"))
552
+ ok(cg._stg_index is None,
553
+ "G5d 代际不符当场丢弃陈旧表(下次访问自愈重建)", cg._stg_index)
554
+ # 自愈判据必须在**换回快照之前**测:陈旧表若没被丢弃,本调用仍会
555
+ # 判代际不符 ⇒ 永陷回退(这正是「不修复代」的形态)。
556
+ b2 = S.timeline(cg, session="s0", limit=50)
557
+ ok((b2.get("index") or {}).get("path") == "index"
558
+ and _biz(b2) == _biz(base),
559
+ "G5e 自愈:下一访问重建后回到索引臂且结果不变", b2.get("index"))
560
+ finally:
561
+ cg.index = live_index
562
+
563
+ # ③ entry_file_read:迭代期旧快照条目(`_scan` 走回退读文件)
564
+ S.timeline(cg, limit=1)
565
+ cg.index["nodes"]["x00"].pop("temporal", None)
566
+ cg.index["nodes"]["x00"].pop("spatial", None)
567
+ try:
568
+ b = S.timeline(cg, session="s0", limit=50)
569
+ ok((b.get("index") or {}).get("fallback") == "entry_file_read",
570
+ "G5f 旧快照条目(须读文件才知会话/时间)⇒ 回退且 reason 在场",
571
+ b.get("index"))
572
+ finally:
573
+ cg.index["nodes"]["x00"]["temporal"] = None
574
+ cg.index["nodes"]["x00"]["spatial"] = None
575
+ cg._stg_index = None
576
+
577
+ # ④⑤⑥ no_condition_dimension / inverted_query_window / time_axis_not_indexed
578
+ b = S.timeline(cg, limit=1) # 跨会话且无层 ⇒ 无维可索引
579
+ ok((b.get("index") or {}).get("fallback") == "no_condition_dimension",
580
+ "G5g 无条件下可索引 ⇒ 回退且 reason=no_condition_dimension",
581
+ b.get("index"))
582
+ b = S.anchors(cg, time_window=[9000.0, 8000.0], limit=1)
583
+ ok((b.get("index") or {}).get("fallback") == "inverted_query_window",
584
+ "G5h 倒置查询窗 ⇒ 不索引时间维(预筛会漏 equals)且 reason 在场",
585
+ b.get("index"))
586
+ b = S.anchors(cg, time_window=[1000.0, 2000.0], time_axis="effective",
587
+ limit=1)
588
+ ok((b.get("index") or {}).get("fallback") == "time_axis_not_indexed",
589
+ "G5i 非观察轴 ⇒ 时间维不索引(表按观察轴建)且 reason 在场",
590
+ b.get("index"))
591
+
592
+ # 关臂:不构建、不落键
593
+ cg2 = fx.scratch("g5_off")
594
+ _add(cg2, "y0", 0, sess="s0", t=1.0)
595
+ cg2.flush()
596
+ with _clean_env():
597
+ b = S.timeline(cg2, session="s0", limit=1)
598
+ ok(cg2._stg_index is None and "index" not in b,
599
+ "G5j flag 默认关:不构建表、不落 index 键(零成本默认面)",
600
+ (cg2._stg_index, sorted(b)))
601
+ fx.drop(cg)
602
+ fx.drop(cg2)
603
+
604
+
605
+ # ---------------------------------------------------------------- G6 资格首验
606
+ def g6_qualification(fx):
607
+ print("== G6 条件资格首验:只上报不过滤(read 面同口径) ==")
608
+ cg = fx.mixed
609
+ with _env(**{QUALIFY_ENV: None}):
610
+ off = S.timeline(cg, session="sess_L", limit=50)
611
+ with _env(**{QUALIFY_ENV: "1"}):
612
+ on = S.timeline(cg, session="sess_L", limit=50)
613
+ ok(all("qualification" not in it for it in off["items"])
614
+ and all("qualification" in it for it in on["items"]),
615
+ "G6a 开关面:关不带 qualification(默认面零变化)、开逐条带出",
616
+ ([it.get("qualification") for it in off["items"]][:1],
617
+ [it.get("qualification") for it in on["items"]][:1]))
618
+ states = {it["id"]: (it.get("qualification") or {}).get("state")
619
+ for it in on["items"]}
620
+ ok(set(states.values()) <= {"ACCEPT", "REJECT", "DEFER", "BLINDSPOT"}
621
+ and states.get("rej_0") == "REJECT",
622
+ "G6b 资格态取自 read 面四态(含夹具设计的 REJECT 条目)", states)
623
+ ok(_ids(off) == _ids(on) and off["count"] == on["count"]
624
+ and "rej_0" in _ids(on),
625
+ "G6c **只上报不过滤**:被打回条目仍在返回体里、条目集与关臂逐位一致",
626
+ (states.get("rej_0"), _ids(off) == _ids(on)))
627
+ stripped = [{k: v for k, v in it.items() if k != "qualification"}
628
+ for it in on["items"]]
629
+ ok(stripped == off["items"] and
630
+ {k: v for k, v in on.items() if k != "items"} ==
631
+ {k: v for k, v in off.items() if k != "items"},
632
+ "G6d 摘掉 qualification 后返回体与关臂逐位一致(行为零变化)", None)
633
+ from .mdcg import MdCG
634
+ exp = {}
635
+ for it in on["items"]:
636
+ n = cg.get(it["id"])
637
+ exp[it["id"]] = MdCG.judge_qualification(
638
+ {"frontmatter": n["frontmatter"], "content": n["content"]}, "",
639
+ {"stg": "timeline", "session": "sess_L", "layer": None,
640
+ "time_axis": "observed"})
641
+ got = {it["id"]: it.get("qualification") for it in on["items"]}
642
+ ok(got == exp, "G6e 与 read 面单点 judge_qualification 逐条同口径(未另写判据)",
643
+ {k: (got.get(k), exp.get(k)) for k in list(exp)[:2]})
644
+
645
+
646
+ # ---------------------------------------------------------------- G7 结构/单点
647
+ def g7_structure(fx):
648
+ print("== G7 结构:资格判据唯一调用点 / _scan 无切片 ==")
649
+ src = _CUR_SRC if _CUR_SRC is not None else inspect.getsource(_stg)
650
+ calls = [n for n in ast.walk(ast.parse(src))
651
+ if isinstance(n, ast.Call)
652
+ and getattr(n.func, "attr", None) == "judge_qualification"]
653
+ ok(len(calls) == 1,
654
+ "G7a stg 侧 judge_qualification 恰一处调用(不另写第二套资格判据)",
655
+ len(calls))
656
+ fn = None
657
+ for node in ast.parse(src).body:
658
+ if isinstance(node, ast.FunctionDef) and node.name == "_scan":
659
+ fn = node
660
+ break
661
+ slices = [] if fn is None else [n for n in ast.walk(fn)
662
+ if isinstance(n, ast.Subscript)
663
+ and isinstance(n.slice, ast.Slice)]
664
+ ok(fn is not None and not slices,
665
+ "G7b _scan 内无切片子表达式(第 1/2 层条件先行红线不因索引化回退)",
666
+ (fn is None, [getattr(s, "lineno", "?") for s in slices]))
667
+
668
+
669
+ _GROUPS = (g1_parity, g2_invariants, g3_unassigned, g4_truncation, g5_meta,
670
+ g6_qualification, g7_structure)
671
+
672
+
673
+ def _run_groups(fx) -> list:
674
+ """跑全部分组,返回失败标签列表(静默模式复用;单组异常记为 EXC 标签)。"""
675
+ _PASS.clear()
676
+ _FAIL.clear()
677
+ for g in _GROUPS:
678
+ try:
679
+ g(fx)
680
+ except Exception as e: # noqa: BLE001 变异可能炸——记标签不中断
681
+ label = "EXC:%s:%s" % (g.__name__, type(e).__name__)
682
+ _FAIL.append(label)
683
+ print(" FAIL " + label + " ← " + str(e)[:160])
684
+ return list(_FAIL)
685
+
686
+
687
+ # ---------------------------------------------------------------- 定点变异表
688
+ # 每条 = (名字, 属主, 属性名, 旧源码, 新源码, 预期打红条数)。
689
+ # 锚点缺失 ⇒ ANCHOR-MISS + 退出码 2(fail-closed);预期条数不符 ⇒ 报红(「恰好」语义)。
690
+ # 编号 ①–⑰ 由 `_table_gaps` 机械核对(防误删:删条目即报缺口)。
691
+ _SRC_MUTATIONS = (
692
+ # ① 写路径不再增量维护 ⇒ 表与快照不一致(安全网随即回退,但 G2 组断言必红)
693
+ ("①写钩增量维护摘除(表与快照不一致)", _mdcg.MdCG, "_set_index_entry",
694
+ " ix = getattr(self, \"_stg_index\", None)\n"
695
+ " if ix is not None:\n"
696
+ " ix.add(node_id, entry)\n",
697
+ "", 3),
698
+
699
+ # ② 删除路径不摘 ⇒ 已删条目留在表里(幽灵召回、划分不成立)
700
+ ("②删除钩摘除(删后表与快照不一致)", _mdcg.MdCG, "_remove_index_entry",
701
+ " ix = getattr(self, \"_stg_index\", None)\n"
702
+ " if ix is not None:\n"
703
+ " ix.remove(node_id)\n",
704
+ "", 2),
705
+
706
+ # ③ 索引臂顺手加限额切片 ⇒ 第 1/2 层红线在索引面回退(G7b 的 AST 判据当场红)
707
+ ("③索引臂加限额切片(条件先行红线回退)", _stg, "_scan",
708
+ " for nid, e in list(nodes):\n",
709
+ " for nid, e in list(nodes)[:5000]:\n", 1),
710
+
711
+ # ④ 时间维取数不回排物理序 ⇒ 基线候选序无法复现(条目次序变)
712
+ ("④取数序错(时间维不回排物理序)", _stgidx.StgIndex, "subset",
713
+ " if dims[0] == \"time\":\n"
714
+ " ids = sorted(ids, key=self.pos.__getitem__)\n",
715
+ " if False:\n"
716
+ " ids = sorted(ids, key=self.pos.__getitem__)\n", 3),
717
+
718
+ # ⑤ unassigned 桶被混进具体会话视图(会话语义放大)——契约 §待签收点 4 红线
719
+ ("⑤unassigned 桶混进具体会话视图", _stgidx.StgIndex, "subset",
720
+ " srcs.append(self.by_session.get(session, []))\n",
721
+ " srcs.append(list(self.by_session.get(session, []))\n"
722
+ " + list(self.by_session.get(None, []) or []))\n", 9),
723
+
724
+ # ⑥ 时间窗上界漏并列(hi 处同刻节点被排除)⇒ 边界命中漏召回(与基线不等价)
725
+ ("⑥时间窗上界漏并列(边界命中漏召回)", _stgidx.StgIndex, "range_ids",
726
+ " while b < len(self.by_time) and self.by_time[b][0] == hi:\n"
727
+ " b += 1\n",
728
+ "", 1),
729
+
730
+ # ⑦ by_time 排序键错(乱序)⇒ 区间二分取不到该取的面
731
+ ("⑦by_time 排序键错(乱序)", _stgidx.StgIndex, "_classify",
732
+ " bisect.insort(self.by_time, (t, nid))\n",
733
+ " bisect.insort(self.by_time, (-t, nid))\n", 8),
734
+
735
+ # ⑧ 索引直取不收窄(子集=全量)⇒ 收敛读数失真(等价性判据的形态面必红)
736
+ ("⑧索引直取不收窄(子集=全量)", _stg, "_index_pairs",
737
+ " pairs = []\n for nid in ids:\n",
738
+ " pairs = []\n for nid in list(nodes):\n", 14),
739
+
740
+ # ⑨ 回退路径静默无 reason ⇒「表缺失/代际不符」不可区分(禁止静默)
741
+ ("⑨回退路径静默无 reason", _stg, "_index_meta_full",
742
+ " \"index_miss\": want, \"fallback\": reason, \"full_nodes\": full,\n",
743
+ " \"index_miss\": want, \"fallback\": None, \"full_nodes\": full,\n", 14),
744
+
745
+ # ⑩ 资格被当过滤生效(打回条目不入返回体)——契约 §3.4 红线
746
+ ("⑩资格被当过滤生效(REJECT 条目被剔除)", _stg, "_attach_qualification",
747
+ " for it in items:\n"
748
+ " nid = it.get(\"id\")\n",
749
+ " _keep = []\n"
750
+ " for it in items:\n"
751
+ " _nd = _qual_node_dict(cg, it.get(\"id\"))\n"
752
+ " if _nd is not None and MdCG.judge_qualification(\n"
753
+ " _nd, query, context).get(\"state\") == \"REJECT\":\n"
754
+ " continue\n"
755
+ " _keep.append(it)\n"
756
+ " items = _keep\n"
757
+ " for it in items:\n"
758
+ " nid = it.get(\"id\")\n", 4),
759
+
760
+ # ⑪ 资格上报面摘除(flag 开了也不带 qualification)
761
+ ("⑪资格上报面摘除(flag 开也不带 qualification)", _stg, "_attach_qualification",
762
+ " if not items or not _flag_on(_QUALIFY_ENV):\n return items\n",
763
+ " return items\n", 3),
764
+
765
+ # ⑫ 默认关失效(关臂也建表/落 meta)——契约 §4「先 flag 化」红线
766
+ ("⑫默认关失效(关臂也建表/落 meta)", _stg, "_index_pairs",
767
+ " if not _flag_on(_INDEX_ENV):\n return None, None\n",
768
+ "", 3),
769
+
770
+ # ⑬ 代际不符不丢陈旧表 ⇒ 永久陷在回退(不修复代)
771
+ ("⑬代际不符不丢陈旧表(永陷回退)", _stg, "_index_bundle",
772
+ " cg._stg_index = None\n"
773
+ " return None, \"generation_mismatch\"\n",
774
+ " return None, \"generation_mismatch\"\n", 2),
775
+
776
+ # ⑭ rebuild/compact 不失效表(陈旧表被继续取数)
777
+ ("⑭rebuild/compact 不失效表", _mdcg.MdCG, "_invalidate_stg_index",
778
+ " \"\"\"第 3 层结构索引的失效单点(整体换索引的三处编排点都调它)。\"\"\"\n"
779
+ " self._stg_index = None\n",
780
+ " \"\"\"第 3 层结构索引的失效单点(整体换索引的三处编排点都调它)。\"\"\"\n"
781
+ " pass\n", 1),
782
+
783
+ # ⑯ 层维取数失效(by_layer 查不到即塌成空候选 ⇒ 层条件漏召回)
784
+ ("⑯层维取数失效(候选面塌成空)", _stgidx.StgIndex, "subset",
785
+ " srcs.append(self.by_layer.get(layer, []))\n",
786
+ " srcs.append([])\n", 5),
787
+
788
+ # ⑰ 分桶键取错字段(by_layer 按 session 建 ⇒ 划分键与条目口径不符)
789
+ ("⑰分桶键取错字段(划分键与条目口径不符)", _stgidx.StgIndex, "_classify",
790
+ " self.by_layer.setdefault(layer, []).append(nid)\n",
791
+ " self.by_layer.setdefault(sid, []).append(nid)\n", 12),
792
+
793
+ # ⑮ 截断选序回退为字典序 ⇒ 近期优先失效(第 1/2 层语义被改)
794
+ ("⑮截断选序改回字典序(近期优先失效)", _stg, "_cap_hits",
795
+ " return sorted(hits, key=recency_key, reverse=True)[:cap], True\n",
796
+ " return sorted(hits, key=str)[:cap], True\n", 1),
797
+
798
+ )
799
+
800
+
801
+ #: 表内编号(`_table_gaps` 的判据面:编号无缺口 + 表长与声明一致)
802
+ _MUTATION_IDS = ("①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩",
803
+ "⑪", "⑫", "⑬", "⑭", "⑮", "⑯", "⑰")
804
+
805
+ _ANNOT_RE = re.compile(r"^ # ([①-⑳㉑-㉔])")
806
+
807
+
808
+ def _table_lines(text):
809
+ """取 `_SRC_MUTATIONS = (` 到首个独立 `)` 行之间的表体行(当前工作区源)。"""
810
+ out, on = [], False
811
+ for line in text.split("\n"):
812
+ if not on:
813
+ if line.startswith("_SRC_MUTATIONS = ("):
814
+ on = True
815
+ continue
816
+ if line.rstrip() == ")":
817
+ break
818
+ out.append(line)
819
+ return out
820
+
821
+
822
+ def _table_gaps(text, entries_count, ids=_MUTATION_IDS):
823
+ """变异表完整性判定(纯函数):返回缺口说明列表(空 = 完好)。
824
+
825
+ 判据(先例 `test_autonomy_modes._table_gaps`):
826
+ a. 编号注释被下一**不同**编号覆盖(删元组留注释)⇒ 报该编号;
827
+ b. 编号序列与声明不一致(缺/重复,含注释与元组同删)⇒ 报缺者;
828
+ c. len(_SRC_MUTATIONS) ≠ 声明长度 ⇒ 报「len:n≠m」兜底。
829
+ """
830
+ annot, gaps, pending = [], [], None
831
+ for line in _table_lines(text):
832
+ m = _ANNOT_RE.match(line)
833
+ if m:
834
+ nid = m.group(1)
835
+ if pending is None:
836
+ pending = nid
837
+ annot.append(nid)
838
+ elif nid != pending:
839
+ gaps.append(pending)
840
+ pending = nid
841
+ annot.append(nid)
842
+ continue
843
+ if pending is not None and line.startswith(" ("):
844
+ pending = None
845
+ if pending is not None:
846
+ gaps.append(pending)
847
+ for i in ids:
848
+ if i not in annot:
849
+ gaps.append(i)
850
+ elif annot.count(i) > 1:
851
+ gaps.append(i)
852
+ if entries_count != len(ids):
853
+ gaps.append("len:%d≠%d" % (entries_count, len(ids)))
854
+ seen, uniq = set(), []
855
+ for g in gaps:
856
+ if g not in seen:
857
+ seen.add(g)
858
+ uniq.append(g)
859
+ return uniq
860
+
861
+
862
+ def _table_integrity_check():
863
+ with io.open(os.path.abspath(__file__), encoding="utf-8") as f:
864
+ text = f.read()
865
+ return _table_gaps(text, len(_SRC_MUTATIONS))
866
+
867
+
868
+ def _strip_indent(text, n):
869
+ if not n:
870
+ return text
871
+ out = []
872
+ for line in text.split("\n"):
873
+ out.append(line[n:] if line[:n].strip() == "" else line.lstrip())
874
+ return "\n".join(out)
875
+
876
+
877
+ def _selfcheck_judge() -> bool:
878
+ """`_table_gaps` 自身的判别力自证(合成源,先例 `test_autonomy_modes` F1)。
879
+
880
+ 只验「删条目/缺口 ⇒ 机械报错」不是空转,不占表内编号:
881
+ ① 删元组留注释(条目注释未被元组消费)⇒ 报该编号;
882
+ ② 注释与元组同删 ⇒ 报缺失编号;
883
+ ③ 表长与声明不符 ⇒ 报 len 兜底;
884
+ ④ 真实表 ⇒ 无缺口(完好判据不误报)。
885
+ """
886
+ ids = ("①", "②", "③")
887
+ # ① 删元组留注释:编号注释直接相邻 ⇒ 前一编号未被子元组消费
888
+ drop_tuple = "x\n_SRC_MUTATIONS = (\n # ①\n # ③\n (\n)\n"
889
+ # ② 注释与元组同删:表体只剩元组行 ⇒ 三个编号全缺
890
+ drop_both = "x\n_SRC_MUTATIONS = (\n (\n)\n"
891
+ with io.open(os.path.abspath(__file__), encoding="utf-8") as f:
892
+ real = f.read()
893
+ n_real = len(_SRC_MUTATIONS)
894
+ return (_table_gaps(drop_tuple, 2, ids) == ["①", "②", "len:2≠3"]
895
+ and _table_gaps(drop_both, 2, ids) == ["①", "②", "③", "len:2≠3"]
896
+ and _table_gaps(real, n_real - 1) == ["len:%d≠%d" % (n_real - 1, n_real)]
897
+ and _table_gaps(real, n_real) == [])
898
+
899
+
900
+ @contextlib.contextmanager
901
+ def _patched(owner, attr, old, new):
902
+ """按字面替换变异目标函数并安装/还原(不落盘、不改源文件)。
903
+
904
+ 属主是模块(stg/stgidx)时在模块命名空间里 exec;是类时在**其模块**命名空间
905
+ 里 exec 后 setattr 到类上(本仓先例:`test_autonomy_modes._patched`)。
906
+ """
907
+ live = getattr(owner, attr)
908
+ src = inspect.getsource(live)
909
+ cut = len(src) - len(src.lstrip(" "))
910
+ src2 = _strip_indent(src, cut)
911
+ old2 = _strip_indent(old, cut)
912
+ new2 = _strip_indent(new, cut)
913
+ if old2 not in src2:
914
+ raise AssertionError("ANCHOR-MISS:%s" % attr)
915
+ if isinstance(owner, type):
916
+ mod = sys.modules[owner.__module__]
917
+ ns = dict(vars(mod))
918
+ ns["__name__"] = mod.__name__
919
+ else:
920
+ ns = dict(vars(owner))
921
+ ns["__name__"] = owner.__name__
922
+ global _CUR_SRC
923
+ mutated = src2.replace(old2, new2)
924
+ exec(compile(mutated, "stgidx_mut.py", "exec"), ns) # noqa: S102
925
+ setattr(owner, attr, ns[attr])
926
+ # 变异体源码留痕:G7 的 AST 判据(judge_qualification 调用点数 / _scan 无切片)
927
+ # 必须读**当前生效的源码**,否则「模块文件未改但属性被换」的变异对它不可见。
928
+ if owner is _stg and cut == 0:
929
+ # 模块级函数:把变异体贴回**整模块源码**——G7 的 AST 判据解析的是模块
930
+ # 源码,只给函数源码会让「判据只看函数体」的断言失真(③ 会连坐 G7a)。
931
+ _CUR_SRC = inspect.getsource(_stg).replace(src, mutated)
932
+ else:
933
+ _CUR_SRC = None
934
+ try:
935
+ yield
936
+ finally:
937
+ setattr(owner, attr, live)
938
+ _CUR_SRC = None
939
+
940
+
941
+ def _anchor_check():
942
+ """全部变异锚点在**运行时源码**上在位(缺一即 ANCHOR-MISS)。"""
943
+ bad = []
944
+ for name, owner, attr, old, _new, _n in _SRC_MUTATIONS:
945
+ live = getattr(owner, attr, None)
946
+ if live is None:
947
+ bad.append("变异目标缺失:%s.%s" % (getattr(owner, "__name__", owner),
948
+ attr))
949
+ continue
950
+ src = inspect.getsource(live)
951
+ cut = len(src) - len(src.lstrip(" "))
952
+ if _strip_indent(old, cut) not in _strip_indent(src, cut):
953
+ bad.append("变异锚点漂移:%s.%s ← %r"
954
+ % (getattr(owner, "__name__", owner), attr, old[:32]))
955
+ return bad
956
+
957
+
958
+ def _branch_baseline(fx) -> int:
959
+ print("!! 定点变异自证:逐处变异,断言须**恰好**打红预期条数;锚点漂移=ANCHOR-MISS+退出码 2\n")
960
+ if not _selfcheck_judge():
961
+ print(" 防误删自检**自身**判别力不足(合成源未按预期报缺口)⇒ FAIL(fail-closed)")
962
+ return 2
963
+ gap_bad = _table_integrity_check()
964
+ anchor_bad = _anchor_check()
965
+ if gap_bad or anchor_bad:
966
+ for g in gap_bad:
967
+ print(" 变异表缺口:" + g)
968
+ for a in anchor_bad:
969
+ print(" ANCHOR-MISS " + a)
970
+ print("\n锚点/表完整性自检:FAIL(fail-closed)")
971
+ return 2
972
+ with _quiet():
973
+ clean_red = _run_groups(fx)
974
+ all_labels = set(_PASS) | set(_FAIL)
975
+ if clean_red:
976
+ print(" 未变异基线即失败:%s" % clean_red)
977
+ return 1
978
+ print(" 未变异基线:%d 条断言全绿(表内 %d 处变异,编号 %s–%s)"
979
+ % (len(all_labels), len(_SRC_MUTATIONS), _MUTATION_IDS[0],
980
+ _MUTATION_IDS[-1]))
981
+ reddened = set()
982
+ bad = []
983
+ for name, owner, attr, old, new, expect in _SRC_MUTATIONS:
984
+ try:
985
+ with _quiet(), _patched(owner, attr, old, new):
986
+ reds = _run_groups(fx)
987
+ except AssertionError as e:
988
+ print(" ANCHOR-MISS %s ← %s" % (name, e))
989
+ return 2
990
+ reddened |= set(reds)
991
+ hit = len(reds) == expect
992
+ print(" 变异「%s」→ 打红 %d 条(预期 %d)%s"
993
+ % (name, len(reds), expect, "OK" if hit else "**不符**"))
994
+ if not hit:
995
+ bad.append("%s:红 %d ≠ 预期 %d" % (name, len(reds), expect))
996
+ for r in sorted(reds):
997
+ print(" red: " + r)
998
+ never = sorted(all_labels - reddened)
999
+ if never:
1000
+ print(" **空转断言(无任何变异能打红)**:%s" % "、".join(never))
1001
+ bad.append("空转断言 %d 条" % len(never))
1002
+ print("\n判别力自证:%s" % ("PASS(十七处变异各自恰好命中,且无空转断言)"
1003
+ if not bad else "FAIL —— " + ";".join(bad)))
1004
+ return 0 if not bad else 1
1005
+
1006
+
1007
+ def main() -> int:
1008
+ fx = _Fixtures()
1009
+ try:
1010
+ if "--branch-baseline" in sys.argv:
1011
+ return _branch_baseline(fx)
1012
+ _run_groups(fx)
1013
+ finally:
1014
+ fx.close()
1015
+ print("\nstgidx 守卫:%d 通过,%d 失败" % (len(_PASS), len(_FAIL)))
1016
+ return 0 if not _FAIL else 1
1017
+
1018
+
1019
+ if __name__ == "__main__":
1020
+ sys.exit(main())