@furongjun1999/dsh-memory 0.7.0 → 0.7.2

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 (78) hide show
  1. package/README.md +8 -6
  2. package/codebuddy/CODEBUDDY.md +5 -5
  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/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
  17. package/docs/eval//345/217/221/345/270/20310_/350/257/273/351/235/242/346/215/237/345/235/217UTF8/345/256/266/346/227/217/346/224/266/345/217/243_v1.0.md +71 -0
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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 +4 -4
  23. package/md_cg/admission.py +718 -0
  24. package/md_cg/autonomy_modes.py +642 -0
  25. package/md_cg/bench6_arms.py +10 -6
  26. package/md_cg/census.py +2 -1
  27. package/md_cg/consistency.py +133 -8
  28. package/md_cg/forgetting.py +38 -6
  29. package/md_cg/freshness.py +17 -1
  30. package/md_cg/lifecycle.py +30 -2
  31. package/md_cg/mcp_server.py +63 -7
  32. package/md_cg/mdcg.py +157 -31
  33. package/md_cg/mdcos.py +675 -19
  34. package/md_cg/mreview/locate.py +1 -1
  35. package/md_cg/protect.py +79 -4
  36. package/md_cg/provenance.py +1 -1
  37. package/md_cg/review_cli.py +31 -2
  38. package/md_cg/rollback.py +420 -0
  39. package/md_cg/rollback_cli.py +104 -0
  40. package/md_cg/routing.py +10 -1
  41. package/md_cg/stg.py +400 -50
  42. package/md_cg/stgidx.py +281 -0
  43. package/md_cg/test_autonomy_admission.py +1098 -0
  44. package/md_cg/test_autonomy_modes.py +1972 -0
  45. package/md_cg/test_b1_auto_id_multiproc.py +7 -0
  46. package/md_cg/test_b1b2_write_face.py +7 -0
  47. package/md_cg/test_b3_merge_keeps_content.py +7 -0
  48. package/md_cg/test_cons200_scan_selection.py +791 -0
  49. package/md_cg/test_corrupt_utf8_read_surfaces.py +451 -0
  50. package/md_cg/test_generation_guard.py +15 -9
  51. package/md_cg/test_health_corrupt_utf8.py +37 -0
  52. package/md_cg/test_i50a_half_dup_defer.py +408 -0
  53. package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
  54. package/md_cg/test_i50c_meta_passthrough.py +608 -0
  55. package/md_cg/test_i50d_importance_source.py +531 -0
  56. package/md_cg/test_i50e_readside_protection.py +656 -0
  57. package/md_cg/test_index_crossprocess_reload.py +39 -24
  58. package/md_cg/test_issue39_utf8_stdio.py +9 -1
  59. package/md_cg/test_issue52_scan_condition_first.py +677 -0
  60. package/md_cg/test_issue53_cipher_health_hash.py +385 -0
  61. package/md_cg/test_linkref.py +7 -0
  62. package/md_cg/test_mode_parity.py +1393 -0
  63. package/md_cg/test_mutation_rollback.py +805 -0
  64. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
  65. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
  66. package/md_cg/test_p2_mcp.py +8 -0
  67. package/md_cg/test_p9_forget_protect.py +7 -0
  68. package/md_cg/test_p9c_dedup_hints.py +7 -0
  69. package/md_cg/test_policy_required_ccg.py +5 -1
  70. package/md_cg/test_protocol.py +7 -0
  71. package/md_cg/test_stgidx_index_parity.py +1020 -0
  72. package/md_cg/test_writelimit.py +36 -9
  73. package/md_cg/test_writepipe.py +5 -2
  74. package/md_cg/weights.py +15 -1
  75. package/md_cg/writepipe.py +101 -2
  76. package/package.json +1 -1
  77. package/skills/plugin.json +1 -1
  78. package/zcode/AGENTS.md +5 -5
@@ -172,14 +172,18 @@ class LingshuAdapter(Adapter):
172
172
  "file_head": head})
173
173
  return out
174
174
 
175
- # 生效条件:无参数,遍历 self.cg.index["nodes"] 各节点的 e.get("bucket"),其缺失或为假值(None/空串等)时归入 "<none>" 计数,再把计数字典交 routing.bucket_health 并返回其结果。
175
+ # 生效条件:无参数,计数与生产同源(MdCG._count_buckets:按 entry.bucket 现算、只含分桶层条目),再把计数字典交 routing.bucket_health 并返回其结果。
176
176
  def bucket_health(self):
177
- """桶健康度自检(routing.bucket_health):取证条件路由是否有区分力。"""
177
+ """桶健康度自检(routing.bucket_health):取证条件路由是否有区分力。
178
+
179
+ issue #53-B④:此前本地版把非分桶层节点全归入 `"<none>"` 一桶——在只含
180
+ contextual 的 bench 库上恒得「单桶/巨桶」报警,读数与生产口径(分桶
181
+ 只覆盖 BUCKETED_LAYERS)不一致。改为委托 `MdCG._count_buckets`
182
+ (同源单点:与快照装载/rebuild 同一口径)。
183
+ """
178
184
  from md_cg import routing
179
- counts = {}
180
- for e in list(self.cg.index["nodes"].values()):
181
- b = e.get("bucket") or "<none>"
182
- counts[b] = counts.get(b, 0) + 1
185
+ from md_cg.mdcg import MdCG
186
+ counts = MdCG._count_buckets(self.cg.index["nodes"])
183
187
  return routing.bucket_health(counts)
184
188
 
185
189
 
package/md_cg/census.py CHANGED
@@ -52,7 +52,8 @@ def load(root):
52
52
  try:
53
53
  with open(p, encoding="utf-8") as f:
54
54
  fm, _content = nodefile.loads(f.read())
55
- except OSError:
55
+ except (OSError, UnicodeDecodeError):
56
+ # 损坏非 UTF-8 节点跳过(与 PR #54 读面同口径)
56
57
  continue
57
58
  if not fm:
58
59
  continue
@@ -59,10 +59,21 @@ from .mdcg import expand_query_terms_weighted
59
59
  LOG_FILE = "_consistency.jsonl"
60
60
 
61
61
  MAX_SCAN = 200 # 单次检测最多比对的既有节点数(防 O(N) 爆炸)
62
+ # cons200(2026-10-03):本值是**相关面/保底面各自的限额**,不是
63
+ # 「索引序前 N」——数值一字未动(契约禁止面);超限走可观测
64
+ # (truncated/kept/hint),修法是选面与预筛,不是调大本值。
62
65
  MAX_DEPTH = 3 # L2 递归深度上限(对齐 :273)
63
66
  MAX_NODES = 60 # L2 递归展开节点上限(对齐 :273)
64
67
  MIN_GAIN = 0.15 # 信息增益门槛:候选空间减少比例低于此值即停(对齐 :273)
65
68
 
69
+ # 截断可操作提示(cons200:与 stg `_CAP_HINT` 同口径精神——禁止静默)——文案必须
70
+ # 含「细化生效条件/不适用条件」与「建立条件索引」语义;数值放大(调 limit/MAX_SCAN)
71
+ # 不是修法,故明文劝阻。
72
+ _SCAN_HINT = ("候选 %s 条超过单次检测限额 limit=%s,已按相关性保留 %s 条"
73
+ "(截断不静默):请细化生效条件/不适用条件收窄候选集,"
74
+ "或按设计稿建立条件索引(docs/plans/stg条件化与结构索引_设计_v0.1.md)"
75
+ "——不要调大 limit/MAX_SCAN 数值。")
76
+
66
77
  CLASH_HIGH = 0.6 # 条件互相覆盖阈值 → 明确互斥
67
78
  CLASH_LOW = 0.35 # 条件部分覆盖阈值 → 待定
68
79
  SELF_NEGATION = 0.5 # 自否定阈值:负条件被自身正文强命中
@@ -303,6 +314,53 @@ def _slot_text(content):
303
314
  _ccg_field(content or "", "子功能"))
304
315
 
305
316
 
317
+ # --------------------------------------------------------------------------
318
+ # 选面支持件(cons200):相关性预筛打分(快照级,不读盘)
319
+ # --------------------------------------------------------------------------
320
+
321
+ # 生效条件:e 为索引快照条目、nid 为节点 id、tw_pos/tw_neg 为 {词: 权重}(可为空)、cov 为 _weighted_coverage 同签名函数、disc 为 _is_discipline 的预判结果(None 时按 `_is_discipline({"tags": e.get("tags")}, nid)` 现算)——只读条目内既有键(tags/rejection_terms),恒返回整数二元组 (rank, score):disc 真 → (3, 0);否则 rejection_terms 与 tw_pos/tw_neg 有整词子串交集 → (2, int(round(1e4 * max(双方 _cov)) ));仅 rejection_terms 非空 → (1, 0);其余 → (0, 0);不读盘、不解析正文、不调用 cg.get。
322
+ def _sift_score(e, nid, tw_pos, tw_neg, cov, disc=None):
323
+ """候选相关性打分(**只排序,不裁决**)——选面的廉价预筛单点。
324
+
325
+ 为什么需要它(cons200 = 设计稿签收点 5):修前选面是 `list(nodes.items())`
326
+ 的索引序前 MAX_SCAN——库超限后 id 字典序靠后的记忆节点(`mem_*`)几乎从不
327
+ 进入比对面;而在役库前 200 名基本全是 `code_*` 索引节点。修后按**相关性**
328
+ (本函数)排序取前 limit,让「可能与本内容构成条件冲突」的候选优先入面。
329
+
330
+ 信号与廉价性(全部来自索引快照既有键,零读盘、零正文解析):
331
+ · `tags`(`_is_discipline` 单点判纪律)——纪律违反是硬冲突(REJECT),
332
+ 且其命中走 `_ban_hit` 子串判定、不走词权,故纪律候选 rank 最高(3);
333
+ · `rejection_terms`(**不适用条件**的索引词项,由既有单点
334
+ `mdcg.rejection_index_terms` 生成——不新造第二套解析):与本次内容的
335
+ 条件词面(tw_pos/tw_neg)做整词交集(`in` 子串,C 速度),命中即
336
+ rank 2 并以 `_weighted_coverage`(既有单点)给连续分;非空但无交集
337
+ rank 1(弱迹象:该节点确有负条件声明)。
338
+ 诚实边界(不假装全知):本键只覆盖**负条件面**(新正↔旧负 / 新负↔旧负)。
339
+ 「旧节点只声明生效条件(e_pos)」的比对面在快照里**无字段可判**(`_declared`
340
+ 还要读 content 的 `# 生效条件:` 行)——故本函数不做任何剔除,只做排序;
341
+ 信息不足的候选按 rank 0 落在相关面尾部、并在候选超限时由**保底面**
342
+ (修前扫描面 ∩ 候选,逐位保留)兜底,二者共同保证「修后检出 ⊇ 修前」。
343
+ """
344
+ if disc is None:
345
+ disc = _is_discipline({"tags": e.get("tags")}, nid)
346
+ if disc:
347
+ return 3, 0
348
+ rt = e.get("rejection_terms") or []
349
+ if not rt:
350
+ return 0, 0
351
+ blob = " ".join(str(t) for t in rt)
352
+ hit = False
353
+ if tw_pos:
354
+ hit = any(str(t) in blob for t in tw_pos)
355
+ if not hit and tw_neg:
356
+ hit = any(str(t) in blob for t in tw_neg)
357
+ if not hit:
358
+ return 1, 0
359
+ s1 = cov(tw_pos, blob) if tw_pos else 0.0
360
+ s2 = cov(tw_neg, blob) if tw_neg else 0.0
361
+ return 2, int(round(10000 * max(s1, s2)))
362
+
363
+
306
364
  # --------------------------------------------------------------------------
307
365
  # L0 情绪通道(信息差二阶变化)
308
366
  # --------------------------------------------------------------------------
@@ -426,7 +484,7 @@ def _recursive_reflect(cg, seeds, tw_pos, tw_neg, max_depth=MAX_DEPTH,
426
484
  # 主入口:三级决策
427
485
  # --------------------------------------------------------------------------
428
486
 
429
- # 生效条件:以 cg.index.nodes 为既有节点、content(假值按 "")经 _new_terms 得 pos/neg 并算 tw_pos/tw_neg(non_applicable_conditions 入参先按 mdcos._is_null_condition 剔除空值语义哨兵——⑧ 漏斗单点),按循环中 hard(自否定或纪律命中)→ divergences(同条件槽且 concl < CONCLUSION_SAME)→ strength ≥ CLASH_HIGH → strength ≥ CLASH_LOW → comparable==0 且 (pos or neg) → 否则 ACCEPT 的顺序定 verdict;DEFER 且 int(depth)>0 且 emo["bias"] != "approaching" 时调 _recursive_reflect 补 recursion,auto_flywheel 且 verdict == REJECT 时加 unresolved_id,最后 log 并返回 rec;
487
+ # 生效条件:以 cg.index.nodes 为既有节点、content(假值按 "")经 _new_terms 得 pos/neg 并算 tw_pos/tw_neg(non_applicable_conditions 入参先按 mdcos._is_null_condition 剔除空值语义哨兵——⑧ 漏斗单点);选面(cons200;收口轮①加面内例外):快照级预筛剔「tw_pos 与 tw_neg 皆空(新节点未声明可展开条件)时的非纪律候选」,且**剔除永不触及修前扫描面**(过滤后索引序前 limit 内候选一律保留——面内例外;「修后检出 ⊇ 修前」由此成为与「条目 tags 与盘面 fm.tags 同源」无关的结构保证),候选超 limit 时精比面=相关面(_sift_score 序)前 limit ∪ 保底面(过滤后索引序前 limit ∩ 候选),记 scanned(候选数)/kept(精比数)/truncated(scanned>kept)/hint;按循环中 hard(自否定或纪律命中)→ divergences(同条件槽且 concl < CONCLUSION_SAME)→ strength ≥ CLASH_HIGH → strength ≥ CLASH_LOW → comparable==0 且 (pos or neg) → 否则 ACCEPT 的顺序定 verdict;DEFER 且 int(depth)>0 且 emo["bias"] != "approaching" 时调 _recursive_reflect 补 recursion,auto_flywheel 且 verdict == REJECT 时加 unresolved_id,最后 log 并返回 rec;
430
488
  def check(cg, content, layer=None, condition_space=None,
431
489
  non_applicable_conditions=None, tags=None, exclude=None,
432
490
  limit=MAX_SCAN, depth=MAX_DEPTH, auto_flywheel=False,
@@ -436,6 +494,7 @@ def check(cg, content, layer=None, condition_space=None,
436
494
  返回完整判据(可审计):
437
495
  verdict / reason / conflict_strength / emotional / conflicts[] /
438
496
  recursion{} / missing[] / unresolved_id(若触发飞轮)
497
+ + 选面读数 scanned / kept / truncated(超限时另附 hint)
439
498
 
440
499
  verdict:
441
500
  ACCEPT 无冲突,或新节点未声明条件(无从冲突)
@@ -443,6 +502,36 @@ def check(cg, content, layer=None, condition_space=None,
443
502
  DEFER 条件互斥但可能可分辨(交给 L2 递归或飞轮)
444
503
  BLINDSPOT 有条件声明,但既有节点全无声明 → 无法建立比对路径(不假装确定)
445
504
 
505
+ 选面(cons200,2026-10-03;设计稿签收点 5;收口轮① 2026-10-03 复核 DEFER
506
+ 后补面内例外):
507
+ 修前=`list(nodes.items())` 索引序前 limit 并静默 break——在役库 17882
508
+ 节点 ⇒ 检测面 ≈1.1%,且 id 字典序最前的 `code_*` 索引节点占满名额,
509
+ 真正相关的记忆节点(`mem_*` 在字典序后段)几乎从不进入比对面。修后:
510
+ · 预筛(快照级、不读盘):`_sift_score` 只读条目既有键(tags /
511
+ rejection_terms),不做任何「信息不足」的剔除——唯一剔除档是
512
+ **可证无产出且不落修前扫描面**的「tw_pos 与 tw_neg 皆空(新节点
513
+ 未声明可展开条件)时的非纪律候选」(c1/c2/c3/c4 与分歧判定都以
514
+ tw_* 为乘子;纪律违反走 `_ban_hit` 子串、不看词权,故纪律候选
515
+ 一律保留)。**面内例外(收口轮①)**:剔除永不触及修前扫描面
516
+ (过滤后索引序前 limit 内的候选一律保留进候选面、落保底面通道)
517
+ ——「修后检出 ⊇ 修前」由此是**与「条目 tags 与盘面 fm.tags
518
+ 同源」无关的结构保证**(预筛读条目快照,而精比读盘面;非同源
519
+ 形态下面内候选仍进精比面、由盘面判据裁决)。
520
+ · 精比面=相关面(`_sift_score` 序)前 limit ∪ **保底面**(过滤后
521
+ 索引序前 limit ∩ 候选,逐位=修前扫描面)。保底面不撤是「修后检出
522
+ ⊇ 修前」的结构性保证(不是概率);两面上限各 limit(≤2×MAX_SCAN)。
523
+ · 可观测:`scanned`=预筛后候选数、`kept`=实际精比数、`truncated`=
524
+ scanned>kept(截断不静默,附 `hint` 给细化条件/建索引方向)。
525
+ 诚实边界:`rejection_terms` 只覆盖负条件面;「旧节点仅声明生效条件
526
+ (e_pos,正文 `# 生效条件:` 行)」在快照里无字段可判——此类节点在
527
+ 候选超限且不落修前扫描面/保底面时可能不被精比(由 truncated 可观测)。
528
+ 非同源形态(盘面 tags 与索引条目不一致,非合作写者直改 `.md` 不重建
529
+ 索引)分两段:**面内**(修前扫描面内)由面内例外保底、精比读盘面判定,
530
+ 修后仍检出(不构成回归);**面外残余**(条目未标纪律、盘面是纪律的
531
+ 节点落修前扫描面之外)修前亦检不出——不构成回归,属已声明边界
532
+ (预筛读索引条目快照;协作写者协议下条目与盘面同源,无此形态)。
533
+ 判据本身(阈值/覆盖计算/四态路由)一字未动——本批只改**选面与读数**。
534
+
446
535
  non_applicable_conditions(⑧ 2026-09-30):入参里的**空值语义哨兵**
447
536
  (`["无"]` / `["(无)"]` / `[""]`…)在本函数的入口统一剔除——判据复用
448
537
  `mdcos._is_null_condition`(仓内唯一哨兵表,**不新造第二张词表**)。
@@ -475,7 +564,7 @@ def check(cg, content, layer=None, condition_space=None,
475
564
  n_fn, n_sb = _slot_text(content)
476
565
 
477
566
  conflicts, hard, divergences = [], [], []
478
- strength, scanned, comparable = 0.0, 0, 0
567
+ strength, comparable = 0.0, 0
479
568
 
480
569
  # ---- L1-a 自否定:自己的负条件排除自己的生效条件/正文 ----
481
570
  body = _body_text(content)
@@ -491,16 +580,47 @@ def check(cg, content, layer=None, condition_space=None,
491
580
  strength = max(strength, round(_cov(tw_neg, body), 4))
492
581
 
493
582
  # ---- L1-b 与既有节点的条件级比对(反题) ----
583
+ # 选面(cons200):预筛(快照级,不读盘)→ 候选面 → 相关面 ∪ 保底面。
494
584
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
495
- seeds = []
496
- for nid, e in list(nodes.items()):
585
+ order = list(nodes.items()) # 索引序快照(H-4(a):快照后再迭代)
586
+ lim = max(0, int(limit))
587
+ no_terms = not (tw_pos or tw_neg) # 新节点未声明可展开条件
588
+ cand = [] # (rank, score, in_before, nid, e)
589
+ fidx = 0
590
+ for nid, e in order:
497
591
  if exclude and nid == exclude:
498
592
  continue
499
593
  if layer and e.get("layer") != layer:
500
594
  continue
501
- if scanned >= int(limit):
502
- break
503
- scanned += 1
595
+ in_before = fidx < lim # 是否在修前扫描面内(过滤后前 limit)
596
+ fidx += 1
597
+ disc = _is_discipline({"tags": e.get("tags")}, nid)
598
+ # 预筛唯一剔除档(可证无产出;其余一律放行——宁多勿漏):
599
+ # 新节点未声明可展开条件时,非纪律候选在精比中产出恒为零
600
+ # (c1/c2/c3/c4 与分歧判定均以 tw_pos/tw_neg 为乘子;纪律违反走
601
+ # _ban_hit 子串判定、不看词权——故纪律候选一律保留)。
602
+ # 收口轮①(复核 DEFER 后补):**面内例外**——剔除永不触及修前扫描面
603
+ # (过滤后索引序前 limit 内的候选一律保留),使「修后检出 ⊇ 修前」
604
+ # 成为与「条目 tags 与盘面 fm.tags 同源」无关的结构保证(非同源形态
605
+ # 下面内候选仍进精比面、由盘面判据裁决);面外非同源残余见 check
606
+ # docstring 诚实边界(修前亦检不出,不构成回归)。
607
+ if no_terms and not disc and not in_before:
608
+ continue
609
+ rank, score = _sift_score(e, nid, tw_pos, tw_neg, _cov, disc)
610
+ cand.append((rank, score, in_before, nid, e))
611
+ # 相关面:rank/score 降序 + id 升序终键(确定性;与索引物理序无关)
612
+ rel = sorted(cand, key=lambda t: (-t[0], -t[1], str(t[3])))[:lim]
613
+ rel_ids = {t[3] for t in rel}
614
+ # 精比面=相关面 ∪ 保底面(过滤后索引序前 limit ∩ 候选)——保底面即
615
+ # 修前扫描面;其逐位保留是「修后检出 ⊇ 修前」的结构性保证(非概率),
616
+ # 两面上限各 limit(≤ 2×MAX_SCAN;MAX_SCAN 数值未动)。
617
+ scan_face = [(t[3], t[4]) for t in rel]
618
+ scan_face += [(t[3], t[4]) for t in cand if t[2] and t[3] not in rel_ids]
619
+ scanned = len(cand) # 预筛后候选数(截断前)
620
+ kept = len(scan_face) # 实际精比数(相关面 ∪ 保底面)
621
+ truncated = scanned > kept
622
+ seeds = []
623
+ for nid, e in scan_face:
504
624
  node = cg.get(nid) or {}
505
625
  fm = node.get("frontmatter") or {}
506
626
  body = node.get("content") or ""
@@ -630,9 +750,14 @@ def check(cg, content, layer=None, condition_space=None,
630
750
  rec = {"t": time.time(), "layer": layer, "verdict": verdict,
631
751
  "reason": reason, "conflict_strength": round(strength, 4),
632
752
  "emotional": emo, "conflicts": allc, "recursion": recursion,
633
- "missing": missing, "scanned": scanned, "comparable": comparable,
753
+ "missing": missing, "scanned": scanned, "kept": kept,
754
+ "truncated": truncated, "comparable": comparable,
634
755
  "pos": pos[:6], "neg": neg[:6],
635
756
  "actor": getattr(cg, "actor", "unknown")}
757
+ # 截断不静默(与 stg `_with_scan_reads` 同口径精神):候选有被限额丢弃时
758
+ # 附可操作 hint(细化条件 / 建索引方向;数值放大不是修法)。
759
+ if truncated:
760
+ rec["hint"] = _SCAN_HINT % (scanned, lim, kept)
636
761
 
637
762
  # ---- 冲突自动触发飞轮(误差 → 补条件 → 结构更新) ----
638
763
  # H11⑥(2026-09-30):**只对真冲突(REJECT)建单**。改前是
@@ -33,7 +33,7 @@
33
33
  噪音,不该去强化既有记忆(否则例行日志
34
34
  会把普通记忆刷成高重要性)
35
35
  3) 冗余 ≥0.85 → MERGE ← 外部/未知来源的重复 = 又一次确认,强化
36
- 4) 半重复 且 不重要 → DEFER
36
+ 4) 半重复 且 未触发保护 → DEFER ← 与重要度无关:半重复是「变更确认」的输入端
37
37
  5) 重要度 ≥0.30 → ACCEPT
38
38
  6) 新信息 ≥0.15 → ACCEPT
39
39
  7) 其余 → DEFER
@@ -236,7 +236,7 @@ def importance_score(hint, novelty, kind, content):
236
236
  return {"score": round(max(0.0, min(1.0, s)), 4), "from": "heuristic"}
237
237
 
238
238
 
239
- # 生效条件:以 source_kind(role,verification_basis) 的 kind 与 redundancy(cg,content,layer=layer,exclude=node_id) 的 red["max"] 为输入,按 if/elif 顺序取首个命中分支——imp["score"]≥PROTECT_IMPORTANCE→"ACCEPT";否则 kind=="internal_deterministic" 且 red["max"]≥DUP_DROP→"DROP";否则 red["max"]≥DUP_MERGE→"MERGE";否则 red["max"]≥DUP_DROP 且 imp["score"]<IMPORTANCE_MIN→"DEFER";否则 imp["score"]≥IMPORTANCE_MIN→"ACCEPT";否则 novelty≥NOVELTY_MIN→"ACCEPT";否则→"DEFER"。返回体附带 P-9b 可见性字段:entropy.overwrite_of / entropy.overwrite_ratio(同 id 覆写时非 null)、dedup_skipped(**仅 PROTECT 分支**非 null,标注「因保护优先未走去重」+ 去重判据读数)——两者都不改 verdict、不改落盘行为。
239
+ # 生效条件:以 source_kind(role,verification_basis) 的 kind 与 redundancy(cg,content,layer=layer,exclude=node_id) 的 red["max"] 为输入,按 if/elif 顺序取首个命中分支——imp["score"]≥PROTECT_IMPORTANCE→"ACCEPT"(reason 按 imp["from"] 分叉:hint→「触发不可遗忘保护」,heuristic→「未落保护——保护须显式声明」,issue50-d);否则 kind=="internal_deterministic" 且 red["max"]≥DUP_DROP→"DROP";否则 red["max"]≥DUP_MERGE→"MERGE";否则 red["max"]≥DUP_DROP→"DEFER"(**与 imp 无关**);否则 imp["score"]≥IMPORTANCE_MIN→"ACCEPT";否则 novelty≥NOVELTY_MIN→"ACCEPT";否则→"DEFER"。返回体附带 P-9b 可见性字段:entropy.overwrite_of / entropy.overwrite_ratio(同 id 覆写时非 null)、dedup_skipped(**仅 PROTECT 分支**非 null,标注「因保护优先未走去重」+ 去重判据读数)——两者都不改 verdict、不改落盘行为。
240
240
  def assess(cg, content, layer="contextual", role=None, verification_basis=None,
241
241
  importance_hint=None, node_id=None):
242
242
  """三问 → 四态裁决。返回完整判据(可审计,不只给结论)。
@@ -261,6 +261,27 @@ def assess(cg, content, layer="contextual", role=None, verification_basis=None,
261
261
  关键正文并入他节点、检索归属漂移——风险高于收益;③ 缺陷本体是「绕过
262
262
  去重而不可见」而非「保护存在」,标注即可消除静默且零回归面
263
263
  (test_p9_forget_protect.py 的「importance_hint≥0.7→ACCEPT」逐字不变)。
264
+
265
+ issue50-d(2026-10-02):保护分支的 reason 文案按 **imp["from"] 分叉**——
266
+ 改前启发式路径(不传 hint)过线时也声称「触发不可遗忘保护」,但落盘面
267
+ importance=0.5(默认)且无保护位(隔离沙箱直读 frontmatter 实证:
268
+ u_heur_new 裁决 0.837/落盘 0.5;t_heur 裁决 0.711/落盘 0.5)——审计面在
269
+ 声称未发生的事。处置:from=="hint" 且 ≥0.7 维持原文案(add 的自动保护位
270
+ 对显式声明真落 protected 位,文案与落盘一致);from=="heuristic" 且 ≥0.7
271
+ 改为如实文案「启发式 …(未落保护——保护须显式声明)」。裁决面 verdict、
272
+ 分支顺序、常量均不变;落盘口径的修复在 remember_gated ACCEPT 分支
273
+ (mdcos.py:落盘=裁决值 + importance_source 标注来源)。
274
+
275
+ issue50-a(2026-10-01):分支 ④「半重复 → DEFER」此前写作
276
+ `red["max"] >= DUP_DROP and imp["score"] < IMPORTANCE_MIN`,**结构性不可达**:
277
+ 用户角色属外部惊奇来源(`SOURCE_WEIGHT["external_surprising"]=1.00`),启发式
278
+ 重要度 = 0.5·novelty + 0.3·1.00 + 0.2·lf ⇒ 基线恰为 0.30 = IMPORTANCE_MIN,
279
+ 且比较用 `>=`;插件侧还显式传 `hint=0.6`(同样 ≥0.30)。实测:重复度 0.7324 /
280
+ 0.7606 的半重复内容一律走分支 ⑤ 判 ACCEPT,`_forgetting.jsonl` 的 DEFER 计数为
281
+ 0(在役库 215 条留痕:ACCEPT 201 / MERGE 14)——本应是「变更确认」输入端的
282
+ 那条回路从不产生输入。处置:**删去 `and imp["score"] < IMPORTANCE_MIN`**,
283
+ 半重复(且未触发保护)一律 DEFER,与重要度解耦。分支顺序与其余六条分支的
284
+ 判据/文案均不变,保护优先(①)仍最先,故保护面零回归。
264
285
  """
265
286
  kind = source_kind(role, verification_basis)
266
287
  red = redundancy(cg, content, layer=layer, exclude=node_id)
@@ -294,8 +315,19 @@ def assess(cg, content, layer="contextual", role=None, verification_basis=None,
294
315
  if red["with"] and red["max"] >= DUP_MERGE:
295
316
  due.append("与 %s 重复度 %.2f≥%.2f(够 MERGE 阈值)"
296
317
  % (red["with"], red["max"], DUP_MERGE))
297
- verdict, why = "ACCEPT", (f"重要度 {imp['score']:.2f}≥{PROTECT_IMPORTANCE}"
298
- f"(触发不可遗忘保护)")
318
+ # issue50-d(2026-10-02):文案按来源分叉——`imp["from"]` 就在手上的
319
+ # imp 里,单点可判。显式 hint 维持「触发不可遗忘保护」(add 的自动
320
+ # 保护位会真落 protected 位,文案与落盘一致);启发式过线**只落分、
321
+ # 不打保护位**(issue50-d 裁决②:保护须显式声明),文案改成如实
322
+ # 表述——改前启发式 0.84 也声称「触发不可遗忘保护」,而落盘面
323
+ # importance=0.5 且无保护位(隔离沙箱实证),审计面在声称未发生的事。
324
+ # 分支顺序与 verdict 不变(硬边界:issue50-a 的那行除外,本批不动)。
325
+ if imp["from"] == "hint":
326
+ verdict, why = "ACCEPT", (f"重要度 {imp['score']:.2f}≥{PROTECT_IMPORTANCE}"
327
+ f"(触发不可遗忘保护)")
328
+ else:
329
+ verdict, why = "ACCEPT", (f"启发式 {imp['score']:.2f}≥{PROTECT_IMPORTANCE}"
330
+ f"(未落保护——保护须显式声明)")
299
331
  dedup_skipped = {
300
332
  "reason": "protect_importance",
301
333
  "detail": ("保护优先分支排在冗余判定之前:本次未走 MERGE/DROP"
@@ -315,9 +347,9 @@ def assess(cg, content, layer="contextual", role=None, verification_basis=None,
315
347
  elif red["max"] >= DUP_MERGE:
316
348
  verdict, why = "MERGE", (f"重复度 {red['max']:.2f}≥{DUP_MERGE}"
317
349
  f"(并入 {red['with']},强化既有)")
318
- elif red["max"] >= DUP_DROP and imp["score"] < IMPORTANCE_MIN:
350
+ elif red["max"] >= DUP_DROP:
319
351
  verdict, why = "DEFER", (f"半重复 {red['max']:.2f}∈[{DUP_DROP},{DUP_MERGE})"
320
- f" 且重要度 {imp['score']:.2f}<{IMPORTANCE_MIN}"
352
+ f" 且未触发不可遗忘保护"
321
353
  f"(待定复核)")
322
354
  elif imp["score"] >= IMPORTANCE_MIN:
323
355
  verdict, why = "ACCEPT", f"重要度 {imp['score']:.2f}≥{IMPORTANCE_MIN}"
@@ -298,7 +298,7 @@ def entry_weight(entry, now=None, gamma=None, fm=None) -> tuple:
298
298
  # 三件套:预演 / 逐节点留痕 / 反向 apply(与 weights.recalc 同款)
299
299
  # --------------------------------------------------------------------------
300
300
 
301
- # 生效条件:cg.index["nodes"] 为 dict 时按 layer 过滤、limit 为真值才截断;dry-run 只出报表;apply=True 时逐节点把乘子(round 6)写入 frontmatter.freshness_weight 与 freshness_components,乘子 < DEMOTE_BELOW 时另写 freshness_state="degraded"(**只标记,不删记录**),乘子下降且 demote_layer 给定时经 cg._move_layer(内含 protect.guard_move)搬迁;每条 append_jsonl(action="freshness") 后 rebuild_index;返回报表 dict;
301
+ # 生效条件:cg.index["nodes"] 为 dict 时按 layer 过滤、limit 为真值才截断;dry-run 只出报表(**不接档位闸**——零写盘面照旧);apply=True 时先经 autonomy_modes.e_gate() 档位判定(补强批次 v1.2·E 面接线,位置=任何盘面写入之前):非 ALLOW(plan 档无计划)即经 forbidden_result() fail-closed 早退(零写盘、零审计行、带 hint),ALLOW(confirm/full)则逐节点把乘子(round 6)写入 frontmatter.freshness_weight 与 freshness_components,乘子 < DEMOTE_BELOW 时另写 freshness_state="degraded"(**只标记,不删记录**),乘子下降且 demote_layer 给定时经 cg._move_layer(内含 protect.guard_move)搬迁;每条 append_jsonl(action="freshness") 后 rebuild_index;返回报表 dict;
302
302
  def recalc(cg, layer=None, limit=None, apply=False, min_delta=APPLY_DELTA,
303
303
  actor="maintain", dry_run_samples=10, now=None, demote_layer=None):
304
304
  """刷新/衰减权重的重算(**可预演 + 逐节点留痕 + 可回滚**)。
@@ -312,6 +312,12 @@ def recalc(cg, layer=None, limit=None, apply=False, min_delta=APPLY_DELTA,
312
312
  `freshness_state="degraded"` 标记;要搬迁层位时给出 `demote_layer`,
313
313
  走既有 `_move_layer`(其内已调 `protect.guard_move`,受保护节点需显式
314
314
  `override`)——不新写第二条搬迁路径。
315
+
316
+ **档位闸(补强批次 v1.2·E 面接线)**:`apply=True` 的**写盘前**先经
317
+ `autonomy_modes.e_gate()`(设计 §三 plan 档 E「须命中计划步骤」)——
318
+ plan 档无计划 ⇒ `forbidden_result()` fail-closed 早退(零写盘、零审计行);
319
+ confirm/full ⇒ ALLOW,**原链原样**(逐位不变)。`apply=False`(预演)
320
+ 不接闸:零写盘面不属「变更」。
315
321
  """
316
322
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
317
323
  ids = [nid for nid, e in list(nodes.items())
@@ -350,6 +356,16 @@ def recalc(cg, layer=None, limit=None, apply=False, min_delta=APPLY_DELTA,
350
356
  samples.append(plan)
351
357
  written, degraded = 0, 0
352
358
  if apply:
359
+ # 三档自治(补强批次 v1.2·E 面接线,设计 §三 plan 档 E「须命中计划步骤」):
360
+ # E 类动作**写盘前**的档位判定单点。本函数无独立资格闸(逐节点取数在下方
361
+ # 循环里、取不到即 skip),故闸口取「进入写盘支路的第一行」=任何盘面写入
362
+ # 之前;放行分支即原链原样(confirm/full 逐位不变),plan 档无计划
363
+ # ⇒ fail-closed 早退,**零写盘、零 _maintain.jsonl 行**。
364
+ from . import autonomy_modes as _am
365
+ _dec = _am.e_gate()
366
+ if _dec["decision"] != _am.ALLOW:
367
+ return _am.forbidden_result(_dec, action="freshness", dry_run=False,
368
+ written=0, batch=batch)
353
369
  if demote_layer:
354
370
  bind_move_layer(cg) # 跨层降级走既有 _move_layer(现绑)
355
371
  for plan in planned:
@@ -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)
@@ -2071,7 +2071,7 @@ def _task_call(cg, a):
2071
2071
  return _t.list_tasks(cg, status=tstat or None, limit=a.get("limit"))
2072
2072
 
2073
2073
 
2074
- # 生效条件:op 取 a.get("op") 转 str 去空白并 lower;缺失时按参数签名回推(含 content→write / query|node_id→read / intent→route;无签名可推时维持 read),op=="help" 在角色闸门之前直通 _help_call;其余 op 先经 cg.principal.require_op(op)(越权即 AccessDenied)再按 alias→内联名→cli 表分派;未识别 op 抛 ValueError;
2074
+ # 生效条件:op 取 a.get("op") 转 str 去空白并 lower;缺失时按参数签名回推(含 content→write / query|node_id→read / intent→route;无签名可推时维持 read),op=="help" 在角色闸门之前直通 _help_call;其余 op 先经 cg.principal.require_op(op)(越权即 AccessDenied)再按 alias→内联名→cli 表分派;未识别 op 抛 ValueError;fork:op=="forget" 的**删除**分支走 cg.forget_gated(三档自治批次②:档位判定在既有资格闸之后,变更确认档出变更单且不软删;restore 分支不变),op=="write" 经 writepipe 默认链(链尾 autonomy 档位闸);
2075
2075
  def _cg_dispatch(cg, a):
2076
2076
  """认知图唯一入口的 op 分发主体。"""
2077
2077
  op = (a.get("op") or "read").strip().lower()
@@ -2408,8 +2408,11 @@ def _cg_dispatch(cg, a):
2408
2408
  if op == "forget":
2409
2409
  if (a.get("action") or "forget").strip().lower() == "restore":
2410
2410
  return cg.restore(a.get("node_id", ""), force=bool(a.get("force")))
2411
- return cg.forget(a.get("node_id", ""), a.get("reason", ""),
2412
- override=bool(a.get("override")))
2411
+ # 三档自治批次②(设计 §三 D 删除):工具面经 forget_gated——档位判定在
2412
+ # 既有资格闸(not_found / protect.guard_forget)**之后**,变更确认档
2413
+ # 出变更单且不软删;库层 forget 语义不变(脚本通道不接,本批边界)。
2414
+ return cg.forget_gated(a.get("node_id", ""), a.get("reason", ""),
2415
+ override=bool(a.get("override")))
2413
2416
 
2414
2417
  if op == "protect":
2415
2418
  return _protect_call(cg, a)
@@ -3124,7 +3127,7 @@ def _whitebox_call(cg, a):
3124
3127
  return whitebox.dispatch(cg, a)
3125
3128
 
3126
3129
 
3127
- # 生效条件:op=(a.get("op") or "").strip().lower();op=="relation" 时返回 stg.relation(cg, a.get("a",""), a.get("b",""), time_axis=axis);op=="timeline" 时返回 stg.timeline(cg, layer=a.get("layer"), limit=int(a.get("limit") or 50), desc=bool(a.get("desc", True)), time_axis=axis, session=a.get("session"))(session 缺省不过滤读遍所有会话,"*" = 显式跨会话,其它值 = 本会话视图);op=="anchors" 时返回 stg.anchors(cg, time_window=a.get("time_window"), bbox=a.get("bbox"), layer=a.get("layer"), limit=int(a.get("limit") or 50));op=="consistency" 时返回 stg.consistency(cg, layer=a.get("layer"), limit=int(a.get("limit") or 50));op 为空或其它的值抛 ValueError。
3130
+ # 生效条件:op=(a.get("op") or "").strip().lower();op=="relation" 时返回 stg.relation(cg, a.get("a",""), a.get("b",""), time_axis=axis);op=="timeline" 时返回 stg.timeline(cg, layer=a.get("layer"), limit=int(a.get("limit") or 50), desc=bool(a.get("desc", True)), time_axis=axis, session=a.get("session"))(session 缺省不过滤读遍所有会话,"*" = 显式跨会话,其它值 = 本会话视图);op=="anchors" 时返回 stg.anchors(cg, time_window=a.get("time_window"), bbox=a.get("bbox"), layer=a.get("layer"), limit=int(a.get("limit") or 50));op=="consistency" 时返回 stg.consistency(cg, layer=a.get("layer"), limit=int(a.get("limit") or 50));op 为空或其它的值抛 ValueError;返回体**原样透传**——stg 三接口的 truncated/scanned/kept/hint 读数与修正后的 count 语义(条件命中总数,issue #52)不经本层裁剪或改写,max_scan 本批不开显式入参。
3128
3131
  def _stg_call(cg, a):
3129
3132
  """语义时空图唯一入口。
3130
3133
 
@@ -3133,6 +3136,13 @@ def _stg_call(cg, a):
3133
3136
  的透传策略**相反**——`cg` 侧缺省由库层定(不启用的时间算子),`stg` 侧轴
3134
3137
  决定「排序/关系依据」必须**恒有值**,故在此填默认;非法轴仍由
3135
3138
  `trust.time_axis_of` fail-closed 抛错(本层不预先白名单,避免两套枚举)。
3139
+
3140
+ 返回体**原样透传**(issue #52):timeline/anchors/consistency 新增的
3141
+ `truncated` / `scanned` / `kept` 读数与截断时的 `hint` 随返回值自动带出,
3142
+ 本层不裁剪、不改写;`count` 语义已是**条件命中总数**(不再受索引切片影响,
3143
+ 在役对照 `timeline(session=…)` 由 25 恢复为 229)。`max_scan` 本批不在
3144
+ MCP 面开显式入参(契约:数值不是修法——需要收窄时细化生效条件/
3145
+ 不适用条件,或按设计稿建立条件索引)。
3136
3146
  """
3137
3147
  from . import stg
3138
3148
  op = (a.get("op") or "").strip().lower()
@@ -3287,7 +3297,7 @@ def recall_fusion_default(use_fuzzy, use_semantic, use_goal, use_causal,
3287
3297
  else None)
3288
3298
 
3289
3299
 
3290
- # 生效条件:name 为已注册工具名之一(cg / stg / mdcg_whitebox / mdcg_service_info / mdcg_remember 等);name 属 _MDCG_OP_REQUIRE 且 cg.principal 具 require_op 属性时先 require_op(越权抛 AccessDenied),principal 为 None 或无该方法时跳过;cg 走 _cg_call、stg 走 _stg_call、whitebox 走 _whitebox_call;未识别的 name 返回含 error 的响应字典而不抛异常,进程不因此中断;
3300
+ # 生效条件:name 为已注册工具名之一(cg / stg / mdcg_whitebox / mdcg_service_info / mdcg_remember 等);name 属 _MDCG_OP_REQUIRE 且 cg.principal 具 require_op 属性时先 require_op(越权抛 AccessDenied),principal 为 None 或无该方法时跳过;cg 走 _cg_call、stg 走 _stg_call、whitebox 走 _whitebox_call;未识别的 name 返回含 error 的响应字典而不抛异常,进程不因此中断;mdcg_forget 走 cg.forget_gated(同 cg(op=forget) 的档位路径)、mdcg_remember 的非 gated 直写分支在同 id 覆写时先过 cg.write_qualify 再按档位判定(三档自治批次②);
3291
3301
  def _dispatch(cg, name, args):
3292
3302
  a = args or {}
3293
3303
  # P1-1:mdcg_* 面的角色作用域闸(与 cg 工具的 require_op 同一语义——
@@ -3354,6 +3364,51 @@ def _dispatch(cg, name, args):
3354
3364
  _prior_cov = (_forgetting.self_coverage(cg, _prior, _content)
3355
3365
  if _prior is not None else None)
3356
3366
  _dup = _forgetting.redundancy(cg, _content, layer=_layer, exclude=nid)
3367
+ # 三档自治批次②(设计 §三):本分支是**热写入路径的第二落点**(另两处 =
3368
+ # writepipe 链尾执行器、remember_gated)——同 id 已存在 ⇒ C 改写、
3369
+ # 不存在 ⇒ A 新增,判据复用 forgetting.prior_node(不另写一份存在性判据)。
3370
+ # 位置:既有 `if a.get("gated")` 分支之后、`cg.add` 之前(直写面没有别的
3371
+ # 资格闸;audit/限流属 gated 分支的链)。确认档 ⇒ 出单且不落盘。
3372
+ from . import autonomy_modes as _am
3373
+ _action = _am.C_REWRITE if _prior is not None else _am.A_ADD
3374
+ if _prior is not None:
3375
+ # 资格在先(纯加严):见 writepipe._gate_autonomy 同款注释——覆写的
3376
+ # 层闸/写保护闸在 cg.add 内部,档位闸不得抢在它们之前把拒绝变成入队。
3377
+ cg.write_qualify(nid, target_layer=_layer,
3378
+ override=bool(a.get("override")),
3379
+ actor=getattr(cg, "actor", None))
3380
+ _dec = _am.decide(_action)
3381
+ if _dec["decision"] != _am.ALLOW:
3382
+ _aut = {"mode": _dec["mode"], "action": _dec["action"],
3383
+ "action_name": _dec["action_name"],
3384
+ "decision": _dec["decision"]}
3385
+ if _dec["decision"] == _am.FORBID:
3386
+ return {"ok": False, "id": nid, "committed": False,
3387
+ "moved_to": "autonomy_forbidden",
3388
+ "error": "autonomy_forbid", "autonomy": _aut,
3389
+ "hint": _dec["hint"]}
3390
+ _meta = {k: a[k] for k in
3391
+ ("tags", "condition_space", "verification_basis",
3392
+ "non_applicable_conditions", "derived_from", "relation",
3393
+ "role", "importance") if a.get(k) is not None}
3394
+ # 直写面的裁决参数(本分支的 add 会自己跑冲突闸):随单落进复现 meta,
3395
+ # 否则「确认后落盘」与「直接落盘」两条路径的判据不等价。
3396
+ _meta["consistency"] = bool(a.get("consistency", True))
3397
+ _meta["on_conflict"] = a.get("on_conflict") or "reject"
3398
+ _meta["override"] = bool(a.get("override"))
3399
+ _pay = _am.mutation_payload(
3400
+ _action, nid, after=_content,
3401
+ reason="mdcg_remember 直写(%s)" % _am.ACTION_NAMES[_action],
3402
+ primitive="add", meta=_meta)
3403
+ _pr = _am.propose_mutation(cg, _action, nid, payload=_pay,
3404
+ layer=_layer,
3405
+ sensitivity=a.get("sensitivity"),
3406
+ info=True)
3407
+ return {"ok": True, "id": nid, "committed": False,
3408
+ "moved_to": "review_queue", "pid": _pr["pid"],
3409
+ "mutation": _pay, "autonomy": _aut,
3410
+ "hint": "%s 本次未落盘:变更单已入审核队列(pid=%s,目标 %s)。"
3411
+ % (_dec["hint"], _pr["pid"], nid)}
3357
3412
  written = cg.add(nid, _content, layer=_layer,
3358
3413
  # B2:同上——落盘面丢字段=上游声明静默失效。
3359
3414
  sensitivity=a.get("sensitivity"),
@@ -3524,8 +3579,9 @@ def _dispatch(cg, name, args):
3524
3579
  return {"records": cg.review_records(pid=a.get("pid"))}
3525
3580
 
3526
3581
  if name == "mdcg_forget":
3527
- return cg.forget(a.get("node_id", ""), a.get("reason", ""),
3528
- override=bool(a.get("override")))
3582
+ # 三档自治批次②:与 cg(op=forget) 同一档位路径(forget_gated,见该分支注释)。
3583
+ return cg.forget_gated(a.get("node_id", ""), a.get("reason", ""),
3584
+ override=bool(a.get("override")))
3529
3585
 
3530
3586
  if name == "mdcg_restore":
3531
3587
  return cg.restore(a.get("node_id", ""), force=bool(a.get("force")))