@furongjun1999/dsh-memory 0.5.0 → 0.6.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 (156) hide show
  1. package/README.md +572 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  4. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  5. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  6. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  7. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  8. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  9. package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
  10. package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
  11. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  12. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
  13. package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
  14. package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
  15. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v1.md +156 -0
  16. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v10.md +210 -0
  17. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v11.md +227 -0
  18. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v12.md +203 -0
  19. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v13.md +233 -0
  20. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v14.md +191 -0
  21. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v15.md +213 -0
  22. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v16.md +214 -0
  23. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v17.md +199 -0
  24. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v18.md +183 -0
  25. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v19.md +207 -0
  26. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v2.md +156 -0
  27. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v3.md +152 -0
  28. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v4.md +128 -0
  29. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v5.md +114 -0
  30. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v6.md +192 -0
  31. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v7.md +187 -0
  32. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v8.md +207 -0
  33. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v9.md +203 -0
  34. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
  35. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
  36. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  37. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  38. 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
  39. package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
  40. package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.d.ts +9 -0
  43. package/lib/bridge.js +35 -0
  44. package/lib/cli.d.ts +3 -0
  45. package/lib/cli.js +66 -0
  46. package/lib/index.js +7 -1
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +287 -0
  49. package/lib/lib/roleplay_web.js +530 -443
  50. package/lib/lib/token_store.d.ts +7 -1
  51. package/lib/lib/token_store.js +12 -3
  52. package/md_cg/audit.py +12 -1
  53. package/md_cg/backfill.py +16 -15
  54. package/md_cg/backfill_bucket_zh.py +35 -0
  55. package/md_cg/bench_e2e_judge.py +532 -0
  56. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  57. package/md_cg/bench_e2e_qa.py +256 -0
  58. package/md_cg/branches.py +18 -2
  59. package/md_cg/ccgc.py +3 -2
  60. package/md_cg/chain.py +19 -4
  61. package/md_cg/consolidate.py +7 -6
  62. package/md_cg/crosscheck.py +4 -3
  63. package/md_cg/crypto.py +439 -437
  64. package/md_cg/datapath.py +395 -335
  65. package/md_cg/evidence.py +8 -3
  66. package/md_cg/export.py +3 -1
  67. package/md_cg/forgetting.py +2 -2
  68. package/md_cg/fsutil.py +48 -0
  69. package/md_cg/hotcache.py +255 -238
  70. package/md_cg/interop.py +161 -22
  71. package/md_cg/judgment_manifest.py +177 -0
  72. package/md_cg/links.py +655 -622
  73. package/md_cg/mcp_server.py +282 -31
  74. package/md_cg/mdcg.py +702 -126
  75. package/md_cg/mdcos.py +454 -68
  76. package/md_cg/mreview/govern.py +5 -4
  77. package/md_cg/postings.py +4 -2
  78. package/md_cg/readcache.py +76 -18
  79. package/md_cg/reconcile.py +228 -0
  80. package/md_cg/review_cli.py +215 -0
  81. package/md_cg/routing.py +28 -0
  82. package/md_cg/run_tests.py +211 -0
  83. package/md_cg/scrub.py +862 -852
  84. package/md_cg/security.py +385 -275
  85. package/md_cg/selfreport.py +3 -2
  86. package/md_cg/signer.py +565 -562
  87. package/md_cg/sources.py +3 -2
  88. package/md_cg/stg.py +6 -0
  89. package/md_cg/sustain.py +1168 -1138
  90. package/md_cg/test_access_hints.py +147 -0
  91. package/md_cg/test_branch_discard_tombstone.py +136 -0
  92. package/md_cg/test_branches.py +259 -249
  93. package/md_cg/test_chain_read_isolate.py +168 -0
  94. package/md_cg/test_datapath_device_name.py +203 -0
  95. package/md_cg/test_emit_negtail_cache.py +156 -0
  96. package/md_cg/test_en_pipeline.py +186 -166
  97. package/md_cg/test_govern_directread.py +421 -0
  98. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  99. package/md_cg/test_identity_attribution.py +228 -147
  100. package/md_cg/test_index_crossprocess_reload.py +301 -0
  101. package/md_cg/test_index_durability.py +238 -224
  102. package/md_cg/test_interop.py +4 -2
  103. package/md_cg/test_interop_judgment.py +228 -0
  104. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  105. package/md_cg/test_links_concurrent_write.py +188 -0
  106. package/md_cg/test_merge_upsert.py +168 -0
  107. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  108. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  109. package/md_cg/test_n131_merge_gate.py +205 -0
  110. package/md_cg/test_none_id_write_guard.py +165 -0
  111. package/md_cg/test_p1x_ref_root.py +160 -0
  112. package/md_cg/test_p27_docindex.py +774 -765
  113. package/md_cg/test_p2_mcp.py +3 -0
  114. package/md_cg/test_p32_backfill.py +304 -298
  115. package/md_cg/test_p39_verify_flow.py +90 -50
  116. package/md_cg/test_p47_session_view.py +60 -25
  117. package/md_cg/test_propose_tail_index.py +157 -0
  118. package/md_cg/test_read_scope_b27.py +277 -0
  119. package/md_cg/test_readcache_default_on.py +168 -0
  120. package/md_cg/test_readcache_precise_inval.py +270 -0
  121. package/md_cg/test_readcache_prodpath.py +55 -7
  122. package/md_cg/test_reconcile_v0.py +294 -0
  123. package/md_cg/test_retr_s1.py +6 -2
  124. package/md_cg/test_retr_s1b.py +67 -0
  125. package/md_cg/test_retr_s7.py +8 -0
  126. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  127. package/md_cg/test_retr_score_once.py +208 -0
  128. package/md_cg/test_review_cli_attribution.py +177 -0
  129. package/md_cg/test_review_cli_visibility.py +235 -0
  130. package/md_cg/test_review_onepass.py +170 -0
  131. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  132. package/md_cg/test_security_audit.py +155 -0
  133. package/md_cg/test_security_audit_b26.py +161 -0
  134. package/md_cg/test_security_audit_v21.py +250 -0
  135. package/md_cg/test_semantic_canonical.py +255 -241
  136. package/md_cg/test_session_isolation.py +168 -0
  137. package/md_cg/test_snapshot_autoclose.py +187 -0
  138. package/md_cg/test_tail_watermark_race.py +208 -0
  139. package/md_cg/test_tenant_env_override_warn.py +139 -0
  140. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  141. package/md_cg/test_v14_fixes.py +415 -397
  142. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  143. package/md_cg/theory.py +276 -273
  144. package/md_cg/tokens.py +734 -677
  145. package/md_cg/units.py +3 -2
  146. package/md_cg/vision_evidence.py +4 -3
  147. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  148. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  149. package/md_cg/writepipe.py +554 -550
  150. package/package.json +11 -2
  151. package/src/bridge.ts +434 -401
  152. package/src/cli.ts +65 -0
  153. package/src/index.ts +526 -518
  154. package/src/init.ts +347 -0
  155. package/src/lib/roleplay_web.ts +1019 -932
  156. package/src/lib/token_store.ts +202 -192
package/md_cg/mdcos.py CHANGED
@@ -36,7 +36,7 @@ from . import (nodefile, routing, chain, subgraph, forgetting, protect,
36
36
  self_state, predict, evolution, weights, pooling,
37
37
  writelimit, reach, trust, roleviews)
38
38
  from .fsutil import (FileLock, atomic_write, append_jsonl, read_jsonl,
39
- count_jsonl)
39
+ read_jsonl_tail, count_jsonl, publish)
40
40
  from .security import (Principal, TenantRegistry, AccessDenied,
41
41
  SENSITIVITY_ORDER, DEFAULT_SENSITIVITY, _rank)
42
42
 
@@ -326,6 +326,27 @@ class MdCGOS(MdCG):
326
326
  os.makedirs(self.trash_dir, exist_ok=True)
327
327
  self._audit_writes = 0 # 进程内写入计数(轮转探测节流,稳态零 stat)
328
328
  self._audit_index = None # 归档索引缓存(懒加载)
329
+ # 审核队列增量对账缓存(issue #32):inbox/decisions 均为 append-only
330
+ # (写点全在 strict 锁内经 append_jsonl,无轮转/截断),propose 的
331
+ # 幂等对账从「每条全量重读 O(M+D)」改为「stat 尾部增量」——跨进程
332
+ # append 由 stat 自动并入,无需显式失效。
333
+ self._inbox_idx = {} # phash → [(pid, node_id)] 文件序
334
+ self._inbox_idx_size = -1 # 已并入的 inbox 字节偏移(扫描水位)
335
+ self._inbox_idx_mtime = -1
336
+ self._pid_st = {} # pid → 最新裁决记录
337
+ self._pid_st_size = -1 # 已并入的 decisions 字节偏移
338
+ self._pid_st_mtime = -1
339
+ # review 治理面全记录增量缓存(issue #35):review_stats/review_list/
340
+ # review_decide/_cascade_dedup/verify_review_record 单次调用内曾各自
341
+ # 全量重读同一 jsonl(inbox 2 遍 + decisions 1-4 遍)——改为 stat 尾部
342
+ # 增量的全记录缓存(与 _pid_status 同款对账),调用方内存线性扫。
343
+ # 注意:缓存列表为内部状态,调用方只读不得变更。
344
+ self._inbox_recs = [] # inbox 全记录(文件序)
345
+ self._inbox_recs_size = -1
346
+ self._inbox_recs_mtime = -1
347
+ self._dec_recs = [] # decisions 全记录(文件序)
348
+ self._dec_recs_size = -1
349
+ self._dec_recs_mtime = -1
329
350
  # 热缓存挂载(issue #28,2026-09-23):读侧(search_rrf)与失效侧
330
351
  # (writepipe)早已接好,唯独无人调 attach——生产恒 None,重复 query
331
352
  # 无法命中。默认关(env 未设 → get(self) 恒 None,零变更纪律);
@@ -336,10 +357,14 @@ class MdCGOS(MdCG):
336
357
  _hc.attach(self)
337
358
  # 检索读缓存挂载(issue #31 P2,批次 21):MdStore 理论落地——节点
338
359
  # 文件解析产物常驻(567 池实测 572 次 open/查询 → 缓存后 ~0),写失
339
- # 效走 _dirty 代际哨兵(所有写路径必然标脏,无漏挂面)。默认关
340
- # (MDCG_READ_CACHE=1 显式启用,零变更纪律)。
341
- if os.environ.get("MDCG_READ_CACHE") == "1":
342
- from . import readcache as _rc
360
+ # 效走 _dirty 代际哨兵(所有写路径必然标脏,无漏挂面)。
361
+ # 默认**开**(issue #31 后续修复):生产检索入口(本类 search →
362
+ # _read_many)默认态每查询全池 open+2×realpath+frontmatter 解析,
363
+ # 3300 池实测 ~602ms/查询、万级外推 ~2s+(O(n) 线性);开关早已
364
+ # 存在但默认关 → 生产恒不装配。MDCG_READ_CACHE=0 显式关闭(opt-out
365
+ # 退出阀:跨进程改写须本进程立即可见 / 内存受限的部署回到旧行为)。
366
+ from . import readcache as _rc
367
+ if _rc.enabled():
343
368
  _rc.install(self)
344
369
 
345
370
  # ================= 6. payload-free 审计 =================
@@ -457,7 +482,7 @@ class MdCGOS(MdCG):
457
482
  name = "_audit.%06d.jsonl" % self._next_shard_seq()
458
483
  dst = os.path.join(self.audit_archive, name)
459
484
  try:
460
- os.replace(self.audit_log, dst)
485
+ publish(self.audit_log, dst) # Windows 短重试:AV 短锁不误判抢输
461
486
  except OSError:
462
487
  return None # 抢输(文件已被移走)→ 让位,不报错
463
488
  scale = self._audit_count_shard(dst, size) # 分档:有界扫描 / 只读量级
@@ -580,10 +605,22 @@ class MdCGOS(MdCG):
580
605
  """在父类 add 之上:写入 role(frontmatter + 索引),默认 role=None(知识)。"""
581
606
  if role is not None:
582
607
  kw["role"] = role
608
+ # 会话归属必须落 frontmatter:本文件 session 过滤的文档即写「frontmatter.session,
609
+ # 写入时自动落盘」,但父类 add 的 fm 固定字典里没有该键、_stage 只把它放进索引 →
610
+ # 实际只有索引带归属,任何 rebuild_index() 都会抹掉(实测 73 节点里 56 个无
611
+ # frontmatter.session,而 _audit.jsonl 的 add 事件却 152/152 都记了 session:
612
+ # 写入口径与落盘口径脱钩)。与 role 同款:显式给就尊重,否则取 self.session
613
+ # (MdCGSecure 由 principal.session 注入,mcp_server 按请求切换 cg.session)。
614
+ _sess = kw.get("session") or getattr(self, "session", None)
615
+ if _sess and not kw.get("session"):
616
+ kw["session"] = _sess
583
617
  nid = super().add(node_id, content, layer=layer, **kw)
584
618
  e = self.index["nodes"].get(nid)
585
619
  if e is not None and role is not None:
586
620
  e["role"] = role
621
+ if e is not None and _sess:
622
+ e["session"] = _sess
623
+ if e is not None and (role is not None or _sess):
587
624
  self._dirty[nid] = e
588
625
  self._audit("add", nid, layer=layer, role=role,
589
626
  payload_hash=_sig(content))
@@ -727,6 +764,7 @@ class MdCGOS(MdCG):
727
764
  q = unify_query(q)
728
765
  if not q:
729
766
  return [], {"tier": None, "reason": "empty_query", "scanned": 0}
767
+ self._maybe_reload_index() # P1b-2:读面代际感知(route/read/search 链)
730
768
  pool_cfg = pooling.resolve(pooling.from_env(pools))
731
769
  entries = self._candidates(layer=layer, roles=roles, include_work=include_work,
732
770
  session=session, branch=branch, validity=validity,
@@ -758,6 +796,12 @@ class MdCGOS(MdCG):
758
796
  if context is not None:
759
797
  ctx = context if isinstance(context, dict) else {}
760
798
  route_bucket = routing.bucket_dir(routing.route_key(ctx, ctx.get("tags")))
799
+ # 与 MdCG.search 同一纪律(X2):无域信号时不施加桶约束,否则 route_key
800
+ # 回落的 orphan 会被当成真桶,下面 T0/T1 阶段读到该桶并因 valid>=min_results
801
+ # 直接 return,**全量阶梯永不执行** —— 标了 domain: 的节点整片不可见。
802
+ # 本覆写才是生产路径(MdCGSecure → 本类):缺此条则 MCP 端依旧丢召回。
803
+ if route_bucket == routing.ORPHAN:
804
+ route_bucket = None
761
805
  big_domain = routing.big_domain_classify(terms)
762
806
  big_scores = routing.big_domain_score_breakdown(terms)
763
807
  # S1/S1b/S2 收敛 + S4 审计——与 MdCG.search 共享同一实现(issue #25:
@@ -771,9 +815,15 @@ class MdCGOS(MdCG):
771
815
  stat["gates"] = gates
772
816
  neg_coverage = self._neg_coverage(terms) if include_neg else []
773
817
 
774
- # 生效条件:对 docs 调 self._score 后,若其中分数 >0 的条数达到(search 作用域内的)min_results 就返回 self._emit(scored, k, tier, stat, route_bucket, record, len(docs), ...),否则返回 None。
775
- def try_stage(docs, tier):
776
- scored = self._score(docs, q, qb, pool_cfg)
818
+ # 生效条件:scored 为 None 时对 docs 调 self._score(docs, q, qb, pool_cfg),非 None 时直接以之为打分结果;分数 >0 的条数达到(search 作用域内的)min_results 就返回 self._emit(scored, k, tier, stat, route_bucket, record, len(docs), ...),否则返回 None。
819
+ def try_stage(docs, tier, scored=None):
820
+ # P2-4 回移(v7 N51 留档修复,缺陷迭代第 14 轮):支持传入已打分
821
+ # 结果——此前 T2/T3 均为「打分截断后又对 picked 重打」(600 池实测
822
+ # _score 4 调 [0,0,600,500]=1100 文档,500/1100≈45% 冗余)。与基类
823
+ # try_stage(mdcg.py P2-4,批次 31)同模板;_score 在单次 search 内
824
+ # 是纯函数(docs/q/qb/pool_cfg 均不变),透传不改变任何分值与输出。
825
+ if scored is None:
826
+ scored = self._score(docs, q, qb, pool_cfg)
777
827
  valid = sum(1 for _, s in scored if s > 0)
778
828
  if valid >= min_results:
779
829
  return self._emit(scored, k, tier, stat, route_bucket, record,
@@ -781,6 +831,22 @@ class MdCGOS(MdCG):
781
831
  big_domain, big_scores, pool_cfg)
782
832
  return None
783
833
 
834
+ def _prescored(docs, scored_all):
835
+ """P2-4 透传形态:按 node_id 键表把 docs 映射回已打分的
836
+ (card, score) 原对(恰为 _score 对该 docs 的输出,picked 序)。
837
+
838
+ 键口径:scored 元素是 (card, score),card["id"] = fm.get("id")
839
+ or e["path"],恰为 doc_key(doc)[0](V21-6 取 [0] 作键同源)——
840
+ **不可**对 scored 元素调 pooling.doc_key(其形参是 (entry, fm,
841
+ content) 三元组;基类 reach 分支旧模板即此形态错误,MDCG_REACH=1
842
+ 时 KeyError: 1,见 mdcg.py 同修)。**不可**自组 (doc, score) 对
843
+ (_emit 排序键读 x[0]["frontmatter"],x[0] 须是 card dict)。
844
+ docs 必为 scored_all 的子序列(cut_by_relevance 只选不造),
845
+ 键表必命中。
846
+ """
847
+ smap = {c.get("id"): (c, s) for c, s in scored_all}
848
+ return [smap[pooling.doc_key(d)[0]] for d in docs]
849
+
784
850
  if route_bucket:
785
851
  in_bucket = [e for e in entries if e.get("bucket") == route_bucket]
786
852
  if in_bucket:
@@ -819,11 +885,15 @@ class MdCGOS(MdCG):
819
885
  stat["cap"] = GLOBAL_CAP
820
886
  # 与 T2 **无条件**同序调用(不可按 cap 短路:cut_by_relevance 还会写
821
887
  # 池账 pool_taken/cands/lost,短路会让 meta.pools.taken 缺失 → test_p43(13) 红)
822
- hits_r, _rep = cut_by_relevance(hits_r, self._score(hits_r, q, qb, pool_cfg),
888
+ scored_r = self._score(hits_r, q, qb, pool_cfg)
889
+ hits_r, _rep = cut_by_relevance(hits_r, scored_r,
823
890
  GLOBAL_CAP, pools=pool_cfg,
824
891
  key_of=pooling.doc_key, stat=stat)
825
892
  pooling.record_audit(stat, _rep)
826
- out = try_stage(hits_r, TIER_GLOBAL_LIKE)
893
+ # P2-4:打分结果经 scored 透传,不再对同一 hits_r 二次 _score
894
+ # (与基类 reach 分支同模板)。
895
+ out = try_stage(hits_r, TIER_GLOBAL_LIKE,
896
+ scored=_prescored(hits_r, scored_r))
827
897
  if out:
828
898
  return out
829
899
  # 收敛阶段未产出结果 → 继续走下方全量 T2/T3;此时必须**改写 reach 审计**,
@@ -844,22 +914,27 @@ class MdCGOS(MdCG):
844
914
  or (semantic_on() and d[1].get("semantic"))]
845
915
  stat["pre_cap"] = len(hits)
846
916
  stat["cap"] = GLOBAL_CAP
847
- hits, _rep = cut_by_relevance(hits, self._score(hits, q, qb, pool_cfg),
917
+ scored_h = self._score(hits, q, qb, pool_cfg)
918
+ hits, _rep = cut_by_relevance(hits, scored_h,
848
919
  GLOBAL_CAP, pools=pool_cfg,
849
920
  key_of=pooling.doc_key, stat=stat)
850
921
  pooling.record_audit(stat, _rep)
851
- out = try_stage(hits, TIER_GLOBAL_LIKE)
922
+ # P2-4:截断打分结果透传,T2 腿不再对 hits 二次 _score。
923
+ out = try_stage(hits, TIER_GLOBAL_LIKE,
924
+ scored=_prescored(hits, scored_h))
852
925
  if out:
853
926
  return out
854
927
 
855
928
  stat["pre_cap"] = len(docs_all)
856
929
  stat["cap"] = GLOBAL_CAP
857
- picked, _rep = cut_by_relevance(docs_all,
858
- self._score(docs_all, q, qb, pool_cfg),
930
+ scored_a = self._score(docs_all, q, qb, pool_cfg)
931
+ picked, _rep = cut_by_relevance(docs_all, scored_a,
859
932
  GLOBAL_CAP, pools=pool_cfg,
860
933
  key_of=pooling.doc_key, stat=stat)
861
934
  pooling.record_audit(stat, _rep)
862
- scored = self._score(picked, q, qb, pool_cfg)
935
+ # P2-4:T3 不再对 picked 重打(600 池实测此腿冗余 500 文档/查询,
936
+ # GLOBAL_CAP=500 截断后 picked ⊆ docs_all,键表回填即得终榜打分)。
937
+ scored = _prescored(picked, scored_a)
863
938
  return self._emit(scored, k, TIER_GLOBAL_SCAN, stat, route_bucket,
864
939
  record, len(picked), judge,
865
940
  context, neg_coverage, big_domain, big_scores, pool_cfg)
@@ -915,6 +990,16 @@ class MdCGOS(MdCG):
915
990
  return []
916
991
  ctx = context if isinstance(context, dict) else {}
917
992
  b = routing.bucket_dir(routing.route_key(ctx, ctx.get("tags")))
993
+ # 「无域信号」等价于「没声明条件」:条件桶路此时必须**弃权**,不能把
994
+ # route_key 回落的 orphan 当成一个真桶去硬过滤——否则凡是被标了 domain:
995
+ # (因而落在 cond_* 目录)的节点,对这条查询就整片不可见。
996
+ # 实测(带 context 但不含 domain / observation_position):弃权前只扫
997
+ # orphan 桶 10 个节点、目标节点召回丢失(T1_bucket_scan/scanned=10);
998
+ # 弃权后由词法/实体/图路正常召回(T2_global_like/scanned=75)。
999
+ # 与上面 context is None 的既有早返回同构,也贴合 test_p0 的
1000
+ # 「桶路由平均 recall@10 ≥ 0.9(加 context 不丢召回)」不变量。
1001
+ if b == routing.ORPHAN:
1002
+ return []
918
1003
  inb = [e for e in entries if e.get("bucket") == b]
919
1004
  if not inb:
920
1005
  return []
@@ -939,7 +1024,7 @@ class MdCGOS(MdCG):
939
1024
  "content": c, "path": e["path"]}, 1.0))
940
1025
  return out
941
1026
 
942
- # 生效条件:当 seeds 非空时,仅取前 5 个种子,从每个种子节点的 frontmatter.edges 取 target(dict 取 target,否则 str(edge)),若 target 在 entries 映射中且不在 seed_ids 中则读取并追加分数 s*0.5;seeds 为空返回 [];depth 默认 1 但本段未使用;
1027
+ # 生效条件:当 seeds 非空时,仅取前 5 个种子,种子的 edges 取法为:其 id 在 entries 的 path 文件名映射(by_id)中时经 self._read(e_seed) 读 frontmatter(读缓存覆盖——get 裸 open 不在缓存包装面),不在映射中(fm.id≠文件名等形态)时回落 self.get(n["id"])(旧口径,冷路径);从 edges 取 target(dict 取 target,否则 str(edge)),若 target 在 entries 映射中且不在 seed_ids 中则读取并追加分数 s*0.5;seeds 为空返回 [];depth 默认 1 但本段未使用;
943
1028
  def _path_graph(self, query, entries, seeds, depth=1):
944
1029
  """图扩展路径:从词法种子沿 edges 一跳扩展。"""
945
1030
  if not seeds:
@@ -951,10 +1036,24 @@ class MdCGOS(MdCG):
951
1036
  by_id[nid] = e
952
1037
  out = []
953
1038
  for n, s in seeds[:5]:
954
- node = self.get(n["id"])
955
- if not node:
956
- continue
957
- for edge in (node["frontmatter"].get("edges") or []):
1039
+ # 种子读改走 self._read(v9:92/v13 留档修复,缺陷迭代第 14 轮):
1040
+ # get 裸 open+nodefile.loads,不在 readcache.install 的包装面
1041
+ #(只包 cg._read)——search_rrf 默认 paths 含 graph,每查询固定
1042
+ # ≤5 次盘读(40 池实测 Q2 增量恰 5,graph 零产出也在边循环前
1043
+ # 发生);边目标(下方 self._read)早已同形。种子只要 frontmatter
1044
+ # 的 edges(content 不解封不用),种子来自 lexical(entries/
1045
+ # _candidates 派生,MdCGSecure._candidates 已过 _readable)——
1046
+ # 绕过 get 的读隔离门无损失,图路扩展产物本由
1047
+ # MdCGSecure.search_rrf 终态二次过滤兜底。by_id 不含(fm.id≠
1048
+ # 文件名等形态)回落 get 保旧口径。
1049
+ e_seed = by_id.get(n["id"])
1050
+ if e_seed is not None:
1051
+ fm_s, _c = self._read(e_seed)
1052
+ edges = (fm_s or {}).get("edges") or []
1053
+ else:
1054
+ node = self.get(n["id"])
1055
+ edges = ((node or {}).get("frontmatter") or {}).get("edges") or []
1056
+ for edge in edges:
958
1057
  tid = edge.get("target") if isinstance(edge, dict) else str(edge)
959
1058
  if tid in by_id and tid not in seed_ids:
960
1059
  e = by_id[tid]
@@ -1210,6 +1309,10 @@ class MdCGOS(MdCG):
1210
1309
  q = unify_query(q)
1211
1310
  if not q:
1212
1311
  return [], {"tier": None, "reason": "empty_query", "paths": {}}
1312
+ # P1b-2:读面代际感知——必须在热路径 query 缓存查询**之前**:旧索引
1313
+ # 代里缓存的命中结果对新写入不可见(静默错答),重载成功时
1314
+ # _maybe_reload_index 已顺带失效 hotcache/readcache。
1315
+ self._maybe_reload_index()
1213
1316
  # 热路径:query 结果缓存命中即返回(不改 RRF 核心)
1214
1317
  # 时间算子**显式启用时绕开缓存**:缓存键不含时间参数,命中会返回
1215
1318
  # 未按本次窗口过滤的结果(静默错答比慢更贵)。
@@ -1228,6 +1331,9 @@ class MdCGOS(MdCG):
1228
1331
  "judge": bool(judge), "judge_ranking": bool(judge_ranking),
1229
1332
  "goal_text": goal_text, "context": context,
1230
1333
  "early_stop_threshold": early_stop_threshold,
1334
+ # 进程级开关也改口径(v14 只补了参数面):MDCG_SEMANTIC /
1335
+ # MDCG_EN_ATOMS / MDCG_UNIFY_QUERY 等不入键即跨口径命中(2026-09-24 修复)。
1336
+ "env_switch": _hc.env_switch_key(),
1231
1337
  }
1232
1338
  # 不可稳定进键的参数(自定义可调用 query_expand):非默认即**绕行**
1233
1339
  # 缓存(读+写双侧闭合)——fail-closed,宁可不用缓存也不串味。
@@ -1631,17 +1737,18 @@ class MdCGOS(MdCG):
1631
1737
  """
1632
1738
  phash = _sig(content)
1633
1739
  with FileLock(self.inbox_log, strict=True):
1740
+ # 增量对账(issue #32):原实现每条全量 read_jsonl(inbox) +
1741
+ # read_jsonl(decisions)(锁内 O(M+D)/条、批量 O(M²),旧格式行
1742
+ # 逐条重算 sha1);改走 stat 尾部增量索引——文件序与覆盖语义
1743
+ # 与原全量扫描逐位一致(同 phash 按文件序取,已裁决优先 break)。
1744
+ ph_idx = self._inbox_phash_index()
1634
1745
  st = self._pid_status()
1635
1746
  dup = None
1636
- for r in read_jsonl(self.inbox_log):
1637
- # 存量记录无 payload_hash 字段 → 现算兼容(对账覆盖旧账)
1638
- rh = r.get("payload_hash") or _sig(r.get("content") or "")
1639
- if rh != phash:
1640
- continue
1641
- s = st.get(r.get("pid")) or {}
1642
- dup = {"pid": r.get("pid"),
1747
+ for pid, nid in ph_idx.get(phash, ()):
1748
+ s = st.get(pid) or {}
1749
+ dup = {"pid": pid,
1643
1750
  "status": s.get("status") or "pending",
1644
- "node_id": r.get("id")}
1751
+ "node_id": nid}
1645
1752
  if dup["status"] in ("accepted", "rejected"):
1646
1753
  break # 已裁决的最有信息量,优先返回
1647
1754
  if dup:
@@ -1675,14 +1782,135 @@ class MdCGOS(MdCG):
1675
1782
  "dup_of": None, "dup_status": None}
1676
1783
  return pid
1677
1784
 
1678
- # 生效条件:当 self.decisions_log 可被 read_jsonl 读取时,仅 r.get("pid") 为真值的记录写入 st[r["pid"]],后出现记录覆盖先出现记录,返回 pid → 最新一条裁决记录的字典;
1785
+ # 生效条件:stat decisions_log 失败(OSError)时把 _pid_st 置空 dict、两扫描键置 -1 并返回空 dict;size 与 mtime_ns 均与缓存键一致时直接返回 _pid_st(零解析);缓存键为 -1(首装)或当前 size 小于已扫描偏移(外部非合作重写/回退)时经 read_jsonl_tail(path, 0) 全量重建 _pid_st,否则经 read_jsonl_tail 从已扫描偏移起增量并入,逐条把 r.get("pid") 为真值的记录写入 _pid_st(后并入覆盖先并入);水位存 read_jsonl_tail 返回的真实 EOF 偏移(end 为 None 时回落 stat size),mtime_ns 存读前 stat 值,返回 _pid_st。
1679
1786
  def _pid_status(self):
1680
- """pid → 最新一条裁决记录(多轮再审批时取最后一轮)。"""
1681
- st = {}
1682
- for r in read_jsonl(self.decisions_log):
1787
+ """pid → 最新一条裁决记录(多轮再审批时取最后一轮)。
1788
+
1789
+ 增量缓存(issue #32):原实现每调用全量 read_jsonl(decisions_log)
1790
+ (json.loads 逐条),propose 对账每条 O(D)、批量 O(M×D)。append-only
1791
+ 契约下改为 stat 尾部对账:size/mtime 未变零解析;size 增长只解析
1792
+ 新增字节;size 回退/首装全量重建兜底。mtime 单变 size 不变(touch
1793
+ 类)走增量窗口=0 行,结果不变。跨进程 append 由 stat 自动并入。
1794
+ 水位口径(竞态修复):存 read_jsonl_tail 返回的**真实 EOF 偏移**,
1795
+ 不存读前 stat 的 size——stat→read 窗口内的并发 append 本轮已并入,
1796
+ 水位偏小会让下一轮重读同一段(本缓存是 dict 后写覆盖不受损,但与
1797
+ 列表型缓存共用同一口径)。
1798
+ 边界(与检索读缓存同款诚实声明):外部**非 append 形态**重写文件
1799
+ (同 size 改内容)不在合作写者协议内,不保证可见。
1800
+ """
1801
+ try:
1802
+ sti = os.stat(self.decisions_log)
1803
+ size, mtime = sti.st_size, sti.st_mtime_ns
1804
+ except OSError:
1805
+ self._pid_st, self._pid_st_size, self._pid_st_mtime = {}, -1, -1
1806
+ return self._pid_st
1807
+ if size == self._pid_st_size and mtime == self._pid_st_mtime:
1808
+ return self._pid_st # 无新内容(零解析)
1809
+ full = self._pid_st_size < 0 or size < self._pid_st_size
1810
+ if full:
1811
+ self._pid_st = {}
1812
+ src, end = read_jsonl_tail(self.decisions_log,
1813
+ 0 if full else self._pid_st_size)
1814
+ for r in src:
1683
1815
  if r.get("pid"):
1684
- st[r["pid"]] = r
1685
- return st
1816
+ self._pid_st[r["pid"]] = r
1817
+ self._pid_st_size = size if end is None else end
1818
+ self._pid_st_mtime = mtime
1819
+ return self._pid_st
1820
+
1821
+ # 生效条件:stat inbox_log 失败(OSError)时把 _inbox_idx 置空 dict、两扫描键置 -1 并返回空 dict;size 与 mtime_ns 均与缓存键一致时直接返回 _inbox_idx(零解析);缓存键为 -1(首装)或当前 size 小于已扫描偏移时经 read_jsonl_tail(path, 0) 全量重建,否则经 read_jsonl_tail 增量并入,逐条以 r.get("payload_hash") 或(缺键时)_sig(r.get("content") or "") 为 ph 把 (r.get("pid"), r.get("id")) 追加进 _inbox_idx[ph](文件序);水位存 read_jsonl_tail 返回的真实 EOF 偏移(end 为 None 时回落 stat size),mtime_ns 存读前 stat 值,返回 _inbox_idx。
1822
+ def _inbox_phash_index(self):
1823
+ """inbox 的 phash → [(pid, node_id)](文件序)增量索引(propose 对账用)。
1824
+
1825
+ 与 _pid_status 同款 stat 尾部对账(issue #32)。存量旧格式行
1826
+ (无 payload_hash)在**并入时**现算一次 _sig(此后常驻,不再
1827
+ 每条 propose 重算——旧路径每条重算 M 次 sha1)。
1828
+ 水位口径(竞态修复,同 _pid_status):存真实 EOF 偏移,不存读前
1829
+ stat 的 size——窗口内并发 append 不得在下轮重复并入。
1830
+ """
1831
+ try:
1832
+ sti = os.stat(self.inbox_log)
1833
+ size, mtime = sti.st_size, sti.st_mtime_ns
1834
+ except OSError:
1835
+ self._inbox_idx, self._inbox_idx_size, self._inbox_idx_mtime = \
1836
+ {}, -1, -1
1837
+ return self._inbox_idx
1838
+ if size == self._inbox_idx_size and mtime == self._inbox_idx_mtime:
1839
+ return self._inbox_idx # 无新内容(零解析)
1840
+ full = self._inbox_idx_size < 0 or size < self._inbox_idx_size
1841
+ if full:
1842
+ self._inbox_idx = {}
1843
+ src, end = read_jsonl_tail(self.inbox_log,
1844
+ 0 if full else self._inbox_idx_size)
1845
+ for r in src:
1846
+ ph = r.get("payload_hash") or _sig(r.get("content") or "")
1847
+ self._inbox_idx.setdefault(ph, []).append(
1848
+ (r.get("pid"), r.get("id")))
1849
+ self._inbox_idx_size = size if end is None else end
1850
+ self._inbox_idx_mtime = mtime
1851
+ return self._inbox_idx
1852
+
1853
+ # 生效条件:stat inbox_log 失败(OSError)时把 _inbox_recs 置空列表、两扫描键置 -1 并返回它;size 与 mtime_ns 均与缓存键一致时直接返回 _inbox_recs(零解析);缓存键为 -1(首装)或当前 size 小于已扫描偏移时以 read_jsonl_tail(path, 0) 全量重建,否则以 read_jsonl_tail(offset) 结果 extend 增量并入;水位存 read_jsonl_tail 返回的真实 EOF 偏移(end 为 None 时回落 stat size),mtime_ns 存读前 stat 值,返回 _inbox_recs(内部缓存,调用方只读不得变更)。
1854
+ def _inbox_records(self):
1855
+ """inbox 全记录(文件序)增量缓存——review 治理面单次调用共用一次解析。
1856
+
1857
+ 与 _pid_status 同款 stat 尾部对账(issue #35):原 review_decide 线性
1858
+ 扫 inbox 找 pid、_cascade_dedup 再全量读一遍(2 遍/调用);改走本
1859
+ 缓存后稳态零解析,调用方在内存列表上线性扫(万条 ~1ms 级,无 IO)。
1860
+ 水位口径(竞态修复):存 read_jsonl_tail 返回的**真实 EOF 偏移**,
1861
+ 不存读前 stat 的 size——stat→read 窗口内的并发 append 本轮已并入,
1862
+ 水位偏小会让下一轮 extend 重读同一条(review_list 重复 pid、
1863
+ _cascade_dedup 连写两条 reject),且稳态 size 只增、永不自愈。
1864
+ """
1865
+ try:
1866
+ sti = os.stat(self.inbox_log)
1867
+ size, mtime = sti.st_size, sti.st_mtime_ns
1868
+ except OSError:
1869
+ self._inbox_recs, self._inbox_recs_size, self._inbox_recs_mtime = \
1870
+ [], -1, -1
1871
+ return self._inbox_recs
1872
+ if size == self._inbox_recs_size and mtime == self._inbox_recs_mtime:
1873
+ return self._inbox_recs # 无新内容(零解析)
1874
+ full = self._inbox_recs_size < 0 or size < self._inbox_recs_size
1875
+ if full:
1876
+ self._inbox_recs = []
1877
+ src, end = read_jsonl_tail(self.inbox_log,
1878
+ 0 if full else self._inbox_recs_size)
1879
+ self._inbox_recs.extend(src)
1880
+ self._inbox_recs_size = size if end is None else end
1881
+ self._inbox_recs_mtime = mtime
1882
+ return self._inbox_recs
1883
+
1884
+ # 生效条件:stat decisions_log 失败(OSError)时把 _dec_recs 置空列表、两扫描键置 -1 并返回它;size 与 mtime_ns 均与缓存键一致时直接返回 _dec_recs(零解析);缓存键为 -1(首装)或当前 size 小于已扫描偏移时以 read_jsonl_tail(path, 0) 全量重建,否则以 read_jsonl_tail(offset) 结果 extend 增量并入;水位存 read_jsonl_tail 返回的真实 EOF 偏移(end 为 None 时回落 stat size),mtime_ns 存读前 stat 值,返回 _dec_recs(内部缓存,调用方只读不得变更)。
1885
+ def _decisions_records(self):
1886
+ """decisions 全记录(文件序)增量缓存——review 治理面单次调用共用一次解析。
1887
+
1888
+ issue #35:原 review_stats 全量读 decisions 统计、verify_review_record
1889
+ 全量线性扫找单条 pid(每调用 1 遍整文件 json.loads);改走本缓存后
1890
+ 稳态零解析,调用方内存线性扫。与 _pid_status(pid→最新)互补:
1891
+ 本缓存保全量记录(多轮裁决历史、by_decision 统计口径)。
1892
+ 水位口径(竞态修复,同 _inbox_records):存真实 EOF 偏移——窗口内
1893
+ 并发 append 不得在下一轮重复 extend(review_stats 计数虚高/翻倍、
1894
+ 永不自愈)。
1895
+ """
1896
+ try:
1897
+ sti = os.stat(self.decisions_log)
1898
+ size, mtime = sti.st_size, sti.st_mtime_ns
1899
+ except OSError:
1900
+ self._dec_recs, self._dec_recs_size, self._dec_recs_mtime = \
1901
+ [], -1, -1
1902
+ return self._dec_recs
1903
+ if size == self._dec_recs_size and mtime == self._dec_recs_mtime:
1904
+ return self._dec_recs # 无新内容(零解析)
1905
+ full = self._dec_recs_size < 0 or size < self._dec_recs_size
1906
+ if full:
1907
+ self._dec_recs = []
1908
+ src, end = read_jsonl_tail(self.decisions_log,
1909
+ 0 if full else self._dec_recs_size)
1910
+ self._dec_recs.extend(src)
1911
+ self._dec_recs_size = size if end is None else end
1912
+ self._dec_recs_mtime = mtime
1913
+ return self._dec_recs
1686
1914
 
1687
1915
  # 生效条件:实例已构建(内部先读 _pid_status());返回其 status ∈ TERMINAL_DECISION_STATUS(accepted/rejected/noop)的 pid 集合,status=="needs_reapproval" 视为未关闭、不计入;
1688
1916
  def _closed_pids(self):
@@ -1695,7 +1923,9 @@ class MdCGOS(MdCG):
1695
1923
  """待审核候选(含被红队打回、待再审批的条目)。"""
1696
1924
  st = self._pid_status()
1697
1925
  out = []
1698
- for r in read_jsonl(self.inbox_log):
1926
+ # issue #35:走全记录增量缓存(稳态零解析)——review_stats 链路单次
1927
+ # 调用共用一次解析;rec=dict(r) 复制,不改内部缓存。
1928
+ for r in self._inbox_records():
1699
1929
  s = st.get(r.get("pid")) or {}
1700
1930
  if s.get("status") in TERMINAL_DECISION_STATUS:
1701
1931
  continue
@@ -1716,7 +1946,9 @@ class MdCGOS(MdCG):
1716
1946
  视图复用 review_list/_closed_pids,避免在此复制第二份终态判定逻辑。
1717
1947
  """
1718
1948
  by = {}
1719
- for r in read_jsonl(self.decisions_log):
1949
+ # issue #35:走全记录增量缓存(稳态零解析)——原每次全量逐行
1950
+ # json.loads,与 review_list/_closed_pids 链路重复解析同一文件。
1951
+ for r in self._decisions_records():
1720
1952
  d = str(r.get("decision") or "").strip() or "unknown"
1721
1953
  by[d] = by.get(d, 0) + 1
1722
1954
  return {"records": sum(by.values()), "by_decision": by,
@@ -1845,7 +2077,9 @@ class MdCGOS(MdCG):
1845
2077
  closed = []
1846
2078
  with FileLock(self.decisions_log, strict=True):
1847
2079
  st = self._pid_status()
1848
- for r in read_jsonl(self.inbox_log):
2080
+ # issue #35:走全记录增量缓存(稳态零解析)——原与 review_decide
2081
+ # 开头的找 pid 扫描重复全量读同一 inbox(2 遍/调用)。
2082
+ for r in self._inbox_records():
1849
2083
  bpid = r.get("pid")
1850
2084
  if not bpid or bpid == pid or bpid in st:
1851
2085
  continue # 自己 / 已有裁决记录(含 needs_reapproval)跳过
@@ -1896,7 +2130,9 @@ class MdCGOS(MdCG):
1896
2130
  return {"ok": False, "error": "node_not_found"}
1897
2131
  fm = node.get("frontmatter") or {}
1898
2132
  pid, rnd = fm.get("pid"), fm.get("round")
1899
- src = next((r for r in read_jsonl(self.decisions_log)
2133
+ # issue #35:走全记录增量缓存后内存线性扫(稳态零解析)——原每次
2134
+ # 全量逐行 json.loads 整份 decisions 只为找一条 pid+round。
2135
+ src = next((r for r in self._decisions_records()
1900
2136
  if r.get("pid") == pid
1901
2137
  and int(r.get("round") or 0) == int(rnd or 0)), None)
1902
2138
  if not src:
@@ -1911,10 +2147,11 @@ class MdCGOS(MdCG):
1911
2147
  "verify_hash": src.get("verify_hash"),
1912
2148
  "source": "hippocampus/decisions.jsonl"}
1913
2149
 
1914
- # 生效条件:decision 须为 DECISION_ACTIONS("accept"/"reject"/"edit"/"merge"/"noop")之一(否则 raise ValueError),inbox_log 中须有 pid 匹配记录(否则 {'ok': False, 'error': 'pid_not_found'}),且 pid 不在 self._closed_pids() 中(否则 'already_decided');edits 为真值且含 "verify"、或 redteam 为真值且含 "verify" 时返回 'verify_readonly';last_status=="needs_reapproval" 时须 redteam.verdict 归一化为 "pass" 且 round_no>last_round(否则 'reapproval_required' / 'round_not_advanced');rt_v=="reject" 或(decision=="reject" 且 rt_issues 非空)时记 needs_reapproval 不落节点;decision=="accept" 且 rt_v 为空且 _redteam_required() 为真时返回 'redteam_required';其余 accept/edit 按 item(edit 时用 edits.get 覆盖 content/tags/layer)add+flush 落节点,merge 须 merge_into 或 item.extra.merge_into 指向的节点存在(否则 'merge_target_not_found')后追加内容并 rebuild_index,reject 与 noop 只记裁决(status 分别为 "rejected"/"noop");最后统一 _record_decision + _cascade_dedup + flush 后返回 result。
2150
+ # 生效条件:decision 须为 DECISION_ACTIONS("accept"/"reject"/"edit"/"merge"/"noop")之一(否则 raise ValueError),inbox_log 中须有 pid 匹配记录(否则 {'ok': False, 'error': 'pid_not_found'}),且 pid 不在 self._closed_pids() 中(否则 'already_decided');edits 为真值且含 "verify"、或 redteam 为真值且含 "verify" 时返回 'verify_readonly';last_status=="needs_reapproval" 时须 redteam.verdict 归一化为 "pass" 且 round_no>last_round(否则 'reapproval_required' / 'round_not_advanced');rt_v=="reject" 或(decision=="reject" 且 rt_issues 非空)时记 needs_reapproval 不落节点;decision=="accept" 且 rt_v 为空且 _redteam_required() 为真时返回 'redteam_required';其余 accept/edit 按 item(edit 时用 edits.get 覆盖 content/tags/layer)add+flush 落节点,merge 须 merge_into 或 item.extra.merge_into 指向的节点存在(否则 'merge_target_not_found'),且写入前经与 add 同款双闸(N131,2026-09-25:principal 在位先 require_layer_write(目标层, 目标敏感度)——与 MdCGSecure.add 同序同错型;再 protect.guard_write(override=override)——self/anchor 层、immutable 标记,拒绝抛 ProtectionError/AccessDenied 且提案留 pending 不落库)后追加内容并定向索引 upsert(_node_entry 同源条目写入 index/_dirty)+flush,reject 与 noop 只记裁决(status 分别为 "rejected"/"noop");最后统一 _record_decision + _cascade_dedup + flush 后返回 result。
1915
2151
  def review_decide(self, pid: str, decision: str, edits: dict = None,
1916
2152
  merge_into: str = None, reason: str = "",
1917
- redteam: dict = None, issues=None):
2153
+ redteam: dict = None, issues=None,
2154
+ override: bool = False):
1918
2155
  """审核裁决:accept / reject / edit / merge / noop。
1919
2156
 
1920
2157
  accept → 按 inbox 原样写入
@@ -1936,7 +2173,9 @@ class MdCGOS(MdCG):
1936
2173
  """
1937
2174
  if decision not in DECISION_ACTIONS:
1938
2175
  raise ValueError(f"未知裁决:{decision}")
1939
- item = next((r for r in read_jsonl(self.inbox_log)
2176
+ # issue #35:走全记录增量缓存后内存线性扫(稳态零解析)——原每次
2177
+ # 全量逐行 json.loads 整份 inbox 只为找一条 pid。
2178
+ item = next((r for r in self._inbox_records()
1940
2179
  if r.get("pid") == pid), None)
1941
2180
  if not item:
1942
2181
  return {"ok": False, "error": "pid_not_found"}
@@ -2003,13 +2242,24 @@ class MdCGOS(MdCG):
2003
2242
  if item.get("verify"):
2004
2243
  extra["verify"] = item["verify"]
2005
2244
  extra["verify_hash"] = expect
2245
+ # 归因保留(P1,2026-09-26,DSH 端在役实测:5 个裁决节点 writer 全被
2246
+ # designer-cli 覆盖、会话 id 各自随机):提案记录的 actor 是 propose
2247
+ # 时库端快照的原始写入者身份(见 propose rec 的 "actor"/"session" 字段,
2248
+ # 非裁决期客户端输入,不可伪造面)。accept/edit 落盘若不显式带上,
2249
+ # _attribution 的 setdefault 会把 writer 缺省成裁决者——原始写入者
2250
+ # 就此不可追溯。writer 保留原值(提案 extra 里已带 writer 的存量提案
2251
+ # 不覆盖,向后兼容);裁决者身份不丢失,改记 reviewer 字段。
2252
+ if item.get("actor"):
2253
+ extra.setdefault("writer", item["actor"])
2254
+ extra.setdefault("reviewer", self.actor)
2006
2255
  nid = self.add(item["id"], content, layer=layer, tags=tags,
2007
2256
  condition_space=item.get("condition_space"), **extra)
2008
2257
  # 索引增量收尾(2026-09-16 取证):add 只把条目放进本进程内存 _dirty,
2009
2258
  # 未达 autoflush(64) 阈值时进程退出即永久丢失——裁决进程(review_cli /
2010
2259
  # MCP op=review)通常只写 1~2 条,不 flush 则「节点在盘上但索引无条目」,
2011
2260
  # 其他进程与重载后的长驻进程都检索不到,只能靠某次全量 rebuild 偶然救回。
2012
- # merge 分支绕开 add 直写节点文件,故其收尾同为索引重建(同因不同法)。
2261
+ # merge 分支同样直写节点文件,其收尾已改为同款定向 upsert+flush
2262
+ # (issue #34,见 else 分支注释)。
2013
2263
  self.flush()
2014
2264
  result.update(ok=True, node_id=nid)
2015
2265
  else: # merge
@@ -2017,6 +2267,22 @@ class MdCGOS(MdCG):
2017
2267
  tgt = self.get(target) if target else None
2018
2268
  if not tgt:
2019
2269
  return {"ok": False, "error": "merge_target_not_found"}
2270
+ # 写保护(N131,2026-09-25):merge = 对目标节点的一次**覆写**
2271
+ # (_write_node 追加正文),与 accept/edit 的 add 落点同受写保护——
2272
+ # 此前直写绕过双闸,orchestr forbidden「anchor/self/goals/knowledge
2273
+ # 层」被打穿。与 add 同序同错型:principal 层写闸在先(对照
2274
+ # MdCGSecure.add),引擎级 guard_write 在后(对照基类 add :1311);
2275
+ # 闸必须在 _record_decision 之前抛出——审计节点写入本就在其
2276
+ # try/except 内,闸放那里会被吞成 record_error 形同虚设。
2277
+ _p = getattr(self, "principal", None)
2278
+ if _p is not None and hasattr(_p, "require_layer_write"):
2279
+ _e = (self.index.get("nodes") or {}).get(target) or {}
2280
+ _p.require_layer_write(
2281
+ _e.get("layer") or tgt["frontmatter"].get("layer")
2282
+ or tgt["path"].split("/")[0],
2283
+ _e.get("sensitivity") or DEFAULT_SENSITIVITY)
2284
+ protect.guard_write(self, target, override=override,
2285
+ actor=getattr(self, "actor", None))
2020
2286
  fm = dict(tgt["frontmatter"])
2021
2287
  merged_content = (tgt["content"].rstrip() + "\n\n" +
2022
2288
  item["content"].strip() + "\n")
@@ -2028,7 +2294,24 @@ class MdCGOS(MdCG):
2028
2294
  fm["non_applicable_conditions"] = neg
2029
2295
  self._write_node(target, os.path.join(self.root, tgt["path"]),
2030
2296
  fm, merged_content)
2031
- self.rebuild_index()
2297
+ # 定向索引 upsert(issue #34):merge 只改一个目标节点,原
2298
+ # rebuild_index 收尾是 _scan_nodes 全库 O(N) 逐文件扫描
2299
+ # (1500 池实测 ~97-247ms/次),与 accept/edit 的 add+flush
2300
+ # O(1) 增量收尾不对称。改为:单文件条目重算(_node_entry 与
2301
+ # _scan_nodes 同源唯一真源——字段集零漂移)+ 内存索引/增量
2302
+ # 日志定向写入 + flush。不走 _stage:目标已在索引,_stage 会
2303
+ # 虚增 bucket 计数;merge 不改 tags/目录,bucket 不变。
2304
+ # _dirty 置数同时推进 readcache 代际(写盘后同实例不陈旧)。
2305
+ try:
2306
+ tp = os.path.join(self.root, tgt["path"])
2307
+ with open(tp, encoding="utf-8") as f:
2308
+ fm2, c2 = nodefile.loads(f.read())
2309
+ e2 = self._node_entry(tp, tgt["path"].split("/")[0], fm2, c2)
2310
+ self.index["nodes"][target] = e2
2311
+ self._dirty[target] = e2
2312
+ except OSError:
2313
+ pass # 单文件重读失败(极罕见):条目留旧值,下次 rebuild 对账
2314
+ self.flush()
2032
2315
  result.update(ok=True, node_id=target)
2033
2316
 
2034
2317
  # status 映射统一出口:reject→"rejected"、noop→"noop"(同为终态,见
@@ -2074,7 +2357,7 @@ class MdCGOS(MdCG):
2074
2357
  h = _sig(content or "", 16)
2075
2358
  dst = os.path.join(self.trash_dir, f"{node_id}.md")
2076
2359
  try:
2077
- os.replace(src, dst)
2360
+ publish(src, dst)
2078
2361
  except OSError as exc:
2079
2362
  return {"ok": False, "error": str(exc)}
2080
2363
  append_jsonl(self.deletions_log, {"t": time.time(), "id": node_id,
@@ -2098,7 +2381,7 @@ class MdCGOS(MdCG):
2098
2381
  def is_tombstoned(self, node_id: str):
2099
2382
  return any(r.get("id") == node_id for r in read_jsonl(self.deletions_log))
2100
2383
 
2101
- # 生效条件:当 node_id 传入时,若 deletions_log 中存在该 id 且 force=False 返回 tombstoned 拒绝;否则检查 trash_dir/{node_id}.md,os.path.exists 为 False 返回 not_in_trash;可打开则读取并 self.add(override=True...) 恢复、移除 trash 源、audit,返回 ok True/id/forced=bool(force);
2384
+ # 生效条件:当 node_id 传入时,若 deletions_log 中存在该 id 且 force=False 返回 tombstoned 拒绝;否则检查 trash_dir/{node_id}.md,os.path.exists 为 False 返回 not_in_trash;可打开则读取并以 add(override=True) + trash frontmatter 元数据全量透传(显式形参逐项传,其余键经 **extra 回写;lifecycle_state 仅在 active→该态合法迁移时透传)恢复、移除 trash 源、audit,返回 ok True/id/forced=bool(force);
2102
2385
  def restore(self, node_id: str, force: bool = False):
2103
2386
  """恢复:若在删除清单中且未 force → 拒绝(恢复时删除检查)。"""
2104
2387
  tomb = [r for r in read_jsonl(self.deletions_log) if r.get("id") == node_id]
@@ -2111,13 +2394,52 @@ class MdCGOS(MdCG):
2111
2394
  with open(src, encoding="utf-8") as f:
2112
2395
  fm, content = nodefile.loads(f.read())
2113
2396
  layer = fm.get("layer", "knowledge")
2114
- tags = fm.get("tags") or []
2115
- self.add(node_id, content, layer=layer, tags=tags, override=True,
2116
- sensitivity=fm.get("sensitivity"),
2397
+ # 恢复 = 原样放回:add 是**全量重建 fm**,只传个位数字段会把 edges/
2398
+ # depends_on/验证态/生命周期/created_at/语义摘要/证据计数等元数据永久
2399
+ # 抹掉(下方 os.remove 删 trash 源后不可再恢复)。故 add 显式形参对应
2400
+ # 的键逐项透传;其余键(created_at/evidence_*/protected/temporal/
2401
+ # derived_relation…)经 **extra 回写(fm.update 通道覆盖默认值)。
2402
+ # _skip 必须覆盖 add 全部显式形参名——**extra 撞形参名直接 TypeError。
2403
+ _skip = ("id", "layer", "tags", "condition_space", "importance",
2404
+ "confidence", "verification_basis", "edges",
2405
+ "non_applicable_conditions", "derived_from", "relation",
2406
+ "semantic", "depends_on", "valid_from", "valid_until",
2407
+ "effective_from", "effective_until", "believed_at",
2408
+ "verification_state", "override", "consistency",
2409
+ "on_conflict", "state", "sensitivity", "derived_relation",
2410
+ "content", "node_id")
2411
+ _extra = {k: v for k, v in fm.items()
2412
+ if k not in _skip and v not in (None, "", [], {})}
2413
+ # 生命周期状态走迁移裁决:restore 时节点已摘索引、按新节点(active)
2414
+ # 起判,active→archived 跳级会被拒——不可迁移时不透传(落 active),
2415
+ # 不让罕见 corner case 阻断恢复主流程。
2416
+ from . import lifecycle as _lc
2417
+ from . import provenance as _prov
2418
+ _ls = fm.get(_lc.STATE_FIELD)
2419
+ if _ls and _lc.can_transition("active", _ls):
2420
+ _extra[_lc.STATE_FIELD] = _ls
2421
+ # 验证态同样按新节点(unverified)起判:非法值透传会被 require_transition
2422
+ # 直接拒掉,过滤为 None(落 unverified)保证恢复不被脏数据卡死。
2423
+ _vs = fm.get("verification_state")
2424
+ self.add(node_id, content, layer=layer, tags=fm.get("tags") or [],
2425
+ override=True, sensitivity=fm.get("sensitivity"),
2117
2426
  condition_space=fm.get("condition_space"),
2118
2427
  importance=fm.get("importance", 0.5),
2119
2428
  confidence=fm.get("confidence", 0.6),
2120
- verification_basis=fm.get("verification_basis"))
2429
+ verification_basis=fm.get("verification_basis"),
2430
+ edges=fm.get("edges"),
2431
+ non_applicable_conditions=fm.get("non_applicable_conditions"),
2432
+ semantic=fm.get("semantic"),
2433
+ depends_on=fm.get("depends_on"),
2434
+ valid_from=fm.get("valid_from"),
2435
+ valid_until=fm.get("valid_until"),
2436
+ effective_from=fm.get("effective_from"),
2437
+ effective_until=fm.get("effective_until"),
2438
+ believed_at=fm.get("believed_at"),
2439
+ verification_state=(_vs if _vs in trust.STATES else None),
2440
+ derived_from=fm.get("derived_from"),
2441
+ relation=fm.get("derived_relation") or _prov.DEFAULT_RELATION,
2442
+ **_extra)
2121
2443
  os.remove(src)
2122
2444
  self._audit("restore", node_id, forced=bool(force))
2123
2445
  return {"ok": True, "id": node_id, "forced": bool(force)}
@@ -2365,7 +2687,7 @@ class MdCGOS(MdCG):
2365
2687
  # 召回热路径,只在显式调用时工作。
2366
2688
 
2367
2689
  MAINTAIN_ACTIONS = ("stat", "history", "importance", "longterm",
2368
- "prefeed", "separate", "rollback",
2690
+ "prefeed", "separate", "rollback", "reload",
2369
2691
  "backfill", "backfill_rollback", "backfill_history",
2370
2692
  "cap", "cap_rollback", "cap_history",
2371
2693
  "exempt", "exempt_rollback", "exempt_history",
@@ -2410,7 +2732,7 @@ class MdCGOS(MdCG):
2410
2732
  "discard": "已丢弃(不写)", "defer": "留待复核(不写不并)"}.get(dec, "")
2411
2733
  return out
2412
2734
 
2413
- # 生效条件:act 取 str(action or "stat") 去空白并 lower 后按分支分派(importance / longterm / prefeed / separate / stat 等);未识别 act 走 stat 兜底;只读与写层 action 库层不鉴权,apply 类批量改写由 MCP 分发层 require_admin 把守;
2735
+ # 生效条件:act 取 str(action or "stat") 去空白并 lower 后按分支分派(reload / importance / longterm / prefeed / separate / stat 等,reload 只触发 _maybe_reload_index 并返回 {"reloaded": bool});未识别 act 走 stat 兜底;只读与写层 action 库层不鉴权,apply 类批量改写由 MCP 分发层 require_admin 把守;
2414
2736
  def maintain(self, action="stat", layer=None, limit=None, apply=False,
2415
2737
  min_delta=None, max_rows=None, force=False, keep=None,
2416
2738
  mode=None, snapshot_id=None, batch=None, entry_ids=None,
@@ -2421,8 +2743,15 @@ class MdCGOS(MdCG):
2421
2743
 
2422
2744
  只读 action(stat/history/longterm 预演)与写层 action(prefeed)不受
2423
2745
  管理权限约束;apply 类批量改写由 MCP 分发层 `require_admin` 把守。
2746
+
2747
+ reload(P1b-2,DSH 建议 2):显式触发 _maybe_reload_index(签名变化
2748
+ 才真重载),返回 {"reloaded": bool} 供外部探活常驻进程的读面代际。
2424
2749
  """
2425
2750
  act = str(action or "stat").strip().lower()
2751
+ if act == "reload":
2752
+ # 探活/显式刷新:无副作用面(签名未变时是零开销 stat + False)。
2753
+ return {"ok": True, "op": "maintain", "action": "reload",
2754
+ "reloaded": bool(self._maybe_reload_index())}
2426
2755
  if act == "importance":
2427
2756
  return weights.recalc(self, layer=layer, limit=limit, apply=apply,
2428
2757
  min_delta=(weights.APPLY_DELTA if min_delta is None
@@ -3529,10 +3858,10 @@ class MdCGSecure(MdCGOS):
3529
3858
  self._index_sensitivity(nid, sens)
3530
3859
  return nid
3531
3860
 
3532
- # 生效条件:sens 取 sensitivity or DEFAULT_SENSITIVITY,先 principal.require_layer_write(kw.get("layer") or "contextual", sens),再转 super().propose(node_id, content, sensitivity=sens) 并返回其结果。
3861
+ # 生效条件:sens 取 sensitivity or DEFAULT_SENSITIVITY,先 principal.require_layer_write(kw.get("layer", "knowledge"), sens)(缺省与基类 MdCGOS.propose 的 layer="knowledge" 一致——此前误用 or "contextual" 导致校验层与落盘层错位:knowledge-only 身份被误拒 / contextual-only 身份入队成死提案,2026-09-25 缺陷 #5),再转 super().propose(node_id, content, sensitivity=sens) 并返回其结果。
3533
3862
  def propose(self, node_id: str, content: str, sensitivity: str = None, **kw):
3534
3863
  sens = sensitivity or DEFAULT_SENSITIVITY
3535
- self.principal.require_layer_write(kw.get("layer") or "contextual", sens)
3864
+ self.principal.require_layer_write(kw.get("layer", "knowledge"), sens)
3536
3865
  return super().propose(node_id, content, sensitivity=sens, **kw)
3537
3866
 
3538
3867
  # 生效条件:sens 取 sensitivity or DEFAULT_SENSITIVITY,先 _rank(sens) 并 principal.require_layer_write("goals", sens),再转 super().add_goal,最后按 gid 调 _index_sensitivity 并返回 gid。
@@ -3549,6 +3878,16 @@ class MdCGSecure(MdCGOS):
3549
3878
  self.principal.require_layer_write("goals", DEFAULT_SENSITIVITY)
3550
3879
  return super().set_goal_status(node_id, status)
3551
3880
 
3881
+ # 生效条件:verdict(转 str 去空白 lower)为 "falsified" 且索引中存在 node_id 条目时,先取该条目 layer 与 sensitivity(缺省回落 DEFAULT_SENSITIVITY)调 principal.require_layer_write——falsified 删除 = 对原节点层的一次删除写(N130,2026-09-25:verify 角色 layers_allow 本就只含 rejected/contextual,不得改/删被验证内容所在层,tokens.py verify spec forbidden 显式列明),与 add/add_rejected 同一闸口;随后把 override 原样转 super().verify(受保护节点的 guard_forget 快照留痕在基类 falsified 分支内)。verdict 非 falsified 或索引无此节点时不加闸直接透传(not_found 语义由基类维持)。
3882
+ def verify(self, node_id: str, evidence: str, verdict: str,
3883
+ override: bool = False):
3884
+ if str(verdict or "").strip().lower() == "falsified":
3885
+ e = (self.index.get("nodes") or {}).get(node_id)
3886
+ if e is not None:
3887
+ self.principal.require_layer_write(
3888
+ e.get("layer"), e.get("sensitivity") or DEFAULT_SENSITIVITY)
3889
+ return super().verify(node_id, evidence, verdict, override=override)
3890
+
3552
3891
  # 生效条件:sens 取 sensitivity or DEFAULT_SENSITIVITY,经 _rank(sens) 与 principal.require_write(sens) 后把 m 基于 meta 复制并 setdefault tenant/session、harness 与 unit 为真值时补入,再强制 m["sensitivity"]=sens,text 经 _seal_content("_recent", text, sens) 后连 tags=tags 一起转 super().remember_event(window 为 None 时不传该参,否则带上 window)。
3553
3892
  def remember_event(self, role: str, text: str, tags=None, meta=None,
3554
3893
  window=None, sensitivity: str = None):
@@ -3571,15 +3910,57 @@ class MdCGSecure(MdCGOS):
3571
3910
  # ---------- 读:密级过滤 ----------
3572
3911
 
3573
3912
  # 生效条件:e 的 sensitivity 为假值时先 _read 回填为 fm 的 sensitivity or DEFAULT_SENSITIVITY,再以 self.principal.allows(sens) 的布尔结果为准。
3574
- def _readable(self, e) -> bool:
3913
+ def _readable(self, e, session=None) -> bool:
3914
+ """读可见性单点判定:clearance × sensitivity(密级)∧ 会话绑定(归属)。
3915
+
3916
+ 会话绑定(issue #35 设计定稿 2026-09-23,使用者拍板):
3917
+ · public / internal = 跨会话共享档——任何通过 clearance 的会话可见;
3918
+ · private / secret = 会话绑定档——仅归属会话(frontmatter.session)
3919
+ 可见;can_admin(设计者,需整体判断)豁免。
3920
+ · 绑定档无归属(存量节点):fail-closed,仅设计者可见(宁可少读,
3921
+ 不可越权——与下方 sensitivity 回填同款纪律)。
3922
+ · 查询侧 session 参数(含 "*")不参与绑定档判定——身份不可自报;
3923
+ 共享档本就跨会话可见,故该参数在 Secure 层不再改变任何可见性。
3924
+ """
3575
3925
  sens = e.get("sensitivity")
3576
3926
  if not sens:
3577
- # 索引可能被「无密级上下文」的实例重建而丢掉该字段:按盘上真相回填,
3578
- # fail-closed(宁可少读,不可越权)。
3927
+ # 索引可能被「无密级上下文」的实例重建而丢掉该字段:按盘上真相回填。
3928
+ # V21-2(批次 34,外部报告):`_read` 失败(返回 None 头)时**不得**
3929
+ # 回落宽松默认——那会把受保护节点判成 internal 对平级可见
3930
+ # (fail-open)。密级未知 = 按最高档 secret 处理(fail-closed:
3931
+ # 宁可误禁,不可越权)。
3579
3932
  fm, _c = self._read(e)
3580
- sens = (fm or {}).get("sensitivity") or DEFAULT_SENSITIVITY
3933
+ if fm is None:
3934
+ sens = "secret"
3935
+ else:
3936
+ sens = fm.get("sensitivity") or DEFAULT_SENSITIVITY
3581
3937
  e["sensitivity"] = sens
3582
- return self.principal.allows(sens)
3938
+ # restricted = 错误处置标记(批次 28 分型,使用者裁定):不走纯密级
3939
+ # rank——密级底线 internal ∧ 错误处置链路角色(designer/orchestr/
3940
+ # verify,或 can_admin)。平级(record/worker)与下游(output/guest)
3941
+ # 限读,处置链路必读——故意的冲突设计在此消解。
3942
+ if sens == "restricted":
3943
+ from .security import can_read_restricted
3944
+ return (self.principal.allows("internal")
3945
+ and can_read_restricted(self.principal))
3946
+ if not self.principal.allows(sens):
3947
+ return False
3948
+ if _rank(sens) >= _rank("private"):
3949
+ if self.principal.can_admin:
3950
+ return True
3951
+ # 绑定档判定**恒用 principal.session(服务端身份,不可伪造)**——
3952
+ # 查询侧 session 参数(含 "*")不构成豁免:调用方自报的会话不能
3953
+ # 越过身份看别人的 private(与 _attribution「env 固定会话时请求
3954
+ # 声明一律忽略」同款纪律)。
3955
+ nsess = e.get("session")
3956
+ return bool(nsess) and nsess == self.principal.session
3957
+ return True
3958
+
3959
+ # 生效条件:nid 在 index["nodes"] 中有条目且 _readable(e) 为真时返回 True,否则(无条目/不可见)返回 False;作为 chain.adjacency 的读隔离谓词(cg._chain_visible)被消费——关系链面(causal_chain/explain_chain/causal_path/检索 chain 路 provenance 经 expand_from_seeds)由此单点隔离,不可见节点的出边、边条件文本与下游拓扑不再进入链展开;实例身份稳定(_readable 绑定档恒用 principal.session),与 adjacency 缓存无串台。
3960
+ def _chain_visible(self, nid) -> bool:
3961
+ """关系链可见性谓词(chain.adjacency 读隔离闸,2026-09-25)。"""
3962
+ e = (self.index.get("nodes") or {}).get(nid)
3963
+ return bool(e) and self._readable(e)
3583
3964
 
3584
3965
  # 生效条件:先以 limit=None 取 super().list_goals(status=status) 的全量,再只保留 index 中 _readable(e) 为真的目标,limit 为真值时返回 keep[:limit]、否则返回全部 keep。
3585
3966
  def list_goals(self, status=None, limit=None):
@@ -3604,6 +3985,13 @@ class MdCGSecure(MdCGOS):
3604
3985
  continue
3605
3986
  if not self.principal.allows(m.get("sensitivity") or DEFAULT_SENSITIVITY):
3606
3987
  continue
3988
+ # 会话绑定档(private/secret):近期事件同口径——仅归属会话,
3989
+ # 设计者豁免(issue #35 设计定稿,与 _readable 一致)。
3990
+ msens = m.get("sensitivity") or DEFAULT_SENSITIVITY
3991
+ if (_rank(msens) >= _rank("private")
3992
+ and not self.principal.can_admin
3993
+ and m.get("session") != self.principal.session):
3994
+ continue
3607
3995
  if crypto.is_encrypted(r.get("text")):
3608
3996
  t = self._open_content("_recent", m, r["text"])
3609
3997
  if t is None:
@@ -3618,18 +4006,18 @@ class MdCGSecure(MdCGOS):
3618
4006
  self.principal.require_admin("clear_recent")
3619
4007
  return super().clear_recent()
3620
4008
 
3621
- # 生效条件:在 super()._candidates(layer=layer, roles=roles, include_work=include_work, session=session, branch=branch, validity=validity, start_time=start_time, end_time=end_time, start_operator=start_operator, end_operator=end_operator, time_axis=time_axis, view=view) 的结果上,只保留 self._readable(e) 为真的条目(时间算子在父类候选层**单点已过滤**,此处只叠加读可见性、不重复判一次——重复判会让 dropped 计数与 meta 脱钩)。
4009
+ # 生效条件:在 super()._candidates(layer=layer, roles=roles, include_work=include_work, session=None(会话判定上收本层:基类硬等值过滤会误剔共享档的他人节点), branch=branch, ...) 的结果上,只保留 self._readable(e, session=session) 为真的条目(clearance × sensitivity ∧ 会话绑定,见 _readable;时间算子在父类候选层单点已过滤,此处只叠加读可见性)。
3622
4010
  def _candidates(self, layer=None, roles=None, include_work=False,
3623
4011
  session=None, branch=None, validity=None,
3624
4012
  start_time=None, end_time=None, start_operator=None,
3625
4013
  end_operator=None, time_axis=None, view=None):
3626
4014
  out = super()._candidates(layer=layer, roles=roles, include_work=include_work,
3627
- session=session, branch=branch, validity=validity,
4015
+ session=None, branch=branch, validity=validity,
3628
4016
  start_time=start_time, end_time=end_time,
3629
4017
  start_operator=start_operator,
3630
4018
  end_operator=end_operator, time_axis=time_axis,
3631
4019
  view=view)
3632
- return [e for e in out if self._readable(e)]
4020
+ return [e for e in out if self._readable(e, session=session)]
3633
4021
 
3634
4022
  # 生效条件:在 super()._neg_coverage(terms) 的结果上,只保留 self._readable(e) 为真的条目。
3635
4023
  def _neg_coverage(self, terms):
@@ -3667,13 +4055,11 @@ class MdCGSecure(MdCGOS):
3667
4055
  keep = []
3668
4056
  for r in res:
3669
4057
  e = self.index["nodes"].get(r[0]["id"], r[0])
3670
- if not self._readable(e):
3671
- continue
3672
- # 会话归属同为候选资格:图扩展会把**别的会话**的节点顺着边带回来,
3673
- # 绕过 _candidates 的会话过滤 → 自动召回照样串台。故与 validity/view
3674
- # 一并在此兜底("*" = 跨会话视图,不过滤)。
3675
- if session and session != "*" and e.get("session") != session:
4058
+ if not self._readable(e, session=session):
3676
4059
  continue
4060
+ # 会话归属兜底已并入 _readable(session 传参):共享档跨会话可见、
4061
+ # 绑定档(private/secret)按归属会话判定(图扩展绕过 _candidates
4062
+ # 的串台路径由它一并挡住,与 validity/view 同层兜底)。
3677
4063
  if view is not None and not roleviews.matches(e, view):
3678
4064
  continue
3679
4065
  if validity and trust.is_expired(e, now=now):