@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
@@ -19,6 +19,8 @@
19
19
  * 外部裁决回填 → MdcgClient.verify() → MCP cg(op=verify)
20
20
  * 最近记忆时间线 → MdcgClient.timeline() → MCP stg(op=timeline)
21
21
  * 近期事件窗口 → MdcgClient.recent() → MCP cg(op=recent)
22
+ * 窗口追加事件 → MdcgClient.recentAdd() → MCP cg(op=recent, action=add)
23
+ * 会话续接包 → MdcgClient.sessionRecall() → MCP cg(op=session, action=recall)
22
24
  * 身份读取 → MdcgClient.identity() → MCP cg(op=identity)
23
25
  * 白箱能力验证 → MdcgClient.whitebox() → MCP cg(op=whitebox)
24
26
  * 服务信息 → MdcgClient.serviceInfo()→ MCP cg(op=info)
@@ -215,6 +217,48 @@ export class MdcgClient {
215
217
  recent(limit = 20) {
216
218
  return this.cg({ op: 'recent', limit });
217
219
  }
220
+ /** 追加一条**近期事件窗口**记录(`cg(op=recent, action=add)` →
221
+ * `md_cg` 的 `remember_event(role, text, tags, meta, window)`)。
222
+ *
223
+ * 这是「滑动窗口」写侧:事件落 `_recent.jsonl`(**运行态面**),按窗口滚动
224
+ * 淘汰(服务端缺省 200 条,`mdcg.py:91 DEFAULT_RECENT_WINDOW`)——它**不是
225
+ * 知识节点**:不占 knowledge 层、不进检索正排,与 `remember()` 的知识面沉淀
226
+ * 是两条独立的轨道(见 src/hooks.ts 文件头的 contextWindow 头注)。
227
+ *
228
+ * ⚠️ **不注入 as_unit**(与 `write()` 同款理由的反面):本调用不写任何层的
229
+ * 节点(服务端 `remember_event` 不做层白名单校验),收窄单元无收益且可能压低
230
+ * 事件密级(缺省 internal);`meta.session` 由调用方显式给出(沿本文件
231
+ * `remember()` 的 sessionTag 口径)。
232
+ *
233
+ * ⚠️ 服务端对 `meta` 做 `setdefault`(tenant/session/harness/unit,见
234
+ * `mdcos.py:5057 remember_event`):调用方已写的键**不被覆盖**。 */
235
+ recentAdd(role, text, meta = {}, tags = []) {
236
+ return this.cg({ op: 'recent', action: 'add', role, text, meta, tags });
237
+ }
238
+ /** **会话续接包**(`cg(op=session, action=recall)` → `md_cg` 的
239
+ * `session_recall`):一次取回 notes / goals / tasks / **recent 事件窗口** /
240
+ * unresolved / self_state,按 `budget_tokens` 整包裁剪(服务端
241
+ * `mdcos.py:3351-3362`:交替丢 recent / notes 尾部)。
242
+ *
243
+ * 本插件消费其中的 `recent` 段——滑动窗口的**注入面**(见 src/hooks.ts)。
244
+ * `recent_limit` 语义 = 近期事件**条数**(非「轮数」);服务端读取时会按
245
+ * `max(1, recent_limit or 10)` 归一(`mdcos.py:3309`)。
246
+ *
247
+ * ⚠️ 两条服务端事实(决定注入侧的可达性,勿据本方法名臆测):
248
+ * ① `recent` 段取 `recent_events(limit=recent_limit)` —— **不按 session
249
+ * 过滤**(近期事件是运行态滚动窗口,会话归属只写在每条事件的 meta 里);
250
+ * ② budget 是**整包**预算:库内 notes/tasks/self_state 占位越多,同样
251
+ * budget 下 recent 段被裁得越短(实测:空库 + 10 条窗口条目,
252
+ * budget_tokens=600 → recent 8 条;1200 → 10 条)。
253
+ *
254
+ * 只读调用,不注入 as_unit(同其它读路径:读无副作用,收窄只会压低 owner
255
+ * 的 private 读能力)。 */
256
+ sessionRecall(session, recentLimit, budgetTokens) {
257
+ return this.cg({
258
+ op: 'session', action: 'recall',
259
+ session, recent_limit: recentLimit, budget_tokens: budgetTokens,
260
+ });
261
+ }
218
262
  /** 最近记忆**时间线**(AEIS `timeline` 的对应物):`stg(op=timeline)` →
219
263
  * `{count, limit, items:[{id, layer, start, end, preview}]}`,按时间倒序。
220
264
  *
package/md_cg/audit.py CHANGED
@@ -456,11 +456,17 @@ def _verify_code(payload, ctx):
456
456
  # 被测命令只要输出非 gbk 字节,读取线程就抛 UnicodeDecodeError →
457
457
  # p.stdout/p.stderr 可能为空 → 下一行的失败证据丢失,
458
458
  # 「实测失败」会退化成一句没有依据的 REJECT(对齐 whitebox.py 的写法)。
459
+ # stdin=subprocess.DEVNULL(issue #63 同批加固):不指定 stdin 时子进程
460
+ # 继承父进程 stdin(常驻宿主下是 JSON-RPC 活管道),读 stdin 的测试
461
+ # 命令会悬挂到 timeout 才被 kill——形态对齐 `md_cg/run_tests.py:105`。
459
462
  p = subprocess.run(argv, cwd=ctx.get("cwd"), capture_output=True,
460
463
  text=True, encoding="utf-8", errors="replace",
461
- shell=False,
464
+ shell=False, stdin=subprocess.DEVNULL,
462
465
  timeout=int(os.environ.get("MDCG_CODE_TEST_TIMEOUT", "60")))
463
466
  except (OSError, subprocess.SubprocessError) as exc:
467
+ # TimeoutExpired(SubprocessError 子类)也走这里:DEFER + 文案带异常
468
+ # 名与原文(如 "TimeoutExpired: Command '…' timed out after 60 seconds")
469
+ # ——超时**不吞**(issue #63)。
464
470
  return _verdict(DEFER, "code", f"测试无法执行:{type(exc).__name__}: {exc}")
465
471
  if p.returncode == 0:
466
472
  return _verdict(ACCEPT, "code", f"实测通过:{cmd}")
@@ -315,9 +315,12 @@ def run(root=None, keep=False):
315
315
 
316
316
  def _git_head():
317
317
  try:
318
+ # timeout=10 既有;stdin=subprocess.DEVNULL 为 issue #63 同批加固——
319
+ # 不指定 stdin 时子进程继承父进程 stdin(常驻宿主下是 JSON-RPC 活管道)。
318
320
  out = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
319
321
  capture_output=True, text=True, encoding="utf-8",
320
- errors="replace", shell=False, timeout=10)
322
+ errors="replace", shell=False, timeout=10,
323
+ stdin=subprocess.DEVNULL)
321
324
  return (out.stdout or "").strip() or "unknown"
322
325
  except Exception: # noqa: BLE001
323
326
  return "unknown"
@@ -303,6 +303,9 @@ ROW_RE = re.compile(
303
303
  r"([\d.]+)%\s+([\d.]+)%\s+([\d.]+)\s*$", re.M)
304
304
  GATE_RE = re.compile(r"拒答率:([\d.]+)%")
305
305
  LINE_RE = re.compile(r"拒答线(正例 hit@1 题 Top-1 分 p10):([\d.]+)")
306
+ #: 单臂评测子进程的超时(秒)——issue #63 同批加固:评测跑批比 git 长,按
307
+ #: 「一臂为分钟级」给 1800s(30min)为安全上界;超时即 kill(不再是无限等待)。
308
+ _EVAL_TIMEOUT_S = 1800
306
309
 
307
310
 
308
311
  # 生效条件:RUST_BIN 经 os.path.exists 为假时抛 SystemExit;否则以 argv 列表 [RUST_BIN, "--dataset", "lc", "--tag", name, "--lib", lib, "--qfile", qfile](extra 为真值时追加其元素)执行 subprocess.run,返回码非 0 或 stdout 未匹配 ROW_RE 时抛 SystemExit,成功时返回 (got, neg, line),其中 GATE_RE/LINE_RE 未命中时对应值为 None。
@@ -320,8 +323,15 @@ def run_one(name, lib, qfile, extra=None):
320
323
  if extra:
321
324
  argv += list(extra)
322
325
  env = dict(os.environ, PYTHONUTF8="1")
323
- p = subprocess.run(argv, capture_output=True, text=True,
324
- encoding="utf-8", errors="replace", env=env, cwd=HERE)
326
+ try:
327
+ # timeout + stdin=DEVNULL(issue #63 同批加固):评测跑批有界化,
328
+ # 且不继承父进程 stdin(常驻宿主下是 JSON-RPC 活管道)。
329
+ p = subprocess.run(argv, capture_output=True, text=True,
330
+ encoding="utf-8", errors="replace", env=env, cwd=HERE,
331
+ timeout=_EVAL_TIMEOUT_S,
332
+ stdin=subprocess.DEVNULL)
333
+ except subprocess.TimeoutExpired:
334
+ raise SystemExit(f"[失败] {name}:评测超时(>{_EVAL_TIMEOUT_S}s)已被 kill")
325
335
  if p.returncode != 0:
326
336
  raise SystemExit(f"[失败] {name}:{(p.stderr or '')[-900:]}")
327
337
  got = {}
@@ -481,6 +481,9 @@ ROW_RE = re.compile(
481
481
  r"^\s*(precise|temporal|interference|reference)\s+(\d+)\s+"
482
482
  r"([\d.]+)%\s+([\d.]+)%\s+([\d.]+)\s*$", re.M)
483
483
  GROUPS = ["precise", "temporal", "interference", "reference"]
484
+ #: 单臂评测子进程的超时(秒)——issue #63 同批加固:评测跑批比 git 长,按
485
+ #: 「一臂为分钟级」给 1800s(30min)为安全上界;超时即 kill(不再是无限等待)。
486
+ _EVAL_TIMEOUT_S = 1800
484
487
 
485
488
 
486
489
  # 生效条件:os.path.exists(RUST_BIN) 为真时以 argv=[RUST_BIN,"--dataset","mad","--tag",name,"--lib",lib](extra 为真值时追加 list(extra))执行 subprocess,返回码非 0 或 ROW_RE 在 stdout 未匹配到任何组时 raise SystemExit,否则返回 {组:(n,hit@1,hit@5,MRR)}。
@@ -498,8 +501,15 @@ def run_one(name, lib, extra=None):
498
501
  if extra:
499
502
  argv += list(extra)
500
503
  env = dict(os.environ, PYTHONUTF8="1")
501
- p = subprocess.run(argv, capture_output=True, text=True,
502
- encoding="utf-8", errors="replace", env=env, cwd=HERE)
504
+ try:
505
+ # timeout + stdin=DEVNULL(issue #63 同批加固):评测跑批有界化,
506
+ # 且不继承父进程 stdin(常驻宿主下是 JSON-RPC 活管道)。
507
+ p = subprocess.run(argv, capture_output=True, text=True,
508
+ encoding="utf-8", errors="replace", env=env, cwd=HERE,
509
+ timeout=_EVAL_TIMEOUT_S,
510
+ stdin=subprocess.DEVNULL)
511
+ except subprocess.TimeoutExpired:
512
+ raise SystemExit(f"[失败] {name}:评测超时(>{_EVAL_TIMEOUT_S}s)已被 kill")
503
513
  if p.returncode != 0:
504
514
  raise SystemExit(f"[失败] {name}:{(p.stderr or '')[-900:]}")
505
515
  got = {}
package/md_cg/chain.py CHANGED
@@ -158,8 +158,8 @@ def node_conditions(cg, nid):
158
158
  return pos
159
159
 
160
160
 
161
- # 生效条件:cg 已有 _chain_adj 且其 [0] 等于 bool(include_hierarchy)、[1] 等于可见性闸存在位时直接返回缓存的 [2];否则以 cg.index["nodes"](无 index 或无该键时视为无节点)逐节点收集 frontmatter.edges 中 edge_target 非空的出边,include_hierarchy 为真时再为 subgraph.nodes 各合成一条 relation_type="part_of"、confidence=1.0 的层级边,仅对有出边的 nid 建表,写回 cg._chain_adj=(bool(include_hierarchy), 闸存在位, adj) 后返回 adj;cg 提供 _chain_visible(nid) 谓词(读隔离,MdCGSecure 注入)时不可见 nid 的出边整体不入表、指向不可见目标的边(含层级合成边)截断——不可见节点 id、其边条件与下游拓扑对调用方不存在(walk/explain/causal_path/expand_from_seeds 全部消费者同闸);
162
- def adjacency(cg, include_hierarchy=True):
161
+ # 生效条件:cg 已有 _chain_adj 且其 [0] 等于 bool(include_hierarchy)、[1] 等于可见性闸存在位、[2] 等于 bool(skip_archived) 时直接返回缓存的 [3];否则以 cg.index["nodes"](无 index 或无该键时视为无节点)逐节点收集 frontmatter.edges 中 edge_target 非空的出边(skip_archived=False 时该轮退役过滤整体旁路——维护面全量口径),include_hierarchy 为真时再为 subgraph.nodes 各合成一条 relation_type="part_of"、confidence=1.0 的层级边,仅对有出边的 nid 建表,写回 cg._chain_adj=(bool(include_hierarchy), 闸存在位, bool(skip_archived), adj) 后返回 adj;skip_archived=True(缺省)时 lifecycle.is_archived 为真的节点出边整体不入表、指向退役目标的边(含层级合成边)截断(判据单点 lifecycle.is_archived,缺键=active fail-open;退役不删除、可显式恢复);cg 提供 _chain_visible(nid) 谓词(读隔离,MdCGSecure 注入)时不可见 nid 的出边整体不入表、指向不可见目标的边(含层级合成边)截断——不可见节点 id、其边条件与下游拓扑对调用方不存在(walk/explain/causal_path/expand_from_seeds 全部消费者同闸);
162
+ def adjacency(cg, include_hierarchy=True, skip_archived=True):
163
163
  """出邻接表:nid → [(target_id, edge_dict)]。
164
164
 
165
165
  来源两处:
@@ -172,8 +172,8 @@ def adjacency(cg, include_hierarchy=True):
172
172
  (`cg.index["nodes"]`),不是「只碰种子邻域」。故契约 S3「不走全表」这一句
173
173
  对**本函数不成立**,正确表述是「不读正文;邻接构建为 O(N) 索引条目级」。
174
174
  代价读数(`scripts/p2p4_probe.py` R7,400 条目确定性小库):冷建 ~0.24ms、
175
- 缓存命中 ~1µs;缓存键 `(include_hierarchy, 闸存在位)` 存于 `cg._chain_adj`,
176
- 写入/删除后由 `invalidate_cache` 作废。
175
+ 缓存命中 ~1µs;缓存键 `(include_hierarchy, 闸存在位, skip_archived)` 存于
176
+ `cg._chain_adj`,写入/删除后由 `invalidate_cache` 作废。
177
177
  (可选硬化路径 (b1):在索引快照的**边集合**上增量构建邻接——本轮未做,
178
178
  如需做见 P3 遗留 B 的二选一。)
179
179
 
@@ -184,17 +184,29 @@ def adjacency(cg, include_hierarchy=True):
184
184
  目标的边截断;谓词缺席(基类)行为零变化。缓存键含闸存在位;闸语义
185
185
  实例内稳定(_readable 绑定档判定恒用 principal.session,见 mdcos
186
186
  _readable),同一实例不会跨身份串台。
187
+
188
+ 退役纪律(《秤》v2.1 §5.2;2026-10-06 接线):`skip_archived=True`(缺省)时
189
+ archived 节点出边整体不入表、指向 archived 目标的边截断——`cg(op=causal,
190
+ action=chain)` 等**读/预测面**(chain()/predict.*)由此不再扩散进退役节点;
191
+ **维护面显式旁路**:`scrub`(去污染抽查,按度数排序的维护写面)传
192
+ `skip_archived=False` 取全量表——退役节点仍可被抽查(判据面 = 默认读/注入
193
+ 面必消费、维护/管理面直读,见运维文档 §九.5 面的判据)。判据单点
194
+ `lifecycle.is_archived`(缺键=active fail-open)。
187
195
  """
188
196
  cached = getattr(cg, "_chain_adj", None)
189
197
  vis = getattr(cg, "_chain_visible", None)
190
198
  vis_on = vis is not None
191
199
  if (cached is not None and cached[0] == bool(include_hierarchy)
192
- and cached[1] == vis_on):
193
- return cached[2]
200
+ and cached[1] == vis_on
201
+ and cached[2] == bool(skip_archived)):
202
+ return cached[3]
194
203
  from . import subgraph as _sg
204
+ from . import lifecycle as _lc
195
205
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
196
206
  adj = {}
197
207
  for nid in list(nodes):
208
+ if skip_archived and _lc.is_archived(nodes.get(nid)):
209
+ continue # 退役节点:其出边与条件整体不入表(不解析 fm)
198
210
  if vis is not None and not vis(nid):
199
211
  continue # 不可见节点:其出边与条件整体不可见(不解析 fm)
200
212
  fm = _sg._fm(cg, nid)
@@ -202,11 +214,15 @@ def adjacency(cg, include_hierarchy=True):
202
214
  for e in (fm.get("edges") or []):
203
215
  tgt = edge_target(e)
204
216
  if tgt:
217
+ if skip_archived and _lc.is_archived(nodes.get(tgt)):
218
+ continue # 拓扑边界:指向退役目标的边径直截断
205
219
  if vis is not None and not vis(tgt):
206
220
  continue # 拓扑边界:指向不可见目标的边对调用方不存在
207
221
  out.append((tgt, e if isinstance(e, dict) else {"target": tgt}))
208
222
  if include_hierarchy:
209
223
  for ch in _sg.declared(fm)["nodes"]:
224
+ if skip_archived and _lc.is_archived(nodes.get(ch)):
225
+ continue
210
226
  if vis is not None and not vis(ch):
211
227
  continue
212
228
  out.append((ch, {"target": ch, "relation_type": "part_of",
@@ -214,7 +230,7 @@ def adjacency(cg, include_hierarchy=True):
214
230
  if out:
215
231
  adj[nid] = out
216
232
  try:
217
- cg._chain_adj = (bool(include_hierarchy), vis_on, adj)
233
+ cg._chain_adj = (bool(include_hierarchy), vis_on, bool(skip_archived), adj)
218
234
  except Exception:
219
235
  pass
220
236
  return adj
@@ -25,11 +25,17 @@
25
25
  闸门 1 · grounding 支撑度(确定性):候选短语必须能在节点正文里找到字符级依据,
26
26
  否则判为幻觉 → REJECT。(对应「不猜测」)
27
27
  闸门 2 · replay 回放(确定性):把候选条件当作查询,回放生产检索路径的判定:
28
- · pos_recall 以「生效条件」为查询 → 本节点应被召回,且不被自身负条件挡住;
28
+ · pos_recall 以「生效条件」为查询 → 本节点应被召回(issue #64:原式另带
29
+ 「负条件一票否决」因子,过严误杀共享主题词的合格负条件,
30
+ 已撤销——自否定职责由 no_conflict 承担);
29
31
  · neg_separated 以「不适用条件」为查询 → 应触发条件级负路由,且负条件与正文
30
32
  低相关(负条件必须是「域外」的,不能把知识本身否定掉);
31
33
  · no_conflict 生效条件与不适用条件不得互相覆盖。
32
34
  三者同时成立才算「条件稳定」。(对应「回放 / 断言 / 回归」)
35
+ 闸门 2 前另设**负条件两态拦截**(issue #64,见 consolidate 主流程):
36
+ 候选未产出负条件(neg_absent)/ 负条件被 grounding 删光(neg_dropped_all)
37
+ → 直接 REJECT,不进 replay、不进验证单元、不落盘——「负条件被删光」不得
38
+ 被 replay 的空集短路翻译成「通过」(否则缺「不适用条件」要素的节点仍会落盘)。
33
39
  闸门 3 · 验证单元(GLM,独立模型):逐条核验候选是否有正文依据、负条件是否真域外。
34
40
  硬约束:**验证单元只能否决,不能新增/改写**——它没有产出权,
35
41
  否则验证环节自己就成了新的幻觉源。
@@ -284,6 +290,8 @@ def http_llm(prompt: str, model: str = None, base: str = None, key: str = None,
284
290
  max_tokens=None 走三级解析(显式 > MDCG_LLM_MAX_TOKENS > DEFAULT_MAX_TOKENS);
285
291
  历史 bug(issue #24):曾硬编码 1200——思考模型 reasoning 吃光预算,
286
292
  content 空串静默落成 parse_failed,离线固化 100% DEFER。
293
+ timeout 由调用方注入(CLI --timeout / --reflect-timeout / --verify-timeout,
294
+ 默认 120 秒)——issue #64:此前 CLI 两 lambda 均未传,恒为硬默认、不可配。
287
295
  """
288
296
  if role:
289
297
  model, base, key = role_config(role, model, base, key)
@@ -496,20 +504,25 @@ def grounding_filter(cand: dict, body: str, thresholds: dict = None):
496
504
  return kept, detail
497
505
 
498
506
 
499
- # 生效条件:给定 pos_terms、neg_terms、body,返回含 pos_recall、neg_separated、no_conflict、ok 的回放判定字典。
507
+ # 生效条件:给定 pos_terms、neg_terms、body,返回含 pos_recall、neg_separated、no_conflict、ok 的回放判定字典;pos_recall 只认「生效条件在正文上有覆盖率」,不含负条件一票否决。
500
508
  def replay_check(pos_terms, neg_terms, body: str) -> dict:
501
509
  """回放生产判定:正例召回 + 负例剔除 + 无自相矛盾。
502
510
 
503
511
  复用 _path_semantic 的同一批原语,保证与生产路同源(P6 与真实路做一致性回归)。
512
+
513
+ issue #64(2026-10-06):pos_recall 原式带 `and not _neg_hit(tw_pos, neg_terms)`
514
+ 一票否决——「生效条件任一词整词出现在不适用条件里」即判负;而负条件描述的正是
515
+ **邻近易混情境**,与生效条件共享领域主题词是结构必然(不共享主题词就谈不上「邻近」),
516
+ 该因子把大量合格负条件误杀为「正例召回失败」。自否定职责已由第 3 条 no_conflict
517
+ 以覆盖率口径(<0.5 才算互相覆盖)承担——本因子与它同意图且更严,故撤销。
504
518
  """
505
519
  pos_text = " ".join(pos_terms or [])
506
520
  neg_text = " ".join(neg_terms or [])
507
521
  tw_pos = expand_query_terms_weighted(pos_text) if pos_text else {}
508
522
  tw_neg = expand_query_terms_weighted(neg_text) if neg_text else {}
509
523
 
510
- # 1. 正例:以生效条件为查询,本节点正文应被命中,且不被自身负条件挡住
511
- pos_recall = bool(pos_text) and _weighted_coverage(tw_pos, body) > 0.0 \
512
- and not _neg_hit(tw_pos, neg_terms)
524
+ # 1. 正例:以生效条件为查询,本节点正文应被命中(不含负条件一票否决,见 docstring)
525
+ pos_recall = bool(pos_text) and _weighted_coverage(tw_pos, body) > 0.0
513
526
 
514
527
  # 2. 负例:以不适用条件为查询,应触发条件级负路由;且负条件与正文低相关
515
528
  # (负条件必须是「域外」的,若与正文强相关,等于让知识否定自己)
@@ -652,7 +665,7 @@ def _apply_node(cg, e, fm: dict, content: str, kept: dict, prov: dict,
652
665
  before=before, after=evolution.state_of(cg, nid) or {})
653
666
 
654
667
 
655
- # 生效条件:给定 root,扫描正排层节点并执行反思→白箱闸门→验证→固化,返回报表 rep;require_verify=True 且无 verify_fn 时全部 DEFER。
668
+ # 生效条件:给定 root,扫描正排层节点并执行反思→白箱闸门(含负条件两态拦截)→验证→固化,返回报表 rep;require_verify=True 且无 verify_fn 时全部 DEFER。
656
669
  def consolidate(root: str, layer: str = None, limit: int = None, apply: bool = False,
657
670
  overwrite: bool = False, llm_fn=None, reflect_fn=None,
658
671
  verify_fn=None, reflect_model: str = "", verify_model: str = "",
@@ -663,6 +676,10 @@ def consolidate(root: str, layer: str = None, limit: int = None, apply: bool = F
663
676
 
664
677
  llm_fn 是 reflect_fn 的旧名(向后兼容,单模型模式)。
665
678
  require_verify=True 且无 verify_fn → 一律 DEFER(纪律 5:未经验证不固化)。
679
+ 白箱闸门内先过**负条件两态拦截**(issue #64):候选未产出负条件 → reasons
680
+ 记 neg_absent;负条件被 grounding 删光 → reasons 记 neg_dropped_all;两态
681
+ 都直接 REJECT(不进 replay / 不进验证单元 / 不落盘)——缺「不适用条件」
682
+ 要素的节点不得因空集短路被判「通过」。
666
683
  """
667
684
  reflect_fn = reflect_fn or llm_fn
668
685
  cg = MdCGOS(root)
@@ -728,6 +745,36 @@ def consolidate(root: str, layer: str = None, limit: int = None, apply: bool = F
728
745
  kept, gdetail = grounding_filter(cand, body, thresholds)
729
746
  pos = kept.get("生效条件") or []
730
747
  neg = kept.get("不适用条件") or []
748
+
749
+ # 2a) 负条件两态拦截(issue #64,2026-10-06):「不适用条件」是 CCG 必需要素,
750
+ # 候选**未产出**(neg_absent)或**产出了但被 grounding 删光**(neg_dropped_all)
751
+ # 时都必须 REJECT——不进 replay、不进验证单元、不落盘。
752
+ # 修复前把空 neg 交给 replay_check,命中其 `else: neg_separated = True` 短路,
753
+ # 「负条件被删光/未产出」被翻译成「通过」,缺要素节点照常落盘并携带
754
+ # verification_basis 声明(B 类实测:accepted=1/written=1,正文无
755
+ # 「# 不适用条件」行)。两态各自计数,便于报表区分「模型没产出」与
756
+ # 「产出被 grounding 判为幻觉丢弃」。
757
+ neg_cand = cand.get("不适用条件") or []
758
+ if not isinstance(neg_cand, list):
759
+ neg_cand = [neg_cand]
760
+ neg_cand = [t for t in neg_cand if str(t).strip()]
761
+ if not neg_cand:
762
+ rep["rejected"] += 1
763
+ _bump("neg_absent")
764
+ if verbose and len(rep["samples"]) < 8:
765
+ rep["samples"].append({"id": nid, "verdict": "REJECT",
766
+ "stage": "whitebox", "reason": "neg_absent",
767
+ "grounding": gdetail, "replay": None})
768
+ continue
769
+ if not neg:
770
+ rep["rejected"] += 1
771
+ _bump("neg_dropped_all")
772
+ if verbose and len(rep["samples"]) < 8:
773
+ rep["samples"].append({"id": nid, "verdict": "REJECT",
774
+ "stage": "whitebox", "reason": "neg_dropped_all",
775
+ "grounding": gdetail, "replay": None})
776
+ continue
777
+
731
778
  replay = replay_check(pos, neg, body)
732
779
  if not kept or not replay["ok"]:
733
780
  rep["rejected"] += 1
@@ -1477,9 +1524,21 @@ def _cli(argv=None) -> int:
1477
1524
  ap.add_argument("--min-grounding", type=float, default=None,
1478
1525
  help="统一 grounding 阈值(默认按字段 0.5 / 不适用条件 0.34)")
1479
1526
  ap.add_argument("--max-tokens", type=int, default=None,
1480
- help=f"LLM 输出预算(含思考模型 reasoning_tokens;默认 "
1527
+ help=f"LLM 输出预算共用缺省(含思考模型 reasoning_tokens;默认 "
1481
1528
  f"{DEFAULT_MAX_TOKENS},可 env {MAX_TOKENS_ENV} 覆盖;"
1482
- "子代理配置标准 v0.5 §1)")
1529
+ "子代理配置标准 v0.5 §1);两角色可分别用 "
1530
+ "--reflect-max-tokens / --verify-max-tokens 单设")
1531
+ ap.add_argument("--reflect-max-tokens", type=int, default=None,
1532
+ help="反思单元输出预算(缺省回落 --max-tokens)")
1533
+ ap.add_argument("--verify-max-tokens", type=int, default=None,
1534
+ help="验证单元输出预算(缺省回落 --max-tokens)")
1535
+ ap.add_argument("--timeout", type=float, default=120,
1536
+ help="LLM HTTP 超时秒数(缺省 120);两角色可分别用 "
1537
+ "--reflect-timeout / --verify-timeout 单设")
1538
+ ap.add_argument("--reflect-timeout", type=float, default=None,
1539
+ help="反思单元 HTTP 超时(缺省回落 --timeout)")
1540
+ ap.add_argument("--verify-timeout", type=float, default=None,
1541
+ help="验证单元 HTTP 超时(缺省回落 --timeout)")
1483
1542
  ap.add_argument("--no-llm", action="store_true",
1484
1543
  help="不调用 LLM,只做四要素完整性普查")
1485
1544
  ap.add_argument("--check", action="store_true",
@@ -1530,16 +1589,28 @@ def _cli(argv=None) -> int:
1530
1589
  f"({_ROLE_ENV[REFLECT_ROLE][2]} / DEEPSEEK_API_KEY)→ 退化为普查模式",
1531
1590
  file=sys.stderr)
1532
1591
  else:
1592
+ # 单值 --max-tokens / --timeout 继续作两角色共用缺省;角色级参数缺省
1593
+ # None → 回落共用缺省。issue #64:此前单值被同时喂 reflect/verify,
1594
+ # timeout 恒为 http_llm 默认 120 不可配(生产两 lambda 均未传)——
1595
+ # 两侧预算/超时需求可不同,须能分别单设。
1596
+ refl_mt = (a.reflect_max_tokens if a.reflect_max_tokens is not None
1597
+ else a.max_tokens)
1598
+ refl_to = (a.reflect_timeout if a.reflect_timeout is not None
1599
+ else a.timeout)
1533
1600
  reflect_fn = (lambda p: http_llm(p, role=REFLECT_ROLE, # noqa: E731
1534
1601
  model=a.reflect_model,
1535
- max_tokens=a.max_tokens))
1602
+ max_tokens=refl_mt, timeout=refl_to))
1536
1603
  if a.self_verify:
1537
1604
  verify_fn = reflect_fn
1538
1605
  elif not a.no_verify:
1539
1606
  if v_key:
1607
+ ver_mt = (a.verify_max_tokens
1608
+ if a.verify_max_tokens is not None else a.max_tokens)
1609
+ ver_to = (a.verify_timeout if a.verify_timeout is not None
1610
+ else a.timeout)
1540
1611
  verify_fn = (lambda p: http_llm(p, role=VERIFY_ROLE, # noqa: E731
1541
1612
  model=a.verify_model,
1542
- max_tokens=a.max_tokens))
1613
+ max_tokens=ver_mt, timeout=ver_to))
1543
1614
  else:
1544
1615
  print(f"[consolidate] 验证单元未配置 key"
1545
1616
  f"({_ROLE_ENV[VERIFY_ROLE][2]} / ZHIPU_API_KEY / GLM_API_KEY)"
package/md_cg/interop.py CHANGED
@@ -47,6 +47,11 @@ _FORBIDDEN_RES = [
47
47
  ]
48
48
  _FORBIDDEN_KEYS = {"prompt", "content", "spec", "api_key", "base", "model",
49
49
  "system_prompt", "user_prompt", "content_head"}
50
+ #: git add/commit 子进程的超时(秒)——issue #63 同批加固:本机 git 为秒级
51
+ #: 操作,120s 为安全上界;超时即 kill。**stdin=subprocess.DEVNULL 同加**:
52
+ #: 不指定 stdin 时子进程继承父进程 stdin(常驻宿主下是 JSON-RPC 活管道),
53
+ #: 读 stdin 的子进程会悬挂(先例形态 `md_cg/run_tests.py:105`)。
54
+ _GIT_TIMEOUT_S = 120
50
55
 
51
56
 
52
57
  class InteropSanityError(ValueError):
@@ -323,15 +328,26 @@ def write_verdict_to_repo(verdict: dict, repo: str = HERE,
323
328
  out = {"ok": True, "path": fp}
324
329
  if do_commit:
325
330
  import subprocess
326
- r1 = subprocess.run(["git", "add", os.path.relpath(fp, repo)],
327
- cwd=repo, capture_output=True, text=True,
328
- encoding="utf-8", errors="replace")
329
- r2 = subprocess.run(
330
- ["git", "commit", "-q", "-m",
331
- f"interop(verifier): iter={it} verdict={verdict.get('verdict')} "
332
- f"passed={verdict.get('passed')} failed={verdict.get('failed')}"],
333
- cwd=repo, capture_output=True, text=True,
334
- encoding="utf-8", errors="replace")
331
+ try:
332
+ r1 = subprocess.run(["git", "add", os.path.relpath(fp, repo)],
333
+ cwd=repo, capture_output=True, text=True,
334
+ encoding="utf-8", errors="replace",
335
+ timeout=_GIT_TIMEOUT_S,
336
+ stdin=subprocess.DEVNULL)
337
+ r2 = subprocess.run(
338
+ ["git", "commit", "-q", "-m",
339
+ f"interop(verifier): iter={it} verdict={verdict.get('verdict')} "
340
+ f"passed={verdict.get('passed')} failed={verdict.get('failed')}"],
341
+ cwd=repo, capture_output=True, text=True,
342
+ encoding="utf-8", errors="replace",
343
+ timeout=_GIT_TIMEOUT_S, stdin=subprocess.DEVNULL)
344
+ except subprocess.TimeoutExpired:
345
+ # 超时**不吞**:如实记进既有错误面(与 rc≠0 同一条 commit_err),
346
+ # 不假装提交成功(issue #63)。
347
+ out["committed"] = False
348
+ out["commit_err"] = ("git 超时(%ss)已被 kill:%s" %
349
+ (_GIT_TIMEOUT_S, out_dir))
350
+ return out
335
351
  out["committed"] = r2.returncode == 0
336
352
  if r2.returncode != 0:
337
353
  out["commit_err"] = (r2.stderr or r1.stderr)[:200]
@@ -280,6 +280,13 @@ def set_state(cg, node_id: str, dst: str, reason: str = None,
280
280
  cg._write_node(node_id, os.path.join(cg.root, node["path"]), fm,
281
281
  node.get("content") or "")
282
282
  _sync_index(cg, node_id, fm)
283
+ # 退役接线(2026-10-06):状态迁移改变 `is_archived` 读数 ⇒ 依赖该判据的
284
+ # 邻接缓存(`chain.adjacency` 的 skip_archived 过滤)必须**同点作废**——
285
+ # 此前只有本地写路径(add/_stage 尾三连)清缓存,set_state 后旧缓存会让
286
+ # 退役节点继续沿因果链/图面扩散(本批探针实证:归档后 causal_chain 仍含
287
+ # 目标)。懒导入避免模块环;`invalidate_cache` 内部自吞异常,不阻断推进。
288
+ from . import chain as _chain
289
+ _chain.invalidate_cache(cg)
283
290
  try:
284
291
  append_jsonl(os.path.join(cg.root, AUDIT_FILE), {
285
292
  "t": at, "action": "set_state", "node_id": node_id,
@@ -189,6 +189,9 @@ TOOLS = [
189
189
  consistency=_p("boolean", "写入前节点间自动冲突检测(三级决策,默认 true)"),
190
190
  on_conflict=_p("string", "冲突处置:reject(默认,抛错)|defer(不写)|record(记录放行)"),
191
191
  condition_space=_p("object", "条件空间"),
192
+ spatial=_p("object", "3D 空间锚(0.8.0 身体×脑对接约定,四条裁定之二):"
193
+ "{\"coords3d\": {x,y,z}} 自定义键直存米制;"
194
+ "可选 bbox 投影后补(不改既有 bbox 2D 语义)"),
192
195
  verification_basis=_p("string", "验证基底"),
193
196
  non_applicable_conditions=_p("array", "不适用条件")),
194
197
  },
@@ -1591,6 +1594,15 @@ def _split_ids(value):
1591
1594
  return out
1592
1595
 
1593
1596
 
1597
+ # 生效条件:a 为 MCP 参数字典时——`a["spatial"]` 非 None 返回 {"spatial": ...},否则返回 {}
1598
+ # (**缺省不传**:未声明 spatial 的写入零落键,既有 fm 形态逐位不变;0.8.0 身体×脑对接
1599
+ # 四条裁定之二引入,gated 分支与非 gated add 两处共用本单点)。
1600
+ def _spatial_kw(a):
1601
+ if a.get("spatial") is not None:
1602
+ return {"spatial": a["spatial"]}
1603
+ return {}
1604
+
1605
+
1594
1606
  # 生效条件:始终构造 ex(verify=a.get("verify")、importance=float(a["importance"]) 当 a.get("importance") is not None 否则 None、verification_basis=a.get("verification_basis") or (verdict or {}).get("basis")、non_applicable_conditions、role、derived_from=_split_ids(a.get("derived_from")) or None、relation),返回其中值不属于 (None, [], '', {}) 的键值对。
1595
1607
  def _proposal_extras(a, verdict=None):
1596
1608
  """入队时保全 write 的落盘要素,避免裁决 accept 后退化成默认值。
@@ -3446,6 +3458,11 @@ def _dispatch(cg, name, args):
3446
3458
  # A1 补接(2026-10-05):检验强度同族透传(remember_gated 内部
3447
3459
  # add(**kw) 直达;此前声明在 gated 分支静默丢弃)。
3448
3460
  check_strength=a.get("check_strength"),
3461
+ # 0.8.0 身体×脑对接(四条裁定之二,2026-10-06):`spatial`
3462
+ # 自定义键**直存**——约定 {"coords3d": {x,y,z}} 米制(可选 bbox
3463
+ # 投影后补);不改既有 fm.spatial.bbox 的 2D 语义。
3464
+ # 缺省不传(_spatial_kw)——未声明时零落键,既有 fm 形态逐位不变。
3465
+ **_spatial_kw(a),
3449
3466
  non_applicable_conditions=a.get("non_applicable_conditions"),
3450
3467
  importance_hint=hint, override=bool(a.get("override")),
3451
3468
  consistency=bool(a.get("consistency", True)),
@@ -3498,6 +3515,9 @@ def _dispatch(cg, name, args):
3498
3515
  # A1 补接(2026-10-05):检验强度入复现 meta——与
3499
3516
  # writepipe._AUTONOMY_META_KEYS 同款(两条路径元数据等价)。
3500
3517
  "check_strength",
3518
+ # 0.8.0 对接:spatial 同样随单落复现 meta(入队后
3519
+ # accept 落盘与直接落盘**元数据等价**,判据不分叉)。
3520
+ "spatial",
3501
3521
  "role", "importance") if a.get(k) is not None}
3502
3522
  # 直写面的裁决参数(本分支的 add 会自己跑冲突闸):随单落进复现 meta,
3503
3523
  # 否则「确认后落盘」与「直接落盘」两条路径的判据不等价。
@@ -3526,6 +3546,8 @@ def _dispatch(cg, name, args):
3526
3546
  verification_basis=a.get("verification_basis"),
3527
3547
  # A1 补接(2026-10-05):检验强度透传(同 B2)。
3528
3548
  check_strength=a.get("check_strength"),
3549
+ # 0.8.0 对接:spatial 直存透传(同 B2);缺省不传(零落键)。
3550
+ **_spatial_kw(a),
3529
3551
  non_applicable_conditions=a.get("non_applicable_conditions"),
3530
3552
  override=bool(a.get("override")),
3531
3553
  consistency=bool(a.get("consistency", True)),
package/md_cg/mdcg.py CHANGED
@@ -3690,7 +3690,12 @@ class MdCG:
3690
3690
  # (非法视图 ValueError——fail-closed 只针对调用方误用)
3691
3691
  and (view is None or roleviews.matches(e, view))
3692
3692
  # 时效:只在显式启用时排除已过期(not_yet 保留)
3693
- and not (validity and trust.is_expired(e, now=now))):
3693
+ and not (validity and trust.is_expired(e, now=now))
3694
+ # 退役纪律(《秤》v2.1 §5.2;2026-10-06 接线):archived
3695
+ # 不进默认检索候选——判据单点 lifecycle.is_archived
3696
+ # (缺键=active fail-open),与生产路径 `MdCGOS._candidates`
3697
+ # 同一口径(此前基类面未接,属已登记的已知未覆盖面)。
3698
+ and not lifecycle.is_archived(e)):
3694
3699
  entries.append(e)
3695
3700
 
3696
3701
  # 默认关:索引里可能残留门控字段(曾开启过 / 回填过)→ 返回前剥离,
package/md_cg/scrub.py CHANGED
@@ -115,11 +115,13 @@ def _access(cg):
115
115
  return {}, {}
116
116
 
117
117
 
118
- # 生效条件:`from . import chain` 成功且 chain.adjacency(cg) 正常返回时返回该 dict,导入或调用抛任何异常时返回 {}。
118
+ # 生效条件:`from . import chain` 成功且 chain.adjacency(cg, skip_archived=False) 正常返回时返回该 dict,导入或调用抛任何异常时返回 {}。
119
119
  def _adjacency(cg) -> dict:
120
120
  try:
121
121
  from . import chain
122
- return chain.adjacency(cg)
122
+ # 维护面全量口径(2026-10-06 退役接线批次):去污染抽查是维护写面,
123
+ # 退役节点仍可被抽查 ⇒ 显式旁路退役过滤(度数口径与接线前逐位一致)。
124
+ return chain.adjacency(cg, skip_archived=False)
123
125
  except Exception:
124
126
  return {}
125
127