@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,409 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · 代校验单点(generation)——「长驻进程是否还持有旧代代码」的唯一判据面
3
+
4
+ 为什么存在(第 4 条根因,2026-09-30 已定因)
5
+ --------------------------------------------
6
+ 本仓是 stdio 长驻 MCP 服务:`python -m md_cg.mcp_server` 只在**模块首次导入**时
7
+ 读盘,此后一直用内存里的模块对象。而 md_cg 内有**大量函数内延迟导入**(AST 实测
8
+ 函数内跨模块相对导入 328 处;其中相当一部分是为**避开循环导入**才写在函数里的)。
9
+ 两者叠加 ⇒ 代码升级(重装/替换/`git pull`)后,长驻进程里「启动时已载入的旧代
10
+ 模块」与「升级后第一次被惰性导入的新代模块」会**跨代混用**:
11
+
12
+ 实测(0.6.1 发版轮):cg 写入返回
13
+ ImportError: cannot import name 'mint_auto_id' from 'md_cg.mdcg'
14
+ 而同一时刻**新解释器**导入同一模块完全正常、盘面上 mdcg.py:1079 就是
15
+ `def mint_auto_id(...)` ⇒ 盘面无罪,是进程内的 md_cg.mdcg 停留在旧代。
16
+
17
+ 处置(使用者裁定方案 b):把「**升级后须重启长驻 MCP 进程**」写成运维前提,并加
18
+ 一道代校验把这句话变成可自检、可机械判定的东西。
19
+
20
+ 本模块提供四件(外加一个自检器):
21
+ · ``fingerprint()`` —— 盘面 md_cg/**/*.py 的**内容**指纹(sha256 十六进制)
22
+ · ``STARTUP_FINGERPRINT`` —— 本模块被导入那一刻的指纹(=本进程的「代」)
23
+ · ``is_stale()`` —— 现算指纹 != 启动指纹
24
+ · ``report()`` —— 给 ``cg(op=info)`` 用的五键自报(纯增量)
25
+ · ``verify_delegations()`` —— 延迟导入自检器(见下)
26
+
27
+ 为什么指纹用**内容哈希**而不是 mtime/size
28
+ ------------------------------------------
29
+ 「无改动重写」在打包/解压/校验回写里很常见(mtime 变、内容不变),用 mtime 会把
30
+ 同代误报成换代 ⇒ 使用者被引导去做无谓的重启;用 size 更糟(不同内容同长度即漏报)。
31
+ 内容哈希只认字节:无改动重写 = 同代(实测全量 416 个 .py / 约 11.7 MB 内容哈希
32
+ 约 21ms,够便宜,可放在每次 op=info 上)。
33
+
34
+ ``verify_delegations()`` 的判定语义(**这一条最要紧,别改成顶层 import**)
35
+ ------------------------------------------------------------------------
36
+ 它对盘面每个「函数内(非模块顶层)相对导入」逐项做「导入目标模块 → getattr 取
37
+ 名字」。要点:
38
+
39
+ 1. **必须在函数内做延迟导入,绝不能为了校验把它们提到模块顶层**——这些导入里
40
+ 有相当一部分正是为避开循环导入才写在函数里的,提到顶层会直接制造循环导入。
41
+ 校验动作本身(本函数)因此天然只能读盘 + 导入 + getattr。
42
+ 2. `importlib.import_module` 对**已在本进程 sys.modules 里的模块直接返回内存实例**
43
+ ——这正是跨代判据生效的地方:检查的是「本进程实际持有的那个模块对象」,
44
+ 而不是盘面文件(盘面文件另由 fingerprint 覆盖)。
45
+ 3. 判定三分(取值见返回 dict):
46
+ · **名字缺失 ⇒ 致命**:目标模块可导入、但其中没有这个名字 ⇒ 抛
47
+ ``GenerationError``(点名子模块与名字 + 「请重启常驻 MCP 进程」指引)。
48
+ · **包内子模块缺失 ⇒ 可选缺失(非致命)**:`from . import x` / `from .pkg import x`
49
+ 里 x 其实是**子模块名**而该子模块不在盘面(例:`whitebox_kb/aeis_core/api.py`
50
+ 里 `from .knowledge import ingest_text`——该目录的 docstring 明写「不带走
51
+ vision/body/world_model 等,它们在 core.py 里全部是方法内惰性导入,**缺失即
52
+ 自动降级**」)。这类是**设计上的可选降级面**,不是换代证据;若判致命,服务
53
+ 将永远起不来(实测 25 项)。判别器=目标模块是否为包(有 ``__path__``):
54
+ 是包才允许用「名字其实是子模块」解释。
55
+ · **目标模块本身导入不进 ⇒ 可选缺失(非致命)**:外部可选模块未装(同上语义)。
56
+ 两条非致命项都**如实记进返回 dict 的 ``unavailable``**,不静默丢弃。
57
+ 4. 结果**按目标模块为单位缓存**(同一进程只扫一次);扫描与校验**不写任何文件、
58
+ 不联网、不做任何时间/随机依赖**(文件按路径排序后处理)。
59
+
60
+ 不适用条件(negative)
61
+ ----------------------
62
+ · 不适用于**已经换代**的判定:本模块只回答「盘面 vs 本进程启动代是否同形」,
63
+ 不回答「哪一代更正确」;跨代处置一律是「重启常驻进程」,不做热重载
64
+ (热重载会与函数内延迟导入的避环设计冲突,且会把已加载模块的类身份打散)。
65
+ · ``verify_delegations()`` 不构成「所有延迟导入都能跑通」的完备证明:它只校验
66
+ **相对导入的名字存在性**(不执行被导入者、不校验签名/行为),也不覆盖
67
+ `import x.y` 绝对导入与 `importlib.import_module("...")` 字符串形态。
68
+ · 代校验只在**进程边界**有效:同一进程内改盘不会自动重载,这正是要重启的原因。
69
+ """
70
+ from __future__ import annotations
71
+
72
+ import ast
73
+ import hashlib
74
+ import importlib
75
+ import os
76
+
77
+ __all__ = [
78
+ "GenerationError",
79
+ "code_root",
80
+ "fingerprint",
81
+ "STARTUP_FINGERPRINT",
82
+ "is_stale",
83
+ "report",
84
+ "verify_delegations",
85
+ ]
86
+
87
+ # 包名(本模块在 md_cg 包内;用 __package__ 而不是写字面量,便于副本/改名场景)
88
+ _PACKAGE = __package__ or "md_cg"
89
+
90
+ # 指纹与代际展示用前缀长度(12 位十六进制足够区分代际,且不把 info 响应撑大)
91
+ _GEN_PREFIX = 12
92
+
93
+ # 单进程内的延迟导入校验缓存(键 = 本模块 code_root(),值 = verify_delegations 返回 dict)
94
+ _VERIFY_CACHE: dict = {}
95
+
96
+ # 扫描时跳过的目录名(编译缓存不是源码;跳掉才不会被 .pyc 干扰指纹)
97
+ _SKIP_DIRS = {"__pycache__"}
98
+
99
+
100
+ class GenerationError(RuntimeError):
101
+ """本进程与磁盘代码不同代(跨代混合快照)。
102
+
103
+ 抛出时机:盘面上某处「函数内相对导入」所要的名字,在**本进程实际持有的**
104
+ 目标模块里不存在(而该模块又不是包、无法用「子模块名」解释)。此时该延迟
105
+ 导入一旦被执行就是 ImportError,且盘面自洽(新解释器导入正常)——唯一处置
106
+ 是把**长驻进程重启**,让它重新只读一代代码。
107
+ """
108
+
109
+
110
+ # 生效条件:无入参,恒返回本文件(md_cg/generation.py)所在目录的绝对路径,即盘面 md_cg 包目录。
111
+ def code_root() -> str:
112
+ """盘面 md_cg 包目录(本文件所在目录)的绝对路径。"""
113
+ return os.path.dirname(os.path.abspath(__file__))
114
+
115
+
116
+ # 生效条件:root 为空/缺省时取 code_root();遍历其下 **.py** 源文件(跳过 __pycache__ 目录与 test_*.py),按相对路径排序后逐字节喂 sha256。
117
+ def _iter_source_files(root: str):
118
+ """产出 (相对路径 posix 形态, 绝对路径),顺序=相对路径排序(确定性)。"""
119
+ found = []
120
+ for dirpath, dirnames, filenames in os.walk(root):
121
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
122
+ for name in filenames:
123
+ if not name.endswith(".py"):
124
+ continue
125
+ # test_*.py 排除:守卫/测试文件不是长驻服务要惰性导入的运行面,
126
+ # 且它们处于持续改写中——纳入指纹会让「跑一次测试」就假报换代。
127
+ if name.startswith("test_"):
128
+ continue
129
+ full = os.path.join(dirpath, name)
130
+ rel = os.path.relpath(full, root).replace(os.sep, "/")
131
+ found.append((rel, full))
132
+ found.sort(key=lambda it: it[0])
133
+ return found
134
+
135
+
136
+ # 生效条件:无入参(root 语义见 _iter_source_files);对每个源文件喂 (相对路径 UTF-8 字节 + NUL + 文件全部字节 + NUL),返回 sha256 十六进制串;不写任何文件。
137
+ def fingerprint() -> str:
138
+ """盘面 md_cg 代码的**内容**指纹(sha256 十六进制,全 64 位)。
139
+
140
+ 只用(相对路径, 文件字节)——不用 mtime/size:打包/解压/校验回写造成的
141
+ 「无改动重写」会变 mtime 而不变内容,用 mtime 会把同代误报成换代。
142
+ """
143
+ root = code_root()
144
+ digest = hashlib.sha256()
145
+ for rel, full in _iter_source_files(root):
146
+ digest.update(rel.encode("utf-8"))
147
+ digest.update(b"\x00")
148
+ with open(full, "rb") as fh:
149
+ digest.update(fh.read())
150
+ digest.update(b"\x00")
151
+ return digest.hexdigest()
152
+
153
+
154
+ # 本进程的「代」=本模块被导入那一刻的盘面指纹。导入后再改盘不会更新它——
155
+ # 这正是「长驻进程停在启动代」的机械表达(需要新代 ⇒ 重启进程)。
156
+ STARTUP_FINGERPRINT = fingerprint()
157
+
158
+
159
+ # 生效条件:无入参;现算 fingerprint() 与 STARTUP_FINGERPRINT 逐字符比较,不等即 True(盘面已换代,本进程仍持启动代)。
160
+ def is_stale() -> bool:
161
+ """现算指纹 != 启动指纹 ⇒ 本进程与磁盘代码不同代。"""
162
+ return fingerprint() != STARTUP_FINGERPRINT
163
+
164
+
165
+ # 生效条件:无入参;恒返回含 code_generation/disk_generation/stale_on_disk/restart_required/hint 五键的 dict;盘面读取失败(OSError)时按「已换代」保守上报并写进 hint,不抛异常(本函数挂在 cg(op=info) 上,诊断面不得把 info 拖崩)。
166
+ def report() -> dict:
167
+ """代校验自报(供 ``cg(op=info)`` 纯增量挂载)。
168
+
169
+ 五键语义:
170
+ · ``code_generation`` —— 启动代指纹前 12 位(本进程实际持有的一代)
171
+ · ``disk_generation`` —— 现算盘面指纹前 12 位(此刻盘面上的一代)
172
+ · ``stale_on_disk`` —— 两者是否不同(True ⇒ 磁盘代码已换代)
173
+ · ``restart_required`` —— 是否需要重启本进程(与 stale_on_disk 同真值)
174
+ · ``hint`` —— 中文一句话处置指引(可执行,不猜测)
175
+ """
176
+ startup = STARTUP_FINGERPRINT[:_GEN_PREFIX]
177
+ try:
178
+ disk_full = fingerprint()
179
+ stale = disk_full != STARTUP_FINGERPRINT
180
+ disk = disk_full[:_GEN_PREFIX]
181
+ except OSError as exc: # 盘面读不到:保守按「已换代」报
182
+ stale = True
183
+ disk = None
184
+ read_error = repr(exc)
185
+ else:
186
+ read_error = None
187
+ if stale:
188
+ hint = ("检测到磁盘上的 md_cg 代码已换代(本进程仍是启动时载入的那一代):"
189
+ "请重启常驻 MCP 进程(重连/重启 mcp_server 即可,记忆数据无损)"
190
+ "后重试。")
191
+ if read_error:
192
+ hint = ("无法读取盘面代码指纹(%s)——按已换代保守上报:请重启常驻 "
193
+ "MCP 进程(重连/重启 mcp_server 即可,记忆数据无损)后重试。"
194
+ % read_error)
195
+ else:
196
+ hint = "本进程与磁盘 md_cg 代码同代,暂不需要重启常驻 MCP 进程。"
197
+ return {
198
+ "code_generation": startup,
199
+ "disk_generation": disk,
200
+ "stale_on_disk": bool(stale),
201
+ "restart_required": bool(stale),
202
+ "hint": hint,
203
+ }
204
+
205
+
206
+ # ---------------------------------------------------------------- 延迟导入校验
207
+ # 生效条件:path 为本包(root)内某 .py 的绝对路径;返回其在包内的点分模块名,__init__.py 归并为包名本身(例:<root>/a/b/__init__.py → "md_cg.a.b")。
208
+ def _module_name_of(path: str, root: str) -> str:
209
+ """源文件绝对路径 → 包内模块名(`__init__.py` 归并为包名本身)。"""
210
+ rel = os.path.relpath(path, os.path.dirname(root)).replace(os.sep, "/")
211
+ if rel.endswith(".py"):
212
+ rel = rel[:-3]
213
+ if rel.endswith("/__init__"):
214
+ rel = rel[: -len("/__init__")]
215
+ return rel.replace("/", ".")
216
+
217
+
218
+ # 生效条件:file_module 为引入语句所在文件的点分模块名,level ≥ 1 为相对层级(from 后点的个数),node_module 为语句里的模块部分(`from . import x` 形态为 None);返回解析后的绝对目标模块名(不校验其是否可导入,空包名时为 "")。
219
+ def _resolve_base(file_module: str, level: int, node_module):
220
+ """把 `from <level 个点><node_module> import ...` 解析成绝对目标模块名。
221
+
222
+ level=1 指当前包;level=2 指上一级,依此类推(相对导入的层数语义)。
223
+ node_module 为 None 时(`from . import x` 形态)目标即所在包的**包名本身**。
224
+ """
225
+ parts = file_module.split(".")
226
+ pkg = parts[:-1] # 文件所在包
227
+ up = level - 1
228
+ if up:
229
+ pkg = pkg[: len(pkg) - up] if up <= len(pkg) else []
230
+ if node_module:
231
+ return ".".join(pkg + [node_module]) if pkg else node_module
232
+ return ".".join(pkg)
233
+
234
+
235
+ # 生效条件:以 (文件名→模块名, 相对路径) 构造后 visit(ast);items 里为**函数体深度 >0** 的相对导入项(模块顶层与类体顶层不计),每项=(目标模块名, 被导入名, 相对路径, 行号, 是否 `from . import x` 形态)。
236
+ class _DelegationVisitor(ast.NodeVisitor):
237
+ """收集**函数体内**(depth>0)的跨模块相对导入。
238
+
239
+ 意义:模块顶层的 import 在导入期就会执行(换代必然同步);写在函数里的
240
+ 导入要到该行被跑到才执行——正是它制造了「新代代码用旧代模块」的窗口。
241
+ """
242
+
243
+ def __init__(self, file_module: str, rel_path: str):
244
+ self.file_module = file_module
245
+ self.rel_path = rel_path
246
+ self.depth = 0
247
+ self.items = [] # (target_base, name, rel_path, lineno, module_is_none)
248
+
249
+ def _visit_function(self, node):
250
+ self.depth += 1
251
+ self.generic_visit(node)
252
+ self.depth -= 1
253
+
254
+ def visit_FunctionDef(self, node): # noqa: N802 —— ast 回调名
255
+ self._visit_function(node)
256
+
257
+ def visit_AsyncFunctionDef(self, node): # noqa: N802
258
+ self._visit_function(node)
259
+
260
+ def visit_ImportFrom(self, node): # noqa: N802
261
+ if self.depth > 0 and node.level and node.level > 0:
262
+ base = _resolve_base(self.file_module, node.level, node.module)
263
+ for alias in node.names:
264
+ self.items.append((base, alias.name, self.rel_path,
265
+ node.lineno, node.module is None))
266
+ self.generic_visit(node)
267
+
268
+
269
+ # 生效条件:无入参;AST 扫盘面 md_cg/**/*.py(与 fingerprint 同排除集:__pycache__ 与 test_*.py)里所有函数内相对导入,对每一项「导入目标模块 → getattr 取名字」,名字缺失即抛 GenerationError(点名子模块与名字 + 重启指引);包内子模块缺失与目标模块导入不进这两类记入 unavailable(非致命);结果按 code_root 缓存,同一进程只扫一次;不写文件、不联网。
270
+ def verify_delegations() -> dict:
271
+ """延迟导入自检器:盘面每个「函数内相对导入」是否能在当前模块面上解析。
272
+
273
+ 返回 dict(键与语义稳定):
274
+ · ``ok`` —— 无致命不一致(True/False)
275
+ · ``scanned_files`` —— 参与扫描的源文件数
276
+ · ``delegations`` —— 扫到的函数内相对导入**项数**(一个 import 语句可多项)
277
+ · ``target_modules`` —— 去重后的目标模块数
278
+ · ``resolved`` —— 去重后成功解析的 (目标模块, 名字) 数
279
+ · ``unavailable`` —— 非致命项列表:[{target, name, rel_path, line, reason}]
280
+ · ``mismatches`` —— 致命项列表:[{target, name, rel_path, line}]
281
+ 致命项非空时抛 ``GenerationError``(消息点名子模块与名字)。
282
+ 同一进程内只扫一次(缓存),缓存键为 ``code_root()``。
283
+
284
+ 文件集与 ``fingerprint()`` 同口径(跳过 ``__pycache__`` 与 ``test_*.py``):
285
+ 长驻服务要惰性导入的是**运行面**,`test_*.py` 不属运行面(且处于持续改写中)。
286
+ 注意本函数**不写任何文件**(不建缓存文件),缓存只在内存。
287
+
288
+ 另:扫描期 `ast.parse` 失败(源码语法坏)会**原样抛出** `SyntaxError`——盘面
289
+ 语法坏本就该 fail-closed(由调用方按启动闸处置),此处不吞成「可用」。
290
+ """
291
+ root = code_root()
292
+ cache_key = root
293
+ if cache_key in _VERIFY_CACHE:
294
+ return _VERIFY_CACHE[cache_key]
295
+
296
+ items = []
297
+ files = _iter_source_files(root)
298
+ for rel, full in files:
299
+ with open(full, encoding="utf-8") as fh:
300
+ src = fh.read()
301
+ visitor = _DelegationVisitor(_module_name_of(full, root), rel)
302
+ visitor.visit(ast.parse(src, full))
303
+ items.extend(visitor.items)
304
+
305
+ # 去重(同一 (目标模块, 名字) 在多处延迟导入只需查一次;首次出现的引用位置留痕)
306
+ uniq: dict = {}
307
+ for base, name, rel, line, module_is_none in items:
308
+ uniq.setdefault((base, name), (base, name, rel, line, module_is_none))
309
+
310
+ resolved = 0
311
+ unavailable = []
312
+ mismatches = []
313
+ for (base, name), (_b, _n, rel, line, module_is_none) in sorted(uniq.items()):
314
+ try:
315
+ # 已加载则取内存实例(跨代判据;不是重读盘面文件)
316
+ target = importlib.import_module(base)
317
+ except ModuleNotFoundError as exc:
318
+ unavailable.append({"target": base, "name": name, "rel_path": rel,
319
+ "line": line, "reason": "目标模块不在盘面:%s" % exc})
320
+ continue
321
+ except Exception as exc: # noqa: BLE001 —— 导入期异常归入非致命并如实登记
322
+ unavailable.append({"target": base, "name": name, "rel_path": rel,
323
+ "line": line,
324
+ "reason": "目标模块导入失败(%s):%s"
325
+ % (type(exc).__name__, exc)})
326
+ continue
327
+ if hasattr(target, name):
328
+ resolved += 1
329
+ continue
330
+ if hasattr(target, "__path__"): # 目标模块是包 ⇒ 名字可能是子模块
331
+ try:
332
+ importlib.import_module("%s.%s" % (base, name))
333
+ resolved += 1
334
+ continue
335
+ except ModuleNotFoundError:
336
+ unavailable.append({
337
+ "target": base, "name": name, "rel_path": rel, "line": line,
338
+ "reason": "包内子模块不存在(可选降级面,非换代证据)"})
339
+ continue
340
+ except Exception as exc: # noqa: BLE001
341
+ unavailable.append({
342
+ "target": base, "name": name, "rel_path": rel, "line": line,
343
+ "reason": "子模块导入失败(%s):%s" % (type(exc).__name__, exc)})
344
+ continue
345
+ mismatches.append({"target": base, "name": name, "rel_path": rel,
346
+ "line": line, "module_is_none_form": module_is_none})
347
+
348
+ result = {
349
+ "ok": not mismatches,
350
+ "scanned_files": len(files),
351
+ "delegations": len(items),
352
+ "target_modules": len({base for base, _n in uniq}),
353
+ "resolved": resolved,
354
+ "unavailable": unavailable,
355
+ "mismatches": mismatches,
356
+ }
357
+ _VERIFY_CACHE[cache_key] = result
358
+
359
+ if mismatches:
360
+ raise GenerationError(_mismatch_message(mismatches))
361
+ return result
362
+
363
+
364
+ # 生效条件:mismatches 非空(每项含 target/name/rel_path/line);返回多行中文消息——点名首项的子模块与名字、按「启动代 vs 盘面代」是否相等分流陈述,并给出含「重启常驻 MCP 进程」的可执行指引。
365
+ def _mismatch_message(mismatches) -> str:
366
+ """跨代不一致的异常消息(点名子模块与名字 + 可执行指引)。
367
+
368
+ 两代关系**分开陈述**,不套一句话:启动代≠盘面代 ⇒ 真·跨代(升级后未重启,
369
+ 这正是本模块要防的那一种);启动代==盘面代 ⇒ 盘面自身不自洽(半升级/改坏/
370
+ 删了定义但引用方还在)。两种都要求重启常驻进程,但只有前者能说「不同代」——
371
+ 把后者也说成「不同代」是假读(实测出现过:两侧前缀都是 1afbcba9935a 却印着
372
+ 「≠」),故此处按事实分流。
373
+ """
374
+ head = mismatches[0]
375
+ startup = STARTUP_FINGERPRINT[:_GEN_PREFIX]
376
+ disk = _disk_prefix()
377
+ lines = [
378
+ "跨代混合快照:盘面上的函数内延迟导入在本进程的模块面上解析不了。",
379
+ " · 目标子模块:%s" % head["target"],
380
+ " · 找不到的名字:%s" % head["name"],
381
+ " · 引用处:%s:%s(函数内延迟导入)" % (head["rel_path"], head["line"]),
382
+ ]
383
+ if startup != disk:
384
+ lines.append(" · 本进程启动代 %s ≠ 当前盘面代 %s" % (startup, disk))
385
+ else:
386
+ lines.append(" · 启动代与当前盘面代同为 %s:本次不一致不是「换代」本身,"
387
+ "而是盘面代码在该模块面上不自洽(半升级/被改坏/定义被删但"
388
+ "引用方仍在)" % startup)
389
+ if len(mismatches) > 1:
390
+ lines.append(" · 同类不一致共 %d 项:%s"
391
+ % (len(mismatches),
392
+ "、".join("%s.%s" % (m["target"], m["name"])
393
+ for m in mismatches[1:9])))
394
+ if startup != disk:
395
+ lines.append("本进程与磁盘代码不同代,请重启常驻 MCP 进程"
396
+ "(重连/重启 mcp_server 即可,记忆数据无损)后重试。")
397
+ else:
398
+ lines.append("请重新完整部署/安装本包(或修复该名字),并重启常驻 MCP 进程"
399
+ "(重连/重启 mcp_server 即可,记忆数据无损)后重试。")
400
+ return "\n".join(lines)
401
+
402
+
403
+ # 生效条件:无入参;现算 fingerprint() 前 12 位返回(盘面读不到时返回 "?" 而不抛——仅供异常消息展示)。
404
+ def _disk_prefix():
405
+ """现算指纹前 12 位(读不到时返回 '?')——仅供异常消息展示。"""
406
+ try:
407
+ return fingerprint()[:_GEN_PREFIX]
408
+ except OSError:
409
+ return "?"
package/md_cg/hotcache.py CHANGED
@@ -60,9 +60,12 @@ _BYPASS_EXTRA = ("query_expand",)
60
60
  #: 影响检索结果的进程级开关(新增一个就登记一个——同 _KEYED_EXTRA 的纪律)。
61
61
  #: MDCG_CN_GRAMS(v9 N78):mdcg.cn_recall_grams 每调用读 env(="0" 回退旧行为),
62
62
  #: 改变 expand_query_terms 召回词集——漏登即运行期翻转后同 query 命中另一口径缓存。
63
+ #: MDCG_CHAIN_TYPES / MDCG_TEMPORAL_GAMMA(P3 图检索路,2026-10-01):前者改「因果路
64
+ #: 沿哪些边类型扩散」(改候选集合),后者改「时间路的衰减率」(改路内名次)——两者都
65
+ #: 直接改变融合结果,同属「漏登即跨口径串味」的一类。
63
66
  _ENV_SWITCHES = ("MDCG_SEMANTIC", "MDCG_EN_ATOMS", "MDCG_UNIFY_QUERY",
64
67
  "MDCG_RETRIEVAL_PIPELINE", "MDCG_S7_FRESHNESS",
65
- "MDCG_CN_GRAMS")
68
+ "MDCG_CN_GRAMS", "MDCG_CHAIN_TYPES", "MDCG_TEMPORAL_GAMMA")
66
69
 
67
70
 
68
71
  # 生效条件:无必需形参;返回 _ENV_SWITCHES 中每个变量名的 (名, 当前取值或 None) 元组(稳定、可哈希,随 query 缓存键一起参与比对)。
@@ -202,7 +202,7 @@ def _sync_index(cg, node_id, fm) -> None:
202
202
 
203
203
  # ---------------------------------------------------------------- 推进 / 回填
204
204
 
205
- # 生效条件:cg.get(node_id) 为假值返回 {'ok': False, 'error': 'node_not_found', ...};否则以 state_of(fm) 作 src 调 stamp——stamp 不 ok 返回 ok=False + error=code,code == "noop" 返回 ok=True + changed=False;其余情况取 fm[HISTORY_FIELD][-1]["at"] 后写盘、_sync_index 并追加审计 JSONL(append_jsonl 抛异常被吞掉),返回 ok=True + changed=True。
205
+ # 生效条件:cg.get(node_id) 为假值返回 {'ok': False, 'error': 'node_not_found', ...};否则以 state_of(fm) 作 src 调 stamp——stamp 不 ok 返回 ok=False + error=code,code == "noop" 返回 ok=True + changed=False;其余情况(合法且真迁移,即**资格面已过**)先经 autonomy_modes.e_gate() 档位判定(补强批次 v1.2·E 面接线,位置=写盘之前):非 ALLOW(plan 档无计划)即经 forbidden_result() fail-closed 早退(ok=False、error=autonomy_forbidden、零写盘、零审计行),ALLOW(confirm/full)才取 fm[HISTORY_FIELD][-1]["at"] 后写盘、_sync_index 并追加审计 JSONL(append_jsonl 抛异常被吞掉),返回 ok=True + changed=True。
206
206
  def set_state(cg, node_id: str, dst: str, reason: str = None,
207
207
  actor: str = None, override: bool = False) -> dict:
208
208
  """推进一个节点的生命周期状态(**唯一推进入口**)。
@@ -210,6 +210,13 @@ def set_state(cg, node_id: str, dst: str, reason: str = None,
210
210
  非法迁移不走异常而是返回 `{"ok": False, "error": <code>, ...}`(负路由:
211
211
  调用方自行决定降级处理;要硬拒请用 `require_transition`)。幂等迁移返回
212
212
  `changed=False` 且不写盘。返回体始终带 `from` / `to` / `code` 便于审计。
213
+
214
+ **档位闸(补强批次 v1.2·E 面接线)**:合法且真迁移(`stamp` 已过=资格面
215
+ 已过)之后、写盘之前,先经 `autonomy_modes.e_gate()`(设计 §三 plan 档
216
+ E「须命中计划步骤」)——plan 档无计划 ⇒ fail-closed 早退(
217
+ `ok=False`/`error="autonomy_forbidden"`/带 hint、零写盘、零审计行);
218
+ confirm/full ⇒ ALLOW、**原链原样**(逐位不变)。非法迁移与 noop 由
219
+ `stamp` 先行裁决、各回各的 code(档位**不参与**资格判定)。
213
220
  """
214
221
  node = cg.get(node_id)
215
222
  if not node:
@@ -222,6 +229,15 @@ def set_state(cg, node_id: str, dst: str, reason: str = None,
222
229
  return {**base, "ok": False, "error": code, "reason": why}
223
230
  if code == "noop":
224
231
  return {**base, "ok": True, "changed": False, "reason": why}
232
+ # 三档自治(补强批次 v1.2·E 面接线,设计 §三 plan 档 E「须命中计划步骤」):
233
+ # 位置=资格面(`stamp` 的合法性/保护裁决)之后、写盘之前——非法迁移与 noop
234
+ # 已在上方各回各的 code,档位不参与资格判定;confirm/full ⇒ ALLOW 原链原样。
235
+ from . import autonomy_modes as _am
236
+ _dec = _am.e_gate()
237
+ if _dec["decision"] != _am.ALLOW:
238
+ out = _am.forbidden_result(_dec, node_id=node_id, changed=False)
239
+ out.update({"from": src, "to": dst, "code": "autonomy_forbidden"})
240
+ return out
225
241
  at = fm[HISTORY_FIELD][-1]["at"]
226
242
  cg._write_node(node_id, os.path.join(cg.root, node["path"]), fm,
227
243
  node.get("content") or "")
@@ -236,13 +252,18 @@ def set_state(cg, node_id: str, dst: str, reason: str = None,
236
252
  return {**base, "ok": True, "changed": True, "reason": why, "at": at}
237
253
 
238
254
 
239
- # 生效条件:nodes 取 (cg.index or {}).get("nodes") or {},遍历中缺 STATE_FIELD 或该值不在 STATES 的 nid 计入 missing(累计数达 limit 即 break,limit <= 0 时立即 break 使 missing 为空),apply 假值只返回 dry_run=True 的盘点结果,apply 为真时对每个 missing 中 cg.get 取不到的记入 failed、取到的把 state_of(fm) 显式写回 fm[STATE_FIELD] 并写盘与 _sync_index 后记入 done,返回 dry_run=False 的结果。
255
+ # 生效条件:nodes 取 (cg.index or {}).get("nodes") or {},遍历中缺 STATE_FIELD 或该值不在 STATES 的 nid 计入 missing(累计数达 limit 即 break,limit <= 0 时立即 break 使 missing 为空),apply 假值只返回 dry_run=True 的盘点结果(**不接档位闸**——零写盘面照旧),apply 为真时先经 autonomy_modes.e_gate() 档位判定(补强批次 v1.2·E 面接线,位置=盘点资格面之后、写盘之前):非 ALLOW(plan 档无计划)即经 forbidden_result() fail-closed 早退(零写盘、backfilled=0),ALLOW(confirm/full)才对每个 missing 中 cg.get 取不到的记入 failed、取到的把 state_of(fm) 显式写回 fm[STATE_FIELD] 并写盘与 _sync_index 后记入 done,返回 dry_run=False 的结果。
240
256
  def backfill(cg, apply: bool = False, limit: int = 5000) -> dict:
241
257
  """存量回填:把缺 `state` 的节点显式补成 `active`(幂等)。
242
258
 
243
259
  缺字段本就按 active 解释(`state_of`),故回填**不是让功能工作的前提**,
244
260
  而是把「缺省」变成「显式」——索引/审计里从此可直接读到状态。
245
261
  `apply=False`(默认)只盘点。写不动(文件取不回)的节点进 `failed` 如实上报。
262
+
263
+ **档位闸(补强批次 v1.2·E 面接线)**:`apply=True` 的**写盘前**先经
264
+ `autonomy_modes.e_gate()`(设计 §三 plan 档 E「须命中计划步骤」)——plan 档
265
+ 无计划 ⇒ fail-closed 早退(零写盘、`backfilled=0`);confirm/full ⇒ ALLOW、
266
+ 原链原样。`apply=False`(盘点)不接闸。
246
267
  """
247
268
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
248
269
  missing = []
@@ -256,6 +277,13 @@ def backfill(cg, apply: bool = False, limit: int = 5000) -> dict:
256
277
  if not apply:
257
278
  return {"ok": True, "dry_run": True, "scanned": len(nodes),
258
279
  "missing": len(missing), "planned": missing[:50]}
280
+ # 三档自治(补强批次 v1.2·E 面接线):E 类动作**写盘前**的档位判定单点
281
+ # (同 `set_state` 口径)——盘点(资格面)已完成、任何盘面写入之前。
282
+ from . import autonomy_modes as _am
283
+ _dec = _am.e_gate()
284
+ if _dec["decision"] != _am.ALLOW:
285
+ return _am.forbidden_result(_dec, dry_run=False, scanned=len(nodes),
286
+ missing=len(missing), backfilled=0)
259
287
  done, failed = [], []
260
288
  for nid in missing:
261
289
  node = cg.get(nid)