@furongjun1999/dsh-memory 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +632 -630
  2. package/codebuddy/CODEBUDDY.md +5 -5
  3. package/docs/discipline/templates/zcode-user.md.tmpl +4 -1
  4. 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
  5. package/docs/eval//345/217/221/345/270/20312_/344/270/226/347/225/214/346/250/241/345/236/213/345/212/237/350/203/275/347/253/257_v1.0.md +1 -1
  6. 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 +114 -0
  7. package/docs/eval//345/217/221/345/270/20314_/350/272/253/344/275/223/303/227/350/204/221/347/273/204/345/220/210_v1.0.md +152 -0
  8. 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
  9. 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
  10. 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
  11. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +14 -3
  12. 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 +85 -1
  13. 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 +119 -0
  14. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +461 -461
  15. package/docs//350/256/260/345/277/206/347/263/273/347/273/237/345/215/263/347/231/275/347/256/261/345/214/226/346/231/272/350/203/275_/346/236/266/346/236/204/345/257/271/347/205/247_v0.2.md +121 -0
  16. package/docs//350/256/260/345/277/206/347/263/273/347/273/237/345/215/263/347/231/275/347/256/261/345/214/226/346/231/272/350/203/275_/346/236/266/346/236/204/345/257/271/347/205/247_v0.3.md +117 -0
  17. package/lib/hooks.d.ts +34 -0
  18. package/lib/hooks.js +165 -0
  19. package/lib/index.js +22 -1
  20. package/lib/lib/mdcg_client.d.ts +37 -0
  21. package/lib/lib/mdcg_client.js +44 -0
  22. package/md_cg/audit.py +7 -1
  23. package/md_cg/bench_governance.py +4 -1
  24. package/md_cg/bench_locomo_zh.py +12 -2
  25. package/md_cg/bench_zh_mad.py +12 -2
  26. package/md_cg/chain.py +23 -7
  27. package/md_cg/consolidate.py +81 -10
  28. package/md_cg/interop.py +25 -9
  29. package/md_cg/lifecycle.py +7 -0
  30. package/md_cg/mcp_server.py +22 -0
  31. package/md_cg/mdcg.py +6 -1
  32. package/md_cg/scrub.py +4 -2
  33. package/md_cg/sleep.py +273 -103
  34. package/md_cg/sustain.py +150 -4
  35. package/md_cg/test_lifecycle_retire_leak.py +90 -0
  36. package/md_cg/test_p6_consolidate.py +389 -7
  37. package/md_cg/test_sleep_gitlock.py +134 -0
  38. package/md_cg/test_spatial_coords3d.py +292 -0
  39. package/md_cg/test_sustain_bounded.py +624 -0
  40. package/md_cg/units.py +4 -1
  41. package/package.json +2 -2
  42. package/skills/plugin.json +1 -1
  43. package/src/hooks.ts +177 -0
  44. package/src/index.ts +22 -1
  45. package/src/lib/mdcg_client.ts +48 -0
  46. package/zcode/AGENTS.md +5 -5
package/md_cg/sustain.py CHANGED
@@ -71,6 +71,17 @@ DEFAULT_SCRUB_INTERVAL = 3600.0 # 记忆自净(抽查/去污染/校准)1h
71
71
  DEFAULT_EVOLVE_INTERVAL = 7200.0 # 演化巡检(固化/重要性候选盘点)2h;只读
72
72
  DEFAULT_TIDY_INTERVAL = 21600.0 # 整理巡检(contextual 同构组聚合)6h
73
73
 
74
+ #: 六档 tick 的(档名 → interval 属性名)映射——进度面与 stale 阈值的取值面,
75
+ #: 顺序即 `SustainLoop._run()` 的执行序(单一真源:改档名/加点只改这里)。
76
+ TICK_INTERVAL_ATTRS = (("beat", "beat_interval"), ("heal", "heal_interval"),
77
+ ("scrub", "scrub_interval"), ("evolve", "evolve_interval"),
78
+ ("tidy", "tidy_interval"), ("sleep", "sleep_interval"))
79
+ #: 活体进度面的 stale 阈值下限(秒)——issue #63:某档 tick 运行超过
80
+ #: `max(2×该档 interval, TICK_STALE_MIN_S)` 时,状态面显式给 `stale_tick`
81
+ #: 告警。**只上报,不杀线程**(线程不可安全强杀;处置=人工重启常驻进程,
82
+ #: 见 `docs/mdcg/睡眠周期_运维前提与维护指南_v1.0.md`)。
83
+ TICK_STALE_MIN_S = 1800.0
84
+
74
85
  # ---- 四档 `auto_*` 缺省的**单一真源**(P0-2,2026-10-01)--------------------
75
86
  # 为什么要有这张表:同一组缺省此前在**三处**各写一份——op 路径
76
87
  # (mcp_server.py 的 `_sustain_call` start 分支)、env 路径(`_start_sustain`)
@@ -1077,6 +1088,13 @@ class SustainLoop:
1077
1088
  self.tidys = []
1078
1089
  # 数据健康不变量断言集结论(Pi⑤):与 tidy 同节奏,只取结论不落盘
1079
1090
  self.last_conformance = None
1091
+ # 活体进度面(issue #63):当前档 / 上一档完成 / 上一档错误——
1092
+ # 写点 = 既有心跳戳(见 `_progress_fields` / `_flush_progress`),
1093
+ # **不造第二套状态文件**。
1094
+ self.current_tick = None
1095
+ self.last_tick_done = None
1096
+ self.last_tick_error = None
1097
+ self._ticks_wrapped = False # 六档进度面包装的幂等标记
1080
1098
  self._started_at = None
1081
1099
  self._th = None
1082
1100
  self._stop = threading.Event()
@@ -1084,18 +1102,126 @@ class SustainLoop:
1084
1102
 
1085
1103
  # ---- 心跳 ----
1086
1104
 
1087
- # 生效条件:task_running 非 None 时先置 self.task_running=bool(task_running),再 write_stamp(self.name, self.d, task_running=self.task_running, root=self.cg.root, uptime=... if self._started_at else 0.0),beats 自增并记 last_beat=rec["ts"],返回该 rec;
1105
+ # 生效条件:task_running 非 None 时先置 self.task_running=bool(task_running),再 write_stamp(self.name, self.d, task_running=self.task_running, root=self.cg.root, uptime=... if self._started_at else 0.0, **进度三字段),beats 自增并记 last_beat=rec["ts"],返回该 rec;
1088
1106
  def beat(self, task_running: bool = None) -> dict:
1089
1107
  if task_running is not None:
1090
1108
  self.task_running = bool(task_running)
1091
1109
  rec = write_stamp(
1092
1110
  self.name, self.d, task_running=self.task_running, root=self.cg.root,
1093
1111
  uptime=round(time.time() - self._started_at, 3)
1094
- if self._started_at else 0.0)
1112
+ if self._started_at else 0.0,
1113
+ **self._progress_fields()) # 进度面随心跳同落(单点状态面)
1095
1114
  self.beats += 1
1096
1115
  self.last_beat = rec["ts"]
1097
1116
  return rec
1098
1117
 
1118
+ # ---- 活体进度面(issue #63)----
1119
+ #
1120
+ # 为什么有这一段:`_run()` 单线程串行六档,任一档**无界阻塞**即全循环停摆
1121
+ # (实测停 9.5 小时:心跳/自愈/巡检/演化/整理/睡眠全停)。卡住期间外部
1122
+ # **无法知道卡在哪档**——tick 台账在 run_cycle **返回后**才写。故:
1123
+ # · 进入/退出每档各刷一次**既有状态面**(心跳戳)——不造第二套状态文件;
1124
+ # · `current_tick` 给出「此刻在哪档、已跑多久」,`stale_tick` 超限告警;
1125
+ # · 六档异常**不再静默**(容忍≠静默):`last_tick_error` 记类名 + 摘要。
1126
+ # 形态:全部经 `_wrap_ticks()` **单点包装**——`_run` 的六档 try/except 逐字
1127
+ # 未改(`md_cg/test_sleep_p1.py` G3a 把它钉死;包装让异常在进入 `_run` 的
1128
+ # except 之前就已被记录,那边的 `pass` 保留为兜底)。
1129
+
1130
+ # 生效条件:无入参;返回进度三字段 dict(current_tick / last_tick_done / last_tick_error;None 值由 write_stamp 丢弃)——写戳与 status 读取的唯一取值面。
1131
+ def _progress_fields(self) -> dict:
1132
+ """进度三字段(写点与读取的**单一取值面**)。"""
1133
+ return {"current_tick": self.current_tick,
1134
+ "last_tick_done": self.last_tick_done,
1135
+ "last_tick_error": self.last_tick_error}
1136
+
1137
+ # 生效条件:以 write_stamp(self.name, self.d, task_running=self.task_running, root=self.cg.root, uptime=... if self._started_at else 0.0, **进度三字段) 刷新既有心跳戳(原子替换);写失败静默(与心跳同纪律)——进度面缺失是已知边界(写失败时 status 的当前档读数退化为 null)。
1138
+ def _flush_progress(self):
1139
+ """把进度面刷新进**既有状态面**(心跳戳)——不新建状态文件。
1140
+
1141
+ 为什么**进入**档时就要刷:卡死场景下「没有下一次写」正是常态——进度
1142
+ 必须在进入时就落盘,否则观测面恰好缺了要观测的那一刻。写失败静默
1143
+ (与 `beat()` 同纪律),但失败即拉不到进度,属已知边界。
1144
+ """
1145
+ try:
1146
+ write_stamp(self.name, self.d, task_running=self.task_running,
1147
+ root=self.cg.root,
1148
+ uptime=round(time.time() - self._started_at, 3)
1149
+ if self._started_at else 0.0,
1150
+ **self._progress_fields())
1151
+ except Exception: # noqa: BLE001
1152
+ pass # 进度面刷新失败不拖垮常驻
1153
+
1154
+ # 生效条件:无入参;把六档 tick 方法(beat / _tick_heal / _tick_scrub / _tick_evolve / _tick_tidy / _tick_sleep)就地包上 _wrap_tick(幂等:_ticks_wrapped 为真即直接返回 self);返回 self。
1155
+ def _wrap_ticks(self):
1156
+ """把六档 tick 包上进度面(幂等)——**不改 `_run` 的逐字形态**。
1157
+
1158
+ 为什么用包装而不是改 `_run` 的六档结构:`_run` 的六档 try/except 形态
1159
+ 被既有守卫**逐字**钉死(`md_cg/test_sleep_p1.py` G3a「第六档形态与
1160
+ 既有五档逐字同构(try/except 吞异常 + 末尾 _stop.wait(_POLL))」),
1161
+ 而 issue #63 要求的「进/出留读数 + 异常不静默」是**每档同款**动作——
1162
+ 单点包装既保住既有形态(六档语义与顺序一字不动),又免六处重复
1163
+ (改档名/加点只动 `TICK_INTERVAL_ATTRS`)。
1164
+ """
1165
+ if getattr(self, "_ticks_wrapped", False):
1166
+ return self
1167
+ for name, _attr in TICK_INTERVAL_ATTRS:
1168
+ meth = "_tick_" + name if name != "beat" else "beat"
1169
+ setattr(self, meth, self._wrap_tick(name, getattr(self, meth)))
1170
+ self._ticks_wrapped = True
1171
+ return self
1172
+
1173
+ # 生效条件:name 为该档名、fn 为该档可调用;返回包装函数 wrapped——调用时先写 current_tick={name, started_at, pid} 并刷新戳,调 fn(*a, **kw),异常记入 last_tick_error={name, error: 类名+摘要(截 200 字符), t} 并**不重抛**(容忍≠静默;`_run` 的既有 except 保留为兜底),随后清 current_tick、记 last_tick_done={name, t, ok} 并再刷新戳。
1174
+ def _wrap_tick(self, name: str, fn):
1175
+ """单档 tick 的**进度面包裹**(issue #63):进/出各留读数,异常不静默。
1176
+
1177
+ 「容忍 ≠ 静默」:六档的 except 面此前只有 `pass`——失败的档在外部
1178
+ 读不到。现在失败一律进 `last_tick_error`(类名 + 摘要);卡住靠
1179
+ `current_tick` 的年龄与 `stale_tick` 告警判(不杀线程)。
1180
+ """
1181
+ def wrapped(*a, **kw):
1182
+ self.current_tick = {"name": name, "started_at": time.time(),
1183
+ "pid": os.getpid()}
1184
+ self._flush_progress()
1185
+ ok_ = True
1186
+ try:
1187
+ fn(*a, **kw)
1188
+ except Exception as e: # noqa: BLE001
1189
+ ok_ = False
1190
+ self.last_tick_error = {"name": name, "t": time.time(),
1191
+ "error": "%s: %s" % (type(e).__name__,
1192
+ str(e)[:200])}
1193
+ self.current_tick = None
1194
+ self.last_tick_done = {"name": name, "t": time.time(), "ok": ok_}
1195
+ self._flush_progress()
1196
+ return ok_
1197
+ wrapped.__name__ = "wrapped_%s_tick" % name
1198
+ return wrapped
1199
+
1200
+ # 生效条件:ct 为 current_tick 形态(含 name 与 started_at)且 name 在 TICK_INTERVAL_ATTRS 内时,以该档 interval 算 lim=max(2×interval, TICK_STALE_MIN_S),年龄(now-started_at)超 lim 返回 {tick, age_s, limit_s, alert}(alert 文案含档名与已运行时长);未超或形态不符返回 None。
1201
+ def _stale_tick(self, ct):
1202
+ """`current_tick` 的 stale 判定:年龄 > `max(2×该档 interval, 1800s)`。
1203
+
1204
+ 超限**只告警不杀线程**(线程不可安全强杀;处置=人工重启常驻进程,
1205
+ 见运维指南「常驻循环的卡死防护与观测」一节)。
1206
+ """
1207
+ if not isinstance(ct, dict):
1208
+ return None
1209
+ name = ct.get("name")
1210
+ attr = dict(TICK_INTERVAL_ATTRS).get(name)
1211
+ if attr is None:
1212
+ return None
1213
+ try:
1214
+ lim = max(2.0 * float(getattr(self, attr)), TICK_STALE_MIN_S)
1215
+ age = max(0.0, time.time() - float(ct.get("started_at") or 0.0))
1216
+ except (TypeError, ValueError):
1217
+ return None
1218
+ if age <= lim:
1219
+ return None
1220
+ return {"tick": name, "age_s": round(age, 1), "limit_s": round(lim, 1),
1221
+ "alert": ("档 %s 已运行 %.0fs(阈值 %.0fs = max(2×间隔, %.0fs))"
1222
+ "——疑似卡死;不自动杀线程,处置=人工重启常驻进程"
1223
+ % (name, age, lim, TICK_STALE_MIN_S))}
1224
+
1099
1225
  # ---- 生命周期 ----
1100
1226
 
1101
1227
  # 生效条件:不适用(无必需形参与模块级常量)
@@ -1119,8 +1245,9 @@ class SustainLoop:
1119
1245
  clear_stamp(self.name, self.d)
1120
1246
  return self
1121
1247
 
1122
- # 生效条件:self._stop 未置位期间轮询,按 beat_interval/heal_interval/scrub_interval/evolve_interval/tidy_interval/**sleep_interval(第六档)** 到期分别执行 beat 与 _tick_heal/_tick_scrub/_tick_evolve/_tick_tidy/_tick_sleep,各 tick 抛出的异常被吞掉不中断循环,末尾以 _stop.wait(_POLL) 休眠;
1248
+ # 生效条件:self._stop 未置位期间轮询(**先调 _wrap_ticks 给六档包上进度面,幂等**),按 beat_interval/heal_interval/scrub_interval/evolve_interval/tidy_interval/**sleep_interval(第六档)** 到期分别执行 beat 与 _tick_heal/_tick_scrub/_tick_evolve/_tick_tidy/_tick_sleep(六档调用形态与既有五档逐字同构:try/except 吞异常 + `_stop.wait(_POLL)` 收尾;进/出读数与异常记录由包装单点提供——issue #63);
1123
1249
  def _run(self):
1250
+ self._wrap_ticks() # issue #63:六档包上进度面(幂等,形态不变)
1124
1251
  next_beat = time.time() + self.beat_interval
1125
1252
  next_heal = time.time() + self.heal_interval
1126
1253
  next_scrub = time.time() + self.scrub_interval
@@ -1309,12 +1436,25 @@ class SustainLoop:
1309
1436
 
1310
1437
  # ---- 状态 ----
1311
1438
 
1312
- # 生效条件:以 read_stamp(self.name, self.d) 判定 state(有戳走 judge(age, interval=self.beat_interval, task_running=...),无戳为 "stopped"),返回含 name/running/pid/uptime(无 _started_at 时为 0.0)/beats/last_beat/各 interval 与 auto_* 开关/heals[-5:]/evolves[-5:]/tidys[-5:]/peers(self.d)/ledger.summary() 的 dict;
1439
+ # 生效条件:以 read_stamp(self.name, self.d) 判定 state(有戳走 judge(age, interval=self.beat_interval, task_running=...),无戳为 "stopped");进度面(issue #63)同进程内存优先、跨进程回落戳内字段,current_tick 附 running_s(已运行秒数);stale_tick 在 current_tick 年龄超 max(2×该档 interval, 1800s) 时给出告警(含档名与时长);返回含 name/running/pid/uptime(无 _started_at 时为 0.0)/beats/last_beat/各 interval 与 auto_* 开关/heals[-5:]/evolves[-5:]/tidys[-5:]/current_tick/last_tick_done/last_tick_error/stale_tick/peers(self.d)/ledger.summary() 的 dict;
1313
1440
  def status(self) -> dict:
1314
1441
  st = read_stamp(self.name, self.d)
1315
1442
  state = (judge(st["age"], interval=self.beat_interval,
1316
1443
  task_running=bool(st.get("task_running")))
1317
1444
  if st else "stopped")
1445
+ # 进度面(issue #63):同进程内存优先(最实时),跨进程回落戳内字段
1446
+ # ——戳是**既有状态面**(不造第二套状态文件);外部 `action=beat`
1447
+ # 覆盖戳时内存侧不受影响。
1448
+ cur = self.current_tick or (st or {}).get("current_tick")
1449
+ if isinstance(cur, dict):
1450
+ cur = dict(cur)
1451
+ try:
1452
+ cur["running_s"] = round(max(
1453
+ 0.0, time.time() - float(cur.get("started_at") or 0.0)), 1)
1454
+ except (TypeError, ValueError):
1455
+ cur["running_s"] = None
1456
+ last_done = self.last_tick_done or (st or {}).get("last_tick_done")
1457
+ last_err = self.last_tick_error or (st or {}).get("last_tick_error")
1318
1458
  return {"name": self.name, "running": bool(self._th
1319
1459
  and self._th.is_alive()),
1320
1460
  "pid": os.getpid(),
@@ -1344,6 +1484,12 @@ class SustainLoop:
1344
1484
  "last_sleep": self.last_sleep,
1345
1485
  "sleeps": self.sleeps[-5:],
1346
1486
  "last_conformance": self.last_conformance,
1487
+ # 活体进度面(issue #63):此刻在哪档 / 上一档完成 / 上一档错误
1488
+ # / 超限告警(只上报,不杀线程)。
1489
+ "current_tick": cur,
1490
+ "last_tick_done": last_done,
1491
+ "last_tick_error": last_err,
1492
+ "stale_tick": self._stale_tick(cur),
1347
1493
  "peers": peers(self.d),
1348
1494
  "sessions": self.ledger.summary()}
1349
1495
 
@@ -35,8 +35,11 @@ import shutil
35
35
  import tempfile
36
36
 
37
37
  from . import lifecycle, stg
38
+ from . import chain as _chain
39
+ from . import scrub as _scrub
38
40
  from . import mcp_server
39
41
  from . import tasks as _tasks
42
+ from .mdcg import MdCG
40
43
  from .mdcos import MdCGOS
41
44
 
42
45
  _root = tempfile.mkdtemp(prefix="mdcg_retire_")
@@ -135,6 +138,18 @@ def _idx(nid):
135
138
  return (cg.index.get("nodes") or {}).get(nid) or {}
136
139
 
137
140
 
141
+ def _subgraph_children():
142
+ """结构面正查表(`subgraph.children_index`:parent_id → [child_id...])。"""
143
+ from . import subgraph as _sg
144
+ return _sg.children_index(cg)
145
+
146
+
147
+ def _subgraph_parents():
148
+ """结构面反查表(`subgraph.parents_index`:child_id → [parent_id...])。"""
149
+ from . import subgraph as _sg
150
+ return _sg.parents_index(cg)
151
+
152
+
138
153
  # ------------------------------------------------------------ R1 复现序列
139
154
  n1 = cg.add("rl_v1", _body("旧事实 v1", "甲地(初始登记)"), layer="knowledge",
140
155
  verification_basis="test", importance=0.4)
@@ -400,6 +415,81 @@ check("R4.6 op=session(action=recall)全链:archived 要点/未解问题
400
415
  _sess_call_ids.isdisjoint({_note1, _unr1}) and _note2 in _sess_call_ids,
401
416
  sorted(_sess_call_ids))
402
417
 
418
+ # ------------------------------------------- R5 基类 MdCG.search(2026-10-06 接线)
419
+ # 边界四条第 1 面(运维文档 §九.5 登记的已知未覆盖面):plain `MdCG` 实例
420
+ # (白箱 KB / 离线脚本 / 测试)的默认检索面此前未接判据——同序列下 MdCG 命中
421
+ # `['a_v1','a_v2']` 而 MdCGOS 只命中 `['a_v1']` 的取证。现按「默认读面必须消费
422
+ # 判据」接线;本腿:在役对照(converged 降权轴仍参与)→ archived 剔除。
423
+ def _base_ids():
424
+ r = MdCG(_root).search(Q, k=50, record=False)
425
+ res = r[0] if isinstance(r, tuple) else r
426
+ return {str(n.get("id")) for n, *_ in (res or [])}
427
+
428
+
429
+ _b5 = _base_ids()
430
+ check("R5.1 基类面:archived(rl_v2)不出现、v1 出现",
431
+ n2 not in _b5 and n1 in _b5, sorted(_b5))
432
+ check("R5.2 基类面不误剔:converged(rl_converged)仍参与(降权轴)",
433
+ n3 in _b5, sorted(_b5))
434
+
435
+ # ------------------------------------------- R6 因果链面(2026-10-06 接线)
436
+ # 边界四条第 2 面:`chain.adjacency` 无 lifecycle 判据 ⇒ `cg(op=causal,
437
+ # action=chain)` 扩散进 archived(取证:链里出现归档 b_tgt)。接线后缺省
438
+ # `skip_archived=True` 剔除退役节点出边与指向退役目标的边;维护面
439
+ # (scrub 去污染抽查)显式 `skip_archived=False` 保留全量(面的判据分工)。
440
+ # 另钉一条本批实证的缓存缺口:归档发生在链**已建**之后(缓存已热)也必须
441
+ # 生效——`lifecycle.set_state` 现在补了 `chain.invalidate_cache` 单点。
442
+ _c6_seed = cg.add("rl_c6_seed", _body("因果链种子", "辛地"), layer="knowledge",
443
+ verification_basis="test", importance=0.4,
444
+ edges=[{"target": "rl_c6_tgt", "relation_type": "causal",
445
+ "confidence": 0.9}])
446
+ _c6_tgt = cg.add("rl_c6_tgt", _body("因果链目标(待退役)", "壬地"),
447
+ layer="knowledge", verification_basis="test", importance=0.4)
448
+
449
+
450
+ def _c6_has(rep):
451
+ return any("rl_c6_tgt" in (c.get("nodes") or []) for c in (rep or []))
452
+
453
+
454
+ check("R6.0 前提:因果链在役时确实扩散到目标(非空断言,防本腿空转)",
455
+ _c6_has(cg.causal_chain("rl_c6_seed")), cg.causal_chain("rl_c6_seed"))
456
+ cg.set_state(_c6_tgt, "demoted", reason="守卫:链面降权", actor="test")
457
+ cg.set_state(_c6_tgt, "archived", reason="守卫:链面归档", actor="test")
458
+ check("R6.1 目标退役后因果链不扩散进它(缓存已热时亦生效——set_state 同点失效)",
459
+ not _c6_has(cg.causal_chain("rl_c6_seed")), cg.causal_chain("rl_c6_seed"))
460
+ check("R6.2 维护面旁路:adjacency(skip_archived=False) 仍含退役边(scrub 全量口径)",
461
+ any(t == "rl_c6_tgt" for t, _e in
462
+ (_chain.adjacency(cg, skip_archived=False).get("rl_c6_seed") or [])))
463
+ check("R6.3 scrub 面经旁路取数同证(维护写面不受退役过滤约束)",
464
+ any(t == "rl_c6_tgt" for t, _e in
465
+ (_scrub._adjacency(cg).get("rl_c6_seed") or [])))
466
+ cg.set_state(_c6_tgt, "active", reason="守卫:链面恢复", actor="test")
467
+ check("R6.4 恢复后因果链重新扩散(退役可逆,同面)",
468
+ _c6_has(cg.causal_chain("rl_c6_seed")), cg.causal_chain("rl_c6_seed"))
469
+ cg.set_state(_c6_tgt, "demoted", reason="守卫:复测降权", actor="test")
470
+ cg.set_state(_c6_tgt, "archived", reason="守卫:复测归档", actor="test")
471
+
472
+ # --------------------------------- R7 结构面(裁定:不接——定向面直读,钉住裁定)
473
+ # 边界四条第 3 面经使用者裁定(2026-10-06)**保持直读**:调用方给显式父/子
474
+ # id、返回 id 映射不回正文,属定向/结构面。本腿把裁定钉住——若未来有人
475
+ # "顺手接线",R7.1 转红即提醒该行为已被裁定(改行为须先改裁定)。
476
+ _c7_parent = cg.add("rl_c7_parent", _body("结构面父节点", "癸地"),
477
+ layer="knowledge", verification_basis="test", importance=0.4,
478
+ subgraph={"nodes": ["rl_c7_child"]})
479
+ _c7_child = cg.add("rl_c7_child", _body("结构面子节点(待退役)", "子地"),
480
+ layer="knowledge", verification_basis="test", importance=0.4)
481
+ check("R7.0 前提:children_index 含该父→子映射(非空断言,防本腿空转)",
482
+ "rl_c7_child" in (_subgraph_children().get("rl_c7_parent") or []),
483
+ _subgraph_children())
484
+ cg.set_state(_c7_child, "demoted", reason="守卫:结构面降权", actor="test")
485
+ cg.set_state(_c7_child, "archived", reason="守卫:结构面归档", actor="test")
486
+ check("R7.1 子节点退役后 children_index 映射仍在(裁定:结构面直读不接判据)",
487
+ "rl_c7_child" in (_subgraph_children().get("rl_c7_parent") or []),
488
+ _subgraph_children())
489
+ check("R7.2 parents_index 反向表同钉(裁定文本两侧对称——仅给一侧接线也会被本腿抓住)",
490
+ "rl_c7_parent" in (_subgraph_parents().get("rl_c7_child") or []),
491
+ _subgraph_parents())
492
+
403
493
  print("=" * 58)
404
494
  print("test_lifecycle_retire_leak: %d 通过 / %d 失败" % (_ok, len(_fail)))
405
495
  if _fail: