@furongjun1999/dsh-memory 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +552 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  4. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  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_v14.md +191 -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_v15.md +213 -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_v16.md +214 -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_v17.md +199 -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_v2.md +156 -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_v3.md +152 -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_v4.md +128 -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_v5.md +114 -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_v6.md +192 -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_v7.md +187 -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_v8.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_v9.md +203 -0
  27. 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
  28. 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
  29. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  30. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  31. 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
  32. 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
  33. 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
  34. package/lib/bridge.d.ts +9 -0
  35. package/lib/bridge.js +35 -0
  36. package/lib/index.js +7 -1
  37. package/lib/lib/roleplay_web.js +530 -443
  38. package/lib/lib/token_store.d.ts +7 -1
  39. package/lib/lib/token_store.js +12 -3
  40. package/md_cg/audit.py +12 -1
  41. package/md_cg/backfill.py +16 -15
  42. package/md_cg/backfill_bucket_zh.py +35 -0
  43. package/md_cg/bench_e2e_judge.py +532 -0
  44. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  45. package/md_cg/bench_e2e_qa.py +256 -0
  46. package/md_cg/branches.py +18 -2
  47. package/md_cg/ccgc.py +3 -2
  48. package/md_cg/chain.py +19 -4
  49. package/md_cg/consolidate.py +7 -6
  50. package/md_cg/crosscheck.py +4 -3
  51. package/md_cg/crypto.py +439 -437
  52. package/md_cg/datapath.py +395 -335
  53. package/md_cg/evidence.py +582 -580
  54. package/md_cg/export.py +3 -1
  55. package/md_cg/forgetting.py +2 -2
  56. package/md_cg/fsutil.py +48 -0
  57. package/md_cg/hotcache.py +255 -238
  58. package/md_cg/interop.py +161 -22
  59. package/md_cg/judgment_manifest.py +177 -0
  60. package/md_cg/links.py +655 -622
  61. package/md_cg/mcp_server.py +280 -29
  62. package/md_cg/mdcg.py +616 -125
  63. package/md_cg/mdcos.py +430 -66
  64. package/md_cg/mreview/govern.py +5 -4
  65. package/md_cg/postings.py +4 -2
  66. package/md_cg/readcache.py +76 -18
  67. package/md_cg/reconcile.py +228 -0
  68. package/md_cg/review_cli.py +170 -0
  69. package/md_cg/routing.py +28 -0
  70. package/md_cg/run_tests.py +211 -0
  71. package/md_cg/scrub.py +862 -852
  72. package/md_cg/security.py +385 -275
  73. package/md_cg/selfreport.py +3 -2
  74. package/md_cg/signer.py +565 -562
  75. package/md_cg/sources.py +3 -2
  76. package/md_cg/stg.py +6 -0
  77. package/md_cg/sustain.py +1168 -1138
  78. package/md_cg/test_access_hints.py +147 -0
  79. package/md_cg/test_branch_discard_tombstone.py +136 -0
  80. package/md_cg/test_branches.py +259 -249
  81. package/md_cg/test_chain_read_isolate.py +168 -0
  82. package/md_cg/test_datapath_device_name.py +203 -0
  83. package/md_cg/test_emit_negtail_cache.py +156 -0
  84. package/md_cg/test_en_pipeline.py +186 -166
  85. package/md_cg/test_govern_directread.py +421 -0
  86. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  87. package/md_cg/test_identity_attribution.py +228 -147
  88. package/md_cg/test_index_durability.py +238 -224
  89. package/md_cg/test_interop.py +4 -2
  90. package/md_cg/test_interop_judgment.py +228 -0
  91. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  92. package/md_cg/test_links_concurrent_write.py +188 -0
  93. package/md_cg/test_merge_upsert.py +168 -0
  94. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  95. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  96. package/md_cg/test_n131_merge_gate.py +205 -0
  97. package/md_cg/test_p1x_ref_root.py +160 -0
  98. package/md_cg/test_p27_docindex.py +774 -765
  99. package/md_cg/test_p2_mcp.py +3 -0
  100. package/md_cg/test_p32_backfill.py +304 -298
  101. package/md_cg/test_p39_verify_flow.py +90 -50
  102. package/md_cg/test_p47_session_view.py +60 -25
  103. package/md_cg/test_propose_tail_index.py +157 -0
  104. package/md_cg/test_read_scope_b27.py +277 -0
  105. package/md_cg/test_readcache_default_on.py +168 -0
  106. package/md_cg/test_readcache_precise_inval.py +270 -0
  107. package/md_cg/test_readcache_prodpath.py +55 -7
  108. package/md_cg/test_reconcile_v0.py +294 -0
  109. package/md_cg/test_retr_s1.py +6 -2
  110. package/md_cg/test_retr_s1b.py +67 -0
  111. package/md_cg/test_retr_s7.py +8 -0
  112. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  113. package/md_cg/test_retr_score_once.py +208 -0
  114. package/md_cg/test_review_onepass.py +170 -0
  115. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  116. package/md_cg/test_security_audit.py +155 -0
  117. package/md_cg/test_security_audit_b26.py +161 -0
  118. package/md_cg/test_security_audit_v21.py +250 -0
  119. package/md_cg/test_semantic_canonical.py +255 -241
  120. package/md_cg/test_session_isolation.py +168 -0
  121. package/md_cg/test_snapshot_autoclose.py +187 -0
  122. package/md_cg/test_tail_watermark_race.py +208 -0
  123. package/md_cg/test_tenant_env_override_warn.py +139 -0
  124. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  125. package/md_cg/test_v14_fixes.py +415 -397
  126. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  127. package/md_cg/theory.py +276 -273
  128. package/md_cg/tokens.py +734 -677
  129. package/md_cg/units.py +3 -2
  130. package/md_cg/vision_evidence.py +4 -3
  131. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  132. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  133. package/md_cg/writepipe.py +554 -550
  134. package/package.json +6 -2
  135. package/src/bridge.ts +434 -401
  136. package/src/index.ts +526 -518
  137. package/src/lib/roleplay_web.ts +1019 -932
  138. 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))
@@ -758,6 +795,12 @@ class MdCGOS(MdCG):
758
795
  if context is not None:
759
796
  ctx = context if isinstance(context, dict) else {}
760
797
  route_bucket = routing.bucket_dir(routing.route_key(ctx, ctx.get("tags")))
798
+ # 与 MdCG.search 同一纪律(X2):无域信号时不施加桶约束,否则 route_key
799
+ # 回落的 orphan 会被当成真桶,下面 T0/T1 阶段读到该桶并因 valid>=min_results
800
+ # 直接 return,**全量阶梯永不执行** —— 标了 domain: 的节点整片不可见。
801
+ # 本覆写才是生产路径(MdCGSecure → 本类):缺此条则 MCP 端依旧丢召回。
802
+ if route_bucket == routing.ORPHAN:
803
+ route_bucket = None
761
804
  big_domain = routing.big_domain_classify(terms)
762
805
  big_scores = routing.big_domain_score_breakdown(terms)
763
806
  # S1/S1b/S2 收敛 + S4 审计——与 MdCG.search 共享同一实现(issue #25:
@@ -771,9 +814,15 @@ class MdCGOS(MdCG):
771
814
  stat["gates"] = gates
772
815
  neg_coverage = self._neg_coverage(terms) if include_neg else []
773
816
 
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)
817
+ # 生效条件: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。
818
+ def try_stage(docs, tier, scored=None):
819
+ # P2-4 回移(v7 N51 留档修复,缺陷迭代第 14 轮):支持传入已打分
820
+ # 结果——此前 T2/T3 均为「打分截断后又对 picked 重打」(600 池实测
821
+ # _score 4 调 [0,0,600,500]=1100 文档,500/1100≈45% 冗余)。与基类
822
+ # try_stage(mdcg.py P2-4,批次 31)同模板;_score 在单次 search 内
823
+ # 是纯函数(docs/q/qb/pool_cfg 均不变),透传不改变任何分值与输出。
824
+ if scored is None:
825
+ scored = self._score(docs, q, qb, pool_cfg)
777
826
  valid = sum(1 for _, s in scored if s > 0)
778
827
  if valid >= min_results:
779
828
  return self._emit(scored, k, tier, stat, route_bucket, record,
@@ -781,6 +830,22 @@ class MdCGOS(MdCG):
781
830
  big_domain, big_scores, pool_cfg)
782
831
  return None
783
832
 
833
+ def _prescored(docs, scored_all):
834
+ """P2-4 透传形态:按 node_id 键表把 docs 映射回已打分的
835
+ (card, score) 原对(恰为 _score 对该 docs 的输出,picked 序)。
836
+
837
+ 键口径:scored 元素是 (card, score),card["id"] = fm.get("id")
838
+ or e["path"],恰为 doc_key(doc)[0](V21-6 取 [0] 作键同源)——
839
+ **不可**对 scored 元素调 pooling.doc_key(其形参是 (entry, fm,
840
+ content) 三元组;基类 reach 分支旧模板即此形态错误,MDCG_REACH=1
841
+ 时 KeyError: 1,见 mdcg.py 同修)。**不可**自组 (doc, score) 对
842
+ (_emit 排序键读 x[0]["frontmatter"],x[0] 须是 card dict)。
843
+ docs 必为 scored_all 的子序列(cut_by_relevance 只选不造),
844
+ 键表必命中。
845
+ """
846
+ smap = {c.get("id"): (c, s) for c, s in scored_all}
847
+ return [smap[pooling.doc_key(d)[0]] for d in docs]
848
+
784
849
  if route_bucket:
785
850
  in_bucket = [e for e in entries if e.get("bucket") == route_bucket]
786
851
  if in_bucket:
@@ -819,11 +884,15 @@ class MdCGOS(MdCG):
819
884
  stat["cap"] = GLOBAL_CAP
820
885
  # 与 T2 **无条件**同序调用(不可按 cap 短路:cut_by_relevance 还会写
821
886
  # 池账 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),
887
+ scored_r = self._score(hits_r, q, qb, pool_cfg)
888
+ hits_r, _rep = cut_by_relevance(hits_r, scored_r,
823
889
  GLOBAL_CAP, pools=pool_cfg,
824
890
  key_of=pooling.doc_key, stat=stat)
825
891
  pooling.record_audit(stat, _rep)
826
- out = try_stage(hits_r, TIER_GLOBAL_LIKE)
892
+ # P2-4:打分结果经 scored 透传,不再对同一 hits_r 二次 _score
893
+ # (与基类 reach 分支同模板)。
894
+ out = try_stage(hits_r, TIER_GLOBAL_LIKE,
895
+ scored=_prescored(hits_r, scored_r))
827
896
  if out:
828
897
  return out
829
898
  # 收敛阶段未产出结果 → 继续走下方全量 T2/T3;此时必须**改写 reach 审计**,
@@ -844,22 +913,27 @@ class MdCGOS(MdCG):
844
913
  or (semantic_on() and d[1].get("semantic"))]
845
914
  stat["pre_cap"] = len(hits)
846
915
  stat["cap"] = GLOBAL_CAP
847
- hits, _rep = cut_by_relevance(hits, self._score(hits, q, qb, pool_cfg),
916
+ scored_h = self._score(hits, q, qb, pool_cfg)
917
+ hits, _rep = cut_by_relevance(hits, scored_h,
848
918
  GLOBAL_CAP, pools=pool_cfg,
849
919
  key_of=pooling.doc_key, stat=stat)
850
920
  pooling.record_audit(stat, _rep)
851
- out = try_stage(hits, TIER_GLOBAL_LIKE)
921
+ # P2-4:截断打分结果透传,T2 腿不再对 hits 二次 _score。
922
+ out = try_stage(hits, TIER_GLOBAL_LIKE,
923
+ scored=_prescored(hits, scored_h))
852
924
  if out:
853
925
  return out
854
926
 
855
927
  stat["pre_cap"] = len(docs_all)
856
928
  stat["cap"] = GLOBAL_CAP
857
- picked, _rep = cut_by_relevance(docs_all,
858
- self._score(docs_all, q, qb, pool_cfg),
929
+ scored_a = self._score(docs_all, q, qb, pool_cfg)
930
+ picked, _rep = cut_by_relevance(docs_all, scored_a,
859
931
  GLOBAL_CAP, pools=pool_cfg,
860
932
  key_of=pooling.doc_key, stat=stat)
861
933
  pooling.record_audit(stat, _rep)
862
- scored = self._score(picked, q, qb, pool_cfg)
934
+ # P2-4:T3 不再对 picked 重打(600 池实测此腿冗余 500 文档/查询,
935
+ # GLOBAL_CAP=500 截断后 picked ⊆ docs_all,键表回填即得终榜打分)。
936
+ scored = _prescored(picked, scored_a)
863
937
  return self._emit(scored, k, TIER_GLOBAL_SCAN, stat, route_bucket,
864
938
  record, len(picked), judge,
865
939
  context, neg_coverage, big_domain, big_scores, pool_cfg)
@@ -915,6 +989,16 @@ class MdCGOS(MdCG):
915
989
  return []
916
990
  ctx = context if isinstance(context, dict) else {}
917
991
  b = routing.bucket_dir(routing.route_key(ctx, ctx.get("tags")))
992
+ # 「无域信号」等价于「没声明条件」:条件桶路此时必须**弃权**,不能把
993
+ # route_key 回落的 orphan 当成一个真桶去硬过滤——否则凡是被标了 domain:
994
+ # (因而落在 cond_* 目录)的节点,对这条查询就整片不可见。
995
+ # 实测(带 context 但不含 domain / observation_position):弃权前只扫
996
+ # orphan 桶 10 个节点、目标节点召回丢失(T1_bucket_scan/scanned=10);
997
+ # 弃权后由词法/实体/图路正常召回(T2_global_like/scanned=75)。
998
+ # 与上面 context is None 的既有早返回同构,也贴合 test_p0 的
999
+ # 「桶路由平均 recall@10 ≥ 0.9(加 context 不丢召回)」不变量。
1000
+ if b == routing.ORPHAN:
1001
+ return []
918
1002
  inb = [e for e in entries if e.get("bucket") == b]
919
1003
  if not inb:
920
1004
  return []
@@ -939,7 +1023,7 @@ class MdCGOS(MdCG):
939
1023
  "content": c, "path": e["path"]}, 1.0))
940
1024
  return out
941
1025
 
942
- # 生效条件:当 seeds 非空时,仅取前 5 个种子,从每个种子节点的 frontmatter.edges 取 target(dict 取 target,否则 str(edge)),若 target 在 entries 映射中且不在 seed_ids 中则读取并追加分数 s*0.5;seeds 为空返回 [];depth 默认 1 但本段未使用;
1026
+ # 生效条件:当 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
1027
  def _path_graph(self, query, entries, seeds, depth=1):
944
1028
  """图扩展路径:从词法种子沿 edges 一跳扩展。"""
945
1029
  if not seeds:
@@ -951,10 +1035,24 @@ class MdCGOS(MdCG):
951
1035
  by_id[nid] = e
952
1036
  out = []
953
1037
  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 []):
1038
+ # 种子读改走 self._read(v9:92/v13 留档修复,缺陷迭代第 14 轮):
1039
+ # get 裸 open+nodefile.loads,不在 readcache.install 的包装面
1040
+ #(只包 cg._read)——search_rrf 默认 paths 含 graph,每查询固定
1041
+ # ≤5 次盘读(40 池实测 Q2 增量恰 5,graph 零产出也在边循环前
1042
+ # 发生);边目标(下方 self._read)早已同形。种子只要 frontmatter
1043
+ # 的 edges(content 不解封不用),种子来自 lexical(entries/
1044
+ # _candidates 派生,MdCGSecure._candidates 已过 _readable)——
1045
+ # 绕过 get 的读隔离门无损失,图路扩展产物本由
1046
+ # MdCGSecure.search_rrf 终态二次过滤兜底。by_id 不含(fm.id≠
1047
+ # 文件名等形态)回落 get 保旧口径。
1048
+ e_seed = by_id.get(n["id"])
1049
+ if e_seed is not None:
1050
+ fm_s, _c = self._read(e_seed)
1051
+ edges = (fm_s or {}).get("edges") or []
1052
+ else:
1053
+ node = self.get(n["id"])
1054
+ edges = ((node or {}).get("frontmatter") or {}).get("edges") or []
1055
+ for edge in edges:
958
1056
  tid = edge.get("target") if isinstance(edge, dict) else str(edge)
959
1057
  if tid in by_id and tid not in seed_ids:
960
1058
  e = by_id[tid]
@@ -1228,6 +1326,9 @@ class MdCGOS(MdCG):
1228
1326
  "judge": bool(judge), "judge_ranking": bool(judge_ranking),
1229
1327
  "goal_text": goal_text, "context": context,
1230
1328
  "early_stop_threshold": early_stop_threshold,
1329
+ # 进程级开关也改口径(v14 只补了参数面):MDCG_SEMANTIC /
1330
+ # MDCG_EN_ATOMS / MDCG_UNIFY_QUERY 等不入键即跨口径命中(2026-09-24 修复)。
1331
+ "env_switch": _hc.env_switch_key(),
1231
1332
  }
1232
1333
  # 不可稳定进键的参数(自定义可调用 query_expand):非默认即**绕行**
1233
1334
  # 缓存(读+写双侧闭合)——fail-closed,宁可不用缓存也不串味。
@@ -1631,17 +1732,18 @@ class MdCGOS(MdCG):
1631
1732
  """
1632
1733
  phash = _sig(content)
1633
1734
  with FileLock(self.inbox_log, strict=True):
1735
+ # 增量对账(issue #32):原实现每条全量 read_jsonl(inbox) +
1736
+ # read_jsonl(decisions)(锁内 O(M+D)/条、批量 O(M²),旧格式行
1737
+ # 逐条重算 sha1);改走 stat 尾部增量索引——文件序与覆盖语义
1738
+ # 与原全量扫描逐位一致(同 phash 按文件序取,已裁决优先 break)。
1739
+ ph_idx = self._inbox_phash_index()
1634
1740
  st = self._pid_status()
1635
1741
  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"),
1742
+ for pid, nid in ph_idx.get(phash, ()):
1743
+ s = st.get(pid) or {}
1744
+ dup = {"pid": pid,
1643
1745
  "status": s.get("status") or "pending",
1644
- "node_id": r.get("id")}
1746
+ "node_id": nid}
1645
1747
  if dup["status"] in ("accepted", "rejected"):
1646
1748
  break # 已裁决的最有信息量,优先返回
1647
1749
  if dup:
@@ -1675,14 +1777,135 @@ class MdCGOS(MdCG):
1675
1777
  "dup_of": None, "dup_status": None}
1676
1778
  return pid
1677
1779
 
1678
- # 生效条件:当 self.decisions_log 可被 read_jsonl 读取时,仅 r.get("pid") 为真值的记录写入 st[r["pid"]],后出现记录覆盖先出现记录,返回 pid → 最新一条裁决记录的字典;
1780
+ # 生效条件: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
1781
  def _pid_status(self):
1680
- """pid → 最新一条裁决记录(多轮再审批时取最后一轮)。"""
1681
- st = {}
1682
- for r in read_jsonl(self.decisions_log):
1782
+ """pid → 最新一条裁决记录(多轮再审批时取最后一轮)。
1783
+
1784
+ 增量缓存(issue #32):原实现每调用全量 read_jsonl(decisions_log)
1785
+ (json.loads 逐条),propose 对账每条 O(D)、批量 O(M×D)。append-only
1786
+ 契约下改为 stat 尾部对账:size/mtime 未变零解析;size 增长只解析
1787
+ 新增字节;size 回退/首装全量重建兜底。mtime 单变 size 不变(touch
1788
+ 类)走增量窗口=0 行,结果不变。跨进程 append 由 stat 自动并入。
1789
+ 水位口径(竞态修复):存 read_jsonl_tail 返回的**真实 EOF 偏移**,
1790
+ 不存读前 stat 的 size——stat→read 窗口内的并发 append 本轮已并入,
1791
+ 水位偏小会让下一轮重读同一段(本缓存是 dict 后写覆盖不受损,但与
1792
+ 列表型缓存共用同一口径)。
1793
+ 边界(与检索读缓存同款诚实声明):外部**非 append 形态**重写文件
1794
+ (同 size 改内容)不在合作写者协议内,不保证可见。
1795
+ """
1796
+ try:
1797
+ sti = os.stat(self.decisions_log)
1798
+ size, mtime = sti.st_size, sti.st_mtime_ns
1799
+ except OSError:
1800
+ self._pid_st, self._pid_st_size, self._pid_st_mtime = {}, -1, -1
1801
+ return self._pid_st
1802
+ if size == self._pid_st_size and mtime == self._pid_st_mtime:
1803
+ return self._pid_st # 无新内容(零解析)
1804
+ full = self._pid_st_size < 0 or size < self._pid_st_size
1805
+ if full:
1806
+ self._pid_st = {}
1807
+ src, end = read_jsonl_tail(self.decisions_log,
1808
+ 0 if full else self._pid_st_size)
1809
+ for r in src:
1683
1810
  if r.get("pid"):
1684
- st[r["pid"]] = r
1685
- return st
1811
+ self._pid_st[r["pid"]] = r
1812
+ self._pid_st_size = size if end is None else end
1813
+ self._pid_st_mtime = mtime
1814
+ return self._pid_st
1815
+
1816
+ # 生效条件: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。
1817
+ def _inbox_phash_index(self):
1818
+ """inbox 的 phash → [(pid, node_id)](文件序)增量索引(propose 对账用)。
1819
+
1820
+ 与 _pid_status 同款 stat 尾部对账(issue #32)。存量旧格式行
1821
+ (无 payload_hash)在**并入时**现算一次 _sig(此后常驻,不再
1822
+ 每条 propose 重算——旧路径每条重算 M 次 sha1)。
1823
+ 水位口径(竞态修复,同 _pid_status):存真实 EOF 偏移,不存读前
1824
+ stat 的 size——窗口内并发 append 不得在下轮重复并入。
1825
+ """
1826
+ try:
1827
+ sti = os.stat(self.inbox_log)
1828
+ size, mtime = sti.st_size, sti.st_mtime_ns
1829
+ except OSError:
1830
+ self._inbox_idx, self._inbox_idx_size, self._inbox_idx_mtime = \
1831
+ {}, -1, -1
1832
+ return self._inbox_idx
1833
+ if size == self._inbox_idx_size and mtime == self._inbox_idx_mtime:
1834
+ return self._inbox_idx # 无新内容(零解析)
1835
+ full = self._inbox_idx_size < 0 or size < self._inbox_idx_size
1836
+ if full:
1837
+ self._inbox_idx = {}
1838
+ src, end = read_jsonl_tail(self.inbox_log,
1839
+ 0 if full else self._inbox_idx_size)
1840
+ for r in src:
1841
+ ph = r.get("payload_hash") or _sig(r.get("content") or "")
1842
+ self._inbox_idx.setdefault(ph, []).append(
1843
+ (r.get("pid"), r.get("id")))
1844
+ self._inbox_idx_size = size if end is None else end
1845
+ self._inbox_idx_mtime = mtime
1846
+ return self._inbox_idx
1847
+
1848
+ # 生效条件: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(内部缓存,调用方只读不得变更)。
1849
+ def _inbox_records(self):
1850
+ """inbox 全记录(文件序)增量缓存——review 治理面单次调用共用一次解析。
1851
+
1852
+ 与 _pid_status 同款 stat 尾部对账(issue #35):原 review_decide 线性
1853
+ 扫 inbox 找 pid、_cascade_dedup 再全量读一遍(2 遍/调用);改走本
1854
+ 缓存后稳态零解析,调用方在内存列表上线性扫(万条 ~1ms 级,无 IO)。
1855
+ 水位口径(竞态修复):存 read_jsonl_tail 返回的**真实 EOF 偏移**,
1856
+ 不存读前 stat 的 size——stat→read 窗口内的并发 append 本轮已并入,
1857
+ 水位偏小会让下一轮 extend 重读同一条(review_list 重复 pid、
1858
+ _cascade_dedup 连写两条 reject),且稳态 size 只增、永不自愈。
1859
+ """
1860
+ try:
1861
+ sti = os.stat(self.inbox_log)
1862
+ size, mtime = sti.st_size, sti.st_mtime_ns
1863
+ except OSError:
1864
+ self._inbox_recs, self._inbox_recs_size, self._inbox_recs_mtime = \
1865
+ [], -1, -1
1866
+ return self._inbox_recs
1867
+ if size == self._inbox_recs_size and mtime == self._inbox_recs_mtime:
1868
+ return self._inbox_recs # 无新内容(零解析)
1869
+ full = self._inbox_recs_size < 0 or size < self._inbox_recs_size
1870
+ if full:
1871
+ self._inbox_recs = []
1872
+ src, end = read_jsonl_tail(self.inbox_log,
1873
+ 0 if full else self._inbox_recs_size)
1874
+ self._inbox_recs.extend(src)
1875
+ self._inbox_recs_size = size if end is None else end
1876
+ self._inbox_recs_mtime = mtime
1877
+ return self._inbox_recs
1878
+
1879
+ # 生效条件: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(内部缓存,调用方只读不得变更)。
1880
+ def _decisions_records(self):
1881
+ """decisions 全记录(文件序)增量缓存——review 治理面单次调用共用一次解析。
1882
+
1883
+ issue #35:原 review_stats 全量读 decisions 统计、verify_review_record
1884
+ 全量线性扫找单条 pid(每调用 1 遍整文件 json.loads);改走本缓存后
1885
+ 稳态零解析,调用方内存线性扫。与 _pid_status(pid→最新)互补:
1886
+ 本缓存保全量记录(多轮裁决历史、by_decision 统计口径)。
1887
+ 水位口径(竞态修复,同 _inbox_records):存真实 EOF 偏移——窗口内
1888
+ 并发 append 不得在下一轮重复 extend(review_stats 计数虚高/翻倍、
1889
+ 永不自愈)。
1890
+ """
1891
+ try:
1892
+ sti = os.stat(self.decisions_log)
1893
+ size, mtime = sti.st_size, sti.st_mtime_ns
1894
+ except OSError:
1895
+ self._dec_recs, self._dec_recs_size, self._dec_recs_mtime = \
1896
+ [], -1, -1
1897
+ return self._dec_recs
1898
+ if size == self._dec_recs_size and mtime == self._dec_recs_mtime:
1899
+ return self._dec_recs # 无新内容(零解析)
1900
+ full = self._dec_recs_size < 0 or size < self._dec_recs_size
1901
+ if full:
1902
+ self._dec_recs = []
1903
+ src, end = read_jsonl_tail(self.decisions_log,
1904
+ 0 if full else self._dec_recs_size)
1905
+ self._dec_recs.extend(src)
1906
+ self._dec_recs_size = size if end is None else end
1907
+ self._dec_recs_mtime = mtime
1908
+ return self._dec_recs
1686
1909
 
1687
1910
  # 生效条件:实例已构建(内部先读 _pid_status());返回其 status ∈ TERMINAL_DECISION_STATUS(accepted/rejected/noop)的 pid 集合,status=="needs_reapproval" 视为未关闭、不计入;
1688
1911
  def _closed_pids(self):
@@ -1695,7 +1918,9 @@ class MdCGOS(MdCG):
1695
1918
  """待审核候选(含被红队打回、待再审批的条目)。"""
1696
1919
  st = self._pid_status()
1697
1920
  out = []
1698
- for r in read_jsonl(self.inbox_log):
1921
+ # issue #35:走全记录增量缓存(稳态零解析)——review_stats 链路单次
1922
+ # 调用共用一次解析;rec=dict(r) 复制,不改内部缓存。
1923
+ for r in self._inbox_records():
1699
1924
  s = st.get(r.get("pid")) or {}
1700
1925
  if s.get("status") in TERMINAL_DECISION_STATUS:
1701
1926
  continue
@@ -1716,7 +1941,9 @@ class MdCGOS(MdCG):
1716
1941
  视图复用 review_list/_closed_pids,避免在此复制第二份终态判定逻辑。
1717
1942
  """
1718
1943
  by = {}
1719
- for r in read_jsonl(self.decisions_log):
1944
+ # issue #35:走全记录增量缓存(稳态零解析)——原每次全量逐行
1945
+ # json.loads,与 review_list/_closed_pids 链路重复解析同一文件。
1946
+ for r in self._decisions_records():
1720
1947
  d = str(r.get("decision") or "").strip() or "unknown"
1721
1948
  by[d] = by.get(d, 0) + 1
1722
1949
  return {"records": sum(by.values()), "by_decision": by,
@@ -1845,7 +2072,9 @@ class MdCGOS(MdCG):
1845
2072
  closed = []
1846
2073
  with FileLock(self.decisions_log, strict=True):
1847
2074
  st = self._pid_status()
1848
- for r in read_jsonl(self.inbox_log):
2075
+ # issue #35:走全记录增量缓存(稳态零解析)——原与 review_decide
2076
+ # 开头的找 pid 扫描重复全量读同一 inbox(2 遍/调用)。
2077
+ for r in self._inbox_records():
1849
2078
  bpid = r.get("pid")
1850
2079
  if not bpid or bpid == pid or bpid in st:
1851
2080
  continue # 自己 / 已有裁决记录(含 needs_reapproval)跳过
@@ -1896,7 +2125,9 @@ class MdCGOS(MdCG):
1896
2125
  return {"ok": False, "error": "node_not_found"}
1897
2126
  fm = node.get("frontmatter") or {}
1898
2127
  pid, rnd = fm.get("pid"), fm.get("round")
1899
- src = next((r for r in read_jsonl(self.decisions_log)
2128
+ # issue #35:走全记录增量缓存后内存线性扫(稳态零解析)——原每次
2129
+ # 全量逐行 json.loads 整份 decisions 只为找一条 pid+round。
2130
+ src = next((r for r in self._decisions_records()
1900
2131
  if r.get("pid") == pid
1901
2132
  and int(r.get("round") or 0) == int(rnd or 0)), None)
1902
2133
  if not src:
@@ -1911,10 +2142,11 @@ class MdCGOS(MdCG):
1911
2142
  "verify_hash": src.get("verify_hash"),
1912
2143
  "source": "hippocampus/decisions.jsonl"}
1913
2144
 
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。
2145
+ # 生效条件: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
2146
  def review_decide(self, pid: str, decision: str, edits: dict = None,
1916
2147
  merge_into: str = None, reason: str = "",
1917
- redteam: dict = None, issues=None):
2148
+ redteam: dict = None, issues=None,
2149
+ override: bool = False):
1918
2150
  """审核裁决:accept / reject / edit / merge / noop。
1919
2151
 
1920
2152
  accept → 按 inbox 原样写入
@@ -1936,7 +2168,9 @@ class MdCGOS(MdCG):
1936
2168
  """
1937
2169
  if decision not in DECISION_ACTIONS:
1938
2170
  raise ValueError(f"未知裁决:{decision}")
1939
- item = next((r for r in read_jsonl(self.inbox_log)
2171
+ # issue #35:走全记录增量缓存后内存线性扫(稳态零解析)——原每次
2172
+ # 全量逐行 json.loads 整份 inbox 只为找一条 pid。
2173
+ item = next((r for r in self._inbox_records()
1940
2174
  if r.get("pid") == pid), None)
1941
2175
  if not item:
1942
2176
  return {"ok": False, "error": "pid_not_found"}
@@ -2009,7 +2243,8 @@ class MdCGOS(MdCG):
2009
2243
  # 未达 autoflush(64) 阈值时进程退出即永久丢失——裁决进程(review_cli /
2010
2244
  # MCP op=review)通常只写 1~2 条,不 flush 则「节点在盘上但索引无条目」,
2011
2245
  # 其他进程与重载后的长驻进程都检索不到,只能靠某次全量 rebuild 偶然救回。
2012
- # merge 分支绕开 add 直写节点文件,故其收尾同为索引重建(同因不同法)。
2246
+ # merge 分支同样直写节点文件,其收尾已改为同款定向 upsert+flush
2247
+ # (issue #34,见 else 分支注释)。
2013
2248
  self.flush()
2014
2249
  result.update(ok=True, node_id=nid)
2015
2250
  else: # merge
@@ -2017,6 +2252,22 @@ class MdCGOS(MdCG):
2017
2252
  tgt = self.get(target) if target else None
2018
2253
  if not tgt:
2019
2254
  return {"ok": False, "error": "merge_target_not_found"}
2255
+ # 写保护(N131,2026-09-25):merge = 对目标节点的一次**覆写**
2256
+ # (_write_node 追加正文),与 accept/edit 的 add 落点同受写保护——
2257
+ # 此前直写绕过双闸,orchestr forbidden「anchor/self/goals/knowledge
2258
+ # 层」被打穿。与 add 同序同错型:principal 层写闸在先(对照
2259
+ # MdCGSecure.add),引擎级 guard_write 在后(对照基类 add :1311);
2260
+ # 闸必须在 _record_decision 之前抛出——审计节点写入本就在其
2261
+ # try/except 内,闸放那里会被吞成 record_error 形同虚设。
2262
+ _p = getattr(self, "principal", None)
2263
+ if _p is not None and hasattr(_p, "require_layer_write"):
2264
+ _e = (self.index.get("nodes") or {}).get(target) or {}
2265
+ _p.require_layer_write(
2266
+ _e.get("layer") or tgt["frontmatter"].get("layer")
2267
+ or tgt["path"].split("/")[0],
2268
+ _e.get("sensitivity") or DEFAULT_SENSITIVITY)
2269
+ protect.guard_write(self, target, override=override,
2270
+ actor=getattr(self, "actor", None))
2020
2271
  fm = dict(tgt["frontmatter"])
2021
2272
  merged_content = (tgt["content"].rstrip() + "\n\n" +
2022
2273
  item["content"].strip() + "\n")
@@ -2028,7 +2279,24 @@ class MdCGOS(MdCG):
2028
2279
  fm["non_applicable_conditions"] = neg
2029
2280
  self._write_node(target, os.path.join(self.root, tgt["path"]),
2030
2281
  fm, merged_content)
2031
- self.rebuild_index()
2282
+ # 定向索引 upsert(issue #34):merge 只改一个目标节点,原
2283
+ # rebuild_index 收尾是 _scan_nodes 全库 O(N) 逐文件扫描
2284
+ # (1500 池实测 ~97-247ms/次),与 accept/edit 的 add+flush
2285
+ # O(1) 增量收尾不对称。改为:单文件条目重算(_node_entry 与
2286
+ # _scan_nodes 同源唯一真源——字段集零漂移)+ 内存索引/增量
2287
+ # 日志定向写入 + flush。不走 _stage:目标已在索引,_stage 会
2288
+ # 虚增 bucket 计数;merge 不改 tags/目录,bucket 不变。
2289
+ # _dirty 置数同时推进 readcache 代际(写盘后同实例不陈旧)。
2290
+ try:
2291
+ tp = os.path.join(self.root, tgt["path"])
2292
+ with open(tp, encoding="utf-8") as f:
2293
+ fm2, c2 = nodefile.loads(f.read())
2294
+ e2 = self._node_entry(tp, tgt["path"].split("/")[0], fm2, c2)
2295
+ self.index["nodes"][target] = e2
2296
+ self._dirty[target] = e2
2297
+ except OSError:
2298
+ pass # 单文件重读失败(极罕见):条目留旧值,下次 rebuild 对账
2299
+ self.flush()
2032
2300
  result.update(ok=True, node_id=target)
2033
2301
 
2034
2302
  # status 映射统一出口:reject→"rejected"、noop→"noop"(同为终态,见
@@ -2074,7 +2342,7 @@ class MdCGOS(MdCG):
2074
2342
  h = _sig(content or "", 16)
2075
2343
  dst = os.path.join(self.trash_dir, f"{node_id}.md")
2076
2344
  try:
2077
- os.replace(src, dst)
2345
+ publish(src, dst)
2078
2346
  except OSError as exc:
2079
2347
  return {"ok": False, "error": str(exc)}
2080
2348
  append_jsonl(self.deletions_log, {"t": time.time(), "id": node_id,
@@ -2098,7 +2366,7 @@ class MdCGOS(MdCG):
2098
2366
  def is_tombstoned(self, node_id: str):
2099
2367
  return any(r.get("id") == node_id for r in read_jsonl(self.deletions_log))
2100
2368
 
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);
2369
+ # 生效条件:当 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
2370
  def restore(self, node_id: str, force: bool = False):
2103
2371
  """恢复:若在删除清单中且未 force → 拒绝(恢复时删除检查)。"""
2104
2372
  tomb = [r for r in read_jsonl(self.deletions_log) if r.get("id") == node_id]
@@ -2111,13 +2379,52 @@ class MdCGOS(MdCG):
2111
2379
  with open(src, encoding="utf-8") as f:
2112
2380
  fm, content = nodefile.loads(f.read())
2113
2381
  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"),
2382
+ # 恢复 = 原样放回:add 是**全量重建 fm**,只传个位数字段会把 edges/
2383
+ # depends_on/验证态/生命周期/created_at/语义摘要/证据计数等元数据永久
2384
+ # 抹掉(下方 os.remove 删 trash 源后不可再恢复)。故 add 显式形参对应
2385
+ # 的键逐项透传;其余键(created_at/evidence_*/protected/temporal/
2386
+ # derived_relation…)经 **extra 回写(fm.update 通道覆盖默认值)。
2387
+ # _skip 必须覆盖 add 全部显式形参名——**extra 撞形参名直接 TypeError。
2388
+ _skip = ("id", "layer", "tags", "condition_space", "importance",
2389
+ "confidence", "verification_basis", "edges",
2390
+ "non_applicable_conditions", "derived_from", "relation",
2391
+ "semantic", "depends_on", "valid_from", "valid_until",
2392
+ "effective_from", "effective_until", "believed_at",
2393
+ "verification_state", "override", "consistency",
2394
+ "on_conflict", "state", "sensitivity", "derived_relation",
2395
+ "content", "node_id")
2396
+ _extra = {k: v for k, v in fm.items()
2397
+ if k not in _skip and v not in (None, "", [], {})}
2398
+ # 生命周期状态走迁移裁决:restore 时节点已摘索引、按新节点(active)
2399
+ # 起判,active→archived 跳级会被拒——不可迁移时不透传(落 active),
2400
+ # 不让罕见 corner case 阻断恢复主流程。
2401
+ from . import lifecycle as _lc
2402
+ from . import provenance as _prov
2403
+ _ls = fm.get(_lc.STATE_FIELD)
2404
+ if _ls and _lc.can_transition("active", _ls):
2405
+ _extra[_lc.STATE_FIELD] = _ls
2406
+ # 验证态同样按新节点(unverified)起判:非法值透传会被 require_transition
2407
+ # 直接拒掉,过滤为 None(落 unverified)保证恢复不被脏数据卡死。
2408
+ _vs = fm.get("verification_state")
2409
+ self.add(node_id, content, layer=layer, tags=fm.get("tags") or [],
2410
+ override=True, sensitivity=fm.get("sensitivity"),
2117
2411
  condition_space=fm.get("condition_space"),
2118
2412
  importance=fm.get("importance", 0.5),
2119
2413
  confidence=fm.get("confidence", 0.6),
2120
- verification_basis=fm.get("verification_basis"))
2414
+ verification_basis=fm.get("verification_basis"),
2415
+ edges=fm.get("edges"),
2416
+ non_applicable_conditions=fm.get("non_applicable_conditions"),
2417
+ semantic=fm.get("semantic"),
2418
+ depends_on=fm.get("depends_on"),
2419
+ valid_from=fm.get("valid_from"),
2420
+ valid_until=fm.get("valid_until"),
2421
+ effective_from=fm.get("effective_from"),
2422
+ effective_until=fm.get("effective_until"),
2423
+ believed_at=fm.get("believed_at"),
2424
+ verification_state=(_vs if _vs in trust.STATES else None),
2425
+ derived_from=fm.get("derived_from"),
2426
+ relation=fm.get("derived_relation") or _prov.DEFAULT_RELATION,
2427
+ **_extra)
2121
2428
  os.remove(src)
2122
2429
  self._audit("restore", node_id, forced=bool(force))
2123
2430
  return {"ok": True, "id": node_id, "forced": bool(force)}
@@ -3529,10 +3836,10 @@ class MdCGSecure(MdCGOS):
3529
3836
  self._index_sensitivity(nid, sens)
3530
3837
  return nid
3531
3838
 
3532
- # 生效条件:sens 取 sensitivity or DEFAULT_SENSITIVITY,先 principal.require_layer_write(kw.get("layer") or "contextual", sens),再转 super().propose(node_id, content, sensitivity=sens) 并返回其结果。
3839
+ # 生效条件: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
3840
  def propose(self, node_id: str, content: str, sensitivity: str = None, **kw):
3534
3841
  sens = sensitivity or DEFAULT_SENSITIVITY
3535
- self.principal.require_layer_write(kw.get("layer") or "contextual", sens)
3842
+ self.principal.require_layer_write(kw.get("layer", "knowledge"), sens)
3536
3843
  return super().propose(node_id, content, sensitivity=sens, **kw)
3537
3844
 
3538
3845
  # 生效条件:sens 取 sensitivity or DEFAULT_SENSITIVITY,先 _rank(sens) 并 principal.require_layer_write("goals", sens),再转 super().add_goal,最后按 gid 调 _index_sensitivity 并返回 gid。
@@ -3549,6 +3856,16 @@ class MdCGSecure(MdCGOS):
3549
3856
  self.principal.require_layer_write("goals", DEFAULT_SENSITIVITY)
3550
3857
  return super().set_goal_status(node_id, status)
3551
3858
 
3859
+ # 生效条件: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 语义由基类维持)。
3860
+ def verify(self, node_id: str, evidence: str, verdict: str,
3861
+ override: bool = False):
3862
+ if str(verdict or "").strip().lower() == "falsified":
3863
+ e = (self.index.get("nodes") or {}).get(node_id)
3864
+ if e is not None:
3865
+ self.principal.require_layer_write(
3866
+ e.get("layer"), e.get("sensitivity") or DEFAULT_SENSITIVITY)
3867
+ return super().verify(node_id, evidence, verdict, override=override)
3868
+
3552
3869
  # 生效条件: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
3870
  def remember_event(self, role: str, text: str, tags=None, meta=None,
3554
3871
  window=None, sensitivity: str = None):
@@ -3571,15 +3888,57 @@ class MdCGSecure(MdCGOS):
3571
3888
  # ---------- 读:密级过滤 ----------
3572
3889
 
3573
3890
  # 生效条件:e 的 sensitivity 为假值时先 _read 回填为 fm 的 sensitivity or DEFAULT_SENSITIVITY,再以 self.principal.allows(sens) 的布尔结果为准。
3574
- def _readable(self, e) -> bool:
3891
+ def _readable(self, e, session=None) -> bool:
3892
+ """读可见性单点判定:clearance × sensitivity(密级)∧ 会话绑定(归属)。
3893
+
3894
+ 会话绑定(issue #35 设计定稿 2026-09-23,使用者拍板):
3895
+ · public / internal = 跨会话共享档——任何通过 clearance 的会话可见;
3896
+ · private / secret = 会话绑定档——仅归属会话(frontmatter.session)
3897
+ 可见;can_admin(设计者,需整体判断)豁免。
3898
+ · 绑定档无归属(存量节点):fail-closed,仅设计者可见(宁可少读,
3899
+ 不可越权——与下方 sensitivity 回填同款纪律)。
3900
+ · 查询侧 session 参数(含 "*")不参与绑定档判定——身份不可自报;
3901
+ 共享档本就跨会话可见,故该参数在 Secure 层不再改变任何可见性。
3902
+ """
3575
3903
  sens = e.get("sensitivity")
3576
3904
  if not sens:
3577
- # 索引可能被「无密级上下文」的实例重建而丢掉该字段:按盘上真相回填,
3578
- # fail-closed(宁可少读,不可越权)。
3905
+ # 索引可能被「无密级上下文」的实例重建而丢掉该字段:按盘上真相回填。
3906
+ # V21-2(批次 34,外部报告):`_read` 失败(返回 None 头)时**不得**
3907
+ # 回落宽松默认——那会把受保护节点判成 internal 对平级可见
3908
+ # (fail-open)。密级未知 = 按最高档 secret 处理(fail-closed:
3909
+ # 宁可误禁,不可越权)。
3579
3910
  fm, _c = self._read(e)
3580
- sens = (fm or {}).get("sensitivity") or DEFAULT_SENSITIVITY
3911
+ if fm is None:
3912
+ sens = "secret"
3913
+ else:
3914
+ sens = fm.get("sensitivity") or DEFAULT_SENSITIVITY
3581
3915
  e["sensitivity"] = sens
3582
- return self.principal.allows(sens)
3916
+ # restricted = 错误处置标记(批次 28 分型,使用者裁定):不走纯密级
3917
+ # rank——密级底线 internal ∧ 错误处置链路角色(designer/orchestr/
3918
+ # verify,或 can_admin)。平级(record/worker)与下游(output/guest)
3919
+ # 限读,处置链路必读——故意的冲突设计在此消解。
3920
+ if sens == "restricted":
3921
+ from .security import can_read_restricted
3922
+ return (self.principal.allows("internal")
3923
+ and can_read_restricted(self.principal))
3924
+ if not self.principal.allows(sens):
3925
+ return False
3926
+ if _rank(sens) >= _rank("private"):
3927
+ if self.principal.can_admin:
3928
+ return True
3929
+ # 绑定档判定**恒用 principal.session(服务端身份,不可伪造)**——
3930
+ # 查询侧 session 参数(含 "*")不构成豁免:调用方自报的会话不能
3931
+ # 越过身份看别人的 private(与 _attribution「env 固定会话时请求
3932
+ # 声明一律忽略」同款纪律)。
3933
+ nsess = e.get("session")
3934
+ return bool(nsess) and nsess == self.principal.session
3935
+ return True
3936
+
3937
+ # 生效条件: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 缓存无串台。
3938
+ def _chain_visible(self, nid) -> bool:
3939
+ """关系链可见性谓词(chain.adjacency 读隔离闸,2026-09-25)。"""
3940
+ e = (self.index.get("nodes") or {}).get(nid)
3941
+ return bool(e) and self._readable(e)
3583
3942
 
3584
3943
  # 生效条件:先以 limit=None 取 super().list_goals(status=status) 的全量,再只保留 index 中 _readable(e) 为真的目标,limit 为真值时返回 keep[:limit]、否则返回全部 keep。
3585
3944
  def list_goals(self, status=None, limit=None):
@@ -3604,6 +3963,13 @@ class MdCGSecure(MdCGOS):
3604
3963
  continue
3605
3964
  if not self.principal.allows(m.get("sensitivity") or DEFAULT_SENSITIVITY):
3606
3965
  continue
3966
+ # 会话绑定档(private/secret):近期事件同口径——仅归属会话,
3967
+ # 设计者豁免(issue #35 设计定稿,与 _readable 一致)。
3968
+ msens = m.get("sensitivity") or DEFAULT_SENSITIVITY
3969
+ if (_rank(msens) >= _rank("private")
3970
+ and not self.principal.can_admin
3971
+ and m.get("session") != self.principal.session):
3972
+ continue
3607
3973
  if crypto.is_encrypted(r.get("text")):
3608
3974
  t = self._open_content("_recent", m, r["text"])
3609
3975
  if t is None:
@@ -3618,18 +3984,18 @@ class MdCGSecure(MdCGOS):
3618
3984
  self.principal.require_admin("clear_recent")
3619
3985
  return super().clear_recent()
3620
3986
 
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 脱钩)。
3987
+ # 生效条件:在 super()._candidates(layer=layer, roles=roles, include_work=include_work, session=None(会话判定上收本层:基类硬等值过滤会误剔共享档的他人节点), branch=branch, ...) 的结果上,只保留 self._readable(e, session=session) 为真的条目(clearance × sensitivity ∧ 会话绑定,见 _readable;时间算子在父类候选层单点已过滤,此处只叠加读可见性)。
3622
3988
  def _candidates(self, layer=None, roles=None, include_work=False,
3623
3989
  session=None, branch=None, validity=None,
3624
3990
  start_time=None, end_time=None, start_operator=None,
3625
3991
  end_operator=None, time_axis=None, view=None):
3626
3992
  out = super()._candidates(layer=layer, roles=roles, include_work=include_work,
3627
- session=session, branch=branch, validity=validity,
3993
+ session=None, branch=branch, validity=validity,
3628
3994
  start_time=start_time, end_time=end_time,
3629
3995
  start_operator=start_operator,
3630
3996
  end_operator=end_operator, time_axis=time_axis,
3631
3997
  view=view)
3632
- return [e for e in out if self._readable(e)]
3998
+ return [e for e in out if self._readable(e, session=session)]
3633
3999
 
3634
4000
  # 生效条件:在 super()._neg_coverage(terms) 的结果上,只保留 self._readable(e) 为真的条目。
3635
4001
  def _neg_coverage(self, terms):
@@ -3667,13 +4033,11 @@ class MdCGSecure(MdCGOS):
3667
4033
  keep = []
3668
4034
  for r in res:
3669
4035
  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:
4036
+ if not self._readable(e, session=session):
3676
4037
  continue
4038
+ # 会话归属兜底已并入 _readable(session 传参):共享档跨会话可见、
4039
+ # 绑定档(private/secret)按归属会话判定(图扩展绕过 _candidates
4040
+ # 的串台路径由它一并挡住,与 validity/view 同层兜底)。
3677
4041
  if view is not None and not roleviews.matches(e, view):
3678
4042
  continue
3679
4043
  if validity and trust.is_expired(e, now=now):