@furongjun1999/dsh-memory 0.6.1 → 0.7.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 (48) hide show
  1. package/README.md +48 -18
  2. package/docs/README.md +1 -0
  3. package/docs/eval//345/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +460 -0
  4. package/docs/eval//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +97 -0
  5. package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +458 -0
  6. package/docs/hive//346/243/200/347/264/242/347/256/227/346/263/225/345/217/243/345/276/204/345/257/271/347/205/247_v0.1.md +136 -11
  7. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +32 -2
  8. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +88 -0
  9. 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
  10. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +47 -11
  11. package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +183 -0
  12. package/docs/plans//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +547 -0
  13. package/md_cg/bench_e2e_locomo_qa.py +11 -4
  14. package/md_cg/chain.py +47 -0
  15. package/md_cg/freshness.py +511 -0
  16. package/md_cg/generation.py +409 -0
  17. package/md_cg/hotcache.py +4 -1
  18. package/md_cg/mcp_server.py +128 -19
  19. package/md_cg/mdcg.py +509 -22
  20. package/md_cg/mdcos.py +203 -22
  21. package/md_cg/nodefile.py +74 -1
  22. package/md_cg/semantic/canonical.py +22 -0
  23. package/md_cg/semantic/unify.py +73 -22
  24. package/md_cg/semantic/unify_fixture.json +25 -0
  25. package/md_cg/sleep.py +1297 -0
  26. package/md_cg/sustain.py +146 -9
  27. package/md_cg/test_auto_defaults.py +424 -0
  28. package/md_cg/test_boundary_hit.py +410 -0
  29. package/md_cg/test_en_pipeline.py +22 -13
  30. package/md_cg/test_generation_guard.py +352 -0
  31. package/md_cg/test_h4_sustain_snapshot.py +14 -4
  32. package/md_cg/test_n212_n213_n224_generation_gates.py +14 -8
  33. package/md_cg/test_n230_dirty_replay.py +375 -0
  34. package/md_cg/test_p2_six_elements.py +463 -0
  35. package/md_cg/test_p3_legacy_closure.py +433 -0
  36. package/md_cg/test_p4_freshness.py +680 -0
  37. package/md_cg/test_p8_subgraph_chain.py +14 -1
  38. package/md_cg/test_rank_parity_score_mode.py +6 -0
  39. package/md_cg/test_semantic_canonical.py +5 -3
  40. package/md_cg/test_sleep.py +611 -0
  41. package/md_cg/test_sleep_p1.py +784 -0
  42. package/md_cg/test_time_core_lint.py +968 -0
  43. package/md_cg/test_unify_default_off.py +701 -0
  44. package/md_cg/test_unify_scope.py +182 -0
  45. package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
  46. package/package.json +3 -2
  47. package/skills/plugin.json +1 -1
  48. package/utf8_boot.py +237 -0
package/md_cg/mdcg.py CHANGED
@@ -27,7 +27,7 @@ import secrets
27
27
  import threading
28
28
 
29
29
  from . import (nodefile, protect, routing, subgraph, chain, provenance, pooling,
30
- lifecycle, reach, trust, roleviews, fsutil)
30
+ lifecycle, reach, trust, roleviews, fsutil, freshness)
31
31
  from .fsutil import (FileLock, ShardedLog, atomic_write, append_jsonl,
32
32
  read_jsonl, sweep_stale_temps, note_bad_payload_rows)
33
33
 
@@ -280,12 +280,14 @@ def en_zh_terms(text: str) -> list:
280
280
  独立归一化原子+Jaccard 路(REPRODUCE.md 双语双路裁定,md_cg
281
281
  char-bigram 管线跑英文实测比独立方案差 25% vs 52%),本集成为
282
282
  opt-in 实验能力与 soul hub 序列化出口,不在默认链路生效;
283
- ⚠ 注意与**统一归一层**的区别(2026-09-24 澄清):`semantic/unify.py`
284
- 的 `unify_query` 是另一个开关且**默认开启**(MDCG_UNIFY_QUERY,
285
- 2026-09-23 口径转正),它在检索入口把英文 query 归一成中文原子——
286
- 于是默认态下英文 query 经**词法路**即可命中中文节点,而本函数
287
- (召回词扩展侧)仍是默认关。两者不能互相推断,改动其一须核对
283
+ ⚠ 注意与**统一归一层**的区别(2026-09-24 澄清;2026-09-30 缺省翻关):
284
+ `semantic/unify.py` 的 `unify_query` 是另一个开关,**缺省关**且**唯一开态
285
+ = 显式 "1"**(MDCG_UNIFY_QUERY,2026-09-30 使用者裁定由「默认 '1'」翻为
286
+ 「未设即关」)——显式开启时它在检索入口把英文 query 归一成中文原子,
287
+ 于是英文 query 经**词法路**即可命中中文节点;而本函数(召回词扩展侧)
288
+ 是另一个独立开关(默认关,=1 才开)。两者不能互相推断,改动其一须核对
288
289
  test_en_pipeline / test_semantic_canonical 的双态断言;
290
+ unify 三态另有专件 `test_unify_default_off.py`(缺省态与两侧一致性);
289
291
  - 仅当 query 含英文字母时触发,纯中文 query 零开销零变化;
290
292
  - 代词语素剔除(我/你/他…超泛词防污染),动词/名词单字语素保留
291
293
  (「吃/雨」在中文正文检索价值高,_score 终排兜底精度);
@@ -605,6 +607,233 @@ def neg_condition_hits(neg_conditions, scene_text, own_topic_text="") -> list:
605
607
  return hits
606
608
 
607
609
 
610
+ # ---- 拒绝域双语义:`boundary_hit`(§5.2,P0-4)-----------------------------
611
+ # 背景(v0.4 §5.2):同一个字段 `non_applicable_conditions` 上会挂**两条**语义——
612
+ # ① 负条件判据:命中当前情境 ⇒ 资格 REJECT(`judge_qualification` 的第 2 段,
613
+ # 走单点 `neg_condition_hits`);
614
+ # ② 正面检索键:问「什么条件下不适用」时,该节点应当**被召回、被展示**,
615
+ # 标记 `boundary_hit`——这正是 §5.1 给「不适用条件」定的索引角色(拒绝域词)。
616
+ # 两条语义必须**可分**:否则默认链路上会出现自否定(用户问边界,节点因边界被否)。
617
+ # 记忆里那条返工教训(检索面 REJECT 46→0)即此类失真的先例。
618
+
619
+ #: 拒绝域命中的**展示标记**(§5.2:可召回、可展示;与资格 REJECT 分开呈现)。
620
+ BOUNDARY_MARKER = "boundary_hit"
621
+
622
+ #: 「把不适用条件当检索键」的问句意图标记——§5.1 检索用法列的原话即
623
+ #: 「什么条件下不适用」;其余为同义形态(中文检索面的问句变体)。
624
+ BOUNDARY_QUERY_MARKERS = ("不适用条件", "不适用", "什么条件下不适用",
625
+ "什么情况下不适用", "何时不适用", "边界条件", "拒绝域")
626
+
627
+ #: 「把验证方式当检索键」的问句意图标记——§5.1 检索用法列的原话即
628
+ #: 「是怎么验证的 / 用什么方法证明的」(P2-1 新增的第 5 行「后置条件词」)。
629
+ POSTCONDITION_QUERY_MARKERS = ("验证方式", "怎么验证", "如何验证", "怎样验证",
630
+ "怎么证明", "如何证明", "用什么方法证明",
631
+ "验证手段", "怎么被验证", "有无验证")
632
+
633
+
634
+ # 生效条件:query 为字符串(假值按空串)时,逐个取 POSTCONDITION_QUERY_MARKERS 判断是否为子串,任一命中即返回 True,全部不命中返回 False;只做字面可判的**意图识别**,不做分词/语义推断。
635
+ def is_postcondition_query(query: str) -> bool:
636
+ """查询是否在问节点的**验证方式本身**(§5.1「验证方式 → 后置条件词」)。
637
+
638
+ 与 `is_boundary_query` 同款形态(同一处 marker 表的兄弟判据,不另起机制):
639
+ 命中即按「验证手段」检索——召回那些**声明了验证方式**的节点。
640
+ """
641
+ q = str(query or "")
642
+ return any(m in q for m in POSTCONDITION_QUERY_MARKERS)
643
+
644
+
645
+ # 生效条件:fm 与 content 任意(缺键按空处理)时,取 CCG `# 不适用条件:` 行(走 nodefile.ccg_element_terms 的取值+切分单点)与 fm.non_applicable_conditions 逐条经 nodefile.element_terms_from_text 切分后的词项,按「CCG 行在前、fm 列表在后」的顺序去重,返回保序词项列表。
646
+ def rejection_index_terms(fm, content) -> list:
647
+ """「不适用条件」的**索引键**词项——**与资格判据同字段**(§5.2 双语义前提)。
648
+
649
+ 两处来源合并(保序去重),都从严归一为词项:
650
+ ① CCG 正文行 `# 不适用条件:`(`nodefile.ccg_field_value` 取值单点);
651
+ ② `frontmatter.non_applicable_conditions`——**资格 REJECT 判据
652
+ (`judge_qualification`)与 `boundary_hit` 读的就是这个字段**。
653
+ 两条语义共用同一份词项,才可能在「同一字段上可分」(否则索引键与拒绝域
654
+ 判据各读一处,检索面的边界命中与资格面的 REJECT 会对不上)。
655
+
656
+ ⚠ 本键**只用于召回/展示**(拒绝域词):它进索引条目后由 `_like` 侧消费,
657
+ 不参与 `judge_qualification` 的否决路径——否决路径仍读 frontmatter 原列表,
658
+ 经 `neg_condition_hits` 单点判定,灵敏度一字不动。
659
+ """
660
+ out, seen = [], set()
661
+ line_terms = nodefile.ccg_element_terms(content, nodefile.REJECTION_FIELD)
662
+ fm_terms = nodefile.element_terms_from_text(
663
+ ";".join(str(x) for x in ((fm or {}).get("non_applicable_conditions") or [])))
664
+ for t in list(line_terms) + list(fm_terms):
665
+ if t and t not in seen:
666
+ seen.add(t)
667
+ out.append(t)
668
+ return out
669
+
670
+
671
+ # 生效条件:entry 为索引条目(六要素后两行的索引键在其中)、terms 为查询词项列表(可为空)、query 为字符串时,按「postcondition_terms 被任一查询词项命中」或「验证方式问句(is_postcondition_query)且该节点声明了验证方式」判 post,按「边界问句(is_boundary_query)且该节点声明了拒绝域」判 reject,返回 {"hit": post 或 reject 的布尔, "post": bool, "reject": bool, "why": [命中的判据名]};
672
+ def index_key_hits(entry, terms, query) -> dict:
673
+ """六要素后两行索引键的**召回判据单点**(§5.4 全进默认检索 / §5.2 双语义)。
674
+
675
+ 两条键的**不对称**是刻意的,也正是 §5.2 要求的「同一字段、两条语义可分」:
676
+
677
+ · **验证方式 → 后置条件词**:`postcondition_terms` 作为**正面**召回键
678
+ (节点自己声明「是怎么验证的」),既可以由查询词项命中(如查询含
679
+ `compiler`,节点 `# 验证方式:compiler test`),也可以由**验证方式问句**
680
+ (`is_postcondition_query`:§5.1 的「是怎么验证的 / 用什么方法证明的」)
681
+ 召回「声明了验证方式」的节点;
682
+ · **不适用条件 → 拒绝域词**:**只在边界问句上**放开召回——普通问句下
683
+ 拒绝域仍**不作召回键**(`nodefile.positive_body` 的既有立场:反例命中
684
+ 只应由资格判定 REJECT,把它当召回键会「恰好在它不该适用的地方召回它」,
685
+ 属实质性错误)。边界问句(`is_boundary_query`:§5.1 的「什么条件下不适用」)
686
+ 问的就是边界本身,此时召回「声明了拒绝域」的节点正是设计要的行为,
687
+ 且该回收面按 §5.2 标记 `boundary_hit`、与资格 REJECT **分开计数**。
688
+
689
+ 取数一律来自**索引条目**(免读文件);键缺失(旧库未重建)时判 False,
690
+ 即行为退回改动前(零破坏)。
691
+ """
692
+ e = entry or {}
693
+ hits = {"hit": False, "post": False, "reject": False, "why": []}
694
+ q = str(query or "")
695
+ post = e.get(nodefile.POSTCONDITION_TERMS_KEY) or []
696
+ if post:
697
+ if any(t in " ".join(post) for t in (terms or [])):
698
+ hits["post"] = True
699
+ hits["why"].append("postcondition_terms:term")
700
+ elif is_postcondition_query(q):
701
+ hits["post"] = True
702
+ hits["why"].append("postcondition_terms:declared")
703
+ rej = e.get(nodefile.REJECTION_TERMS_KEY) or []
704
+ if rej and is_boundary_query(q):
705
+ hits["reject"] = True
706
+ hits["why"].append("rejection_terms:declared")
707
+ hits["hit"] = bool(hits["post"] or hits["reject"])
708
+ return hits
709
+
710
+
711
+ # 生效条件:node_dict 为 {frontmatter, content}(缺键按空处理)、query 为字符串、context 为 dict 或 None 时,先取 nodefile 的拒绝域词项(ccg_element_terms 走 CCG 行解析单点 + frontmatter.non_applicable_conditions)与 P0-4 的 boundary_hit 判据;二者任一命中即 hit=True,返回 {"hit": bool, "terms": [命中原样条目], "marker": "boundary_hit" 或 None, "why": [命中来源名], "reason": str};
712
+ def boundary_mark(node_dict, query, context=None) -> dict:
713
+ """**边界命中标记的唯一单点**(§5.2 检索面的展示判据)。
714
+
715
+ 两条来源,判据全部复用既有单点(不另写第二份):
716
+
717
+ ① **情境落在拒绝域**:`boundary_hit`(P0-4 已落)——查询/情境命中该节点的
718
+ `non_applicable_conditions`(走 `neg_condition_hits` 单点);
719
+ ② **边界问句 ∧ 该节点声明了拒绝域**:`is_boundary_query(query)` 为真且
720
+ `rejection_index_terms` 非空——这正是 §5.1 第 6 行的检索用法
721
+ 「什么条件下不适用」:用户问的就是边界,回的就是「它声明了什么边界」。
722
+ ②的取数与**索引键**同源(`rejection_index_terms`),故索引侧与展示侧
723
+ 不会各说各话。
724
+
725
+ `marker` 恒为 `BOUNDARY_MARKER`("boundary_hit")——它是**展示标记**,
726
+ 与资格判定的 REJECT **分开呈现、分开计数**(§5.2)。
727
+ """
728
+ bh = boundary_hit(node_dict, query, context)
729
+ fm = (node_dict or {}).get("frontmatter") or {}
730
+ content = (node_dict or {}).get("content") or ""
731
+ why, terms = [], []
732
+ if bh.get("hit"):
733
+ why.append("scene_in_rejection_domain")
734
+ terms.extend(bh.get("terms") or [])
735
+ if is_boundary_query(query):
736
+ rej = rejection_index_terms(fm, content)
737
+ if rej:
738
+ why.append("boundary_query_and_declared")
739
+ for t in rej:
740
+ if t not in terms:
741
+ terms.append(t)
742
+ hit = bool(why)
743
+ return {"hit": hit, "terms": terms,
744
+ "marker": BOUNDARY_MARKER if hit else None, "why": why,
745
+ "reason": ("边界命中(%s):可召回可展示;不影响其正例资格"
746
+ % "、".join(why) if hit else "未落在边界面上")}
747
+
748
+
749
+ # 生效条件:query 为字符串(假值按空串)时,逐个取 BOUNDARY_QUERY_MARKERS 判断是否为子串,任一命中即返回 True,全部不命中返回 False;只做字面可判的**意图识别**,不做分词/语义推断。
750
+ def is_boundary_query(query: str) -> bool:
751
+ """查询是否在问该节点的**拒绝域本身**(§5.1「不适用条件 → 拒绝域词」)。
752
+
753
+ 这是双语义的分界:是 ⇒ 命中属**边界命中**(可召回可展示,不得因此 REJECT);
754
+ 否 ⇒ 命中仍是资格面的负条件(照旧 REJECT,灵敏度不变)。
755
+ """
756
+ q = str(query or "")
757
+ return any(m in q for m in BOUNDARY_QUERY_MARKERS)
758
+
759
+
760
+ # 生效条件:node_dict 为 {frontmatter, content}(缺键按空处理)且 query 为字符串时,以 frontmatter.non_applicable_conditions 为拒绝域词面、以 json({"query":query, **context}) 为情境(与 judge_qualification 同一情境口径)、以 MdCG._self_topic_text(fm, content) 为自主题面,调单点 neg_condition_hits 取命中;返回 {"hit": bool, "terms": [命中原样条目(按首次出现序)], "marker": "boundary_hit" 或 None, "reason": str, "scene": str}。
761
+ def boundary_hit(node_dict, query, context=None) -> dict:
762
+ """把「不适用条件」当**正面检索键**命中该节点的判据(**不产生 REJECT**)。
763
+
764
+ 与资格判定的负条件腿**共用同一个单点**(`neg_condition_hits`)——两条语义
765
+ 共享判据实现,只在**归类**上分开:本函数只回答「查询是否落在该节点的拒绝域上、
766
+ 可否召回展示」,一律不动 qualification。
767
+
768
+ 返回形状(P2 直接调用)::
769
+
770
+ {"hit": bool, # 是否边界命中(可召回、可展示)
771
+ "terms": [str, ...], # 命中的不适用条件条目(原样、按首次出现序)
772
+ "marker": str | None, # 命中即 BOUNDARY_MARKER("boundary_hit"),否则 None
773
+ "reason": str, # 人类可读依据
774
+ "scene": str} # 本判据用的情境串(与 judge_qualification 同口径)
775
+ """
776
+ fm = (node_dict or {}).get("frontmatter") or {}
777
+ content = (node_dict or {}).get("content") or ""
778
+ scene = {"query": query or ""}
779
+ if isinstance(context, dict):
780
+ scene.update(context)
781
+ scene_str = json.dumps(scene, ensure_ascii=False)
782
+ terms = fm.get("non_applicable_conditions") or []
783
+ hit = neg_condition_hits(terms, scene_str, MdCG._self_topic_text(fm, content))
784
+ return {"hit": bool(hit), "terms": hit,
785
+ "marker": BOUNDARY_MARKER if hit else None,
786
+ "reason": (("边界命中:查询落在该节点的拒绝域(不适用条件)上,"
787
+ "可召回可展示;不影响其正例资格") if hit
788
+ else "未落在该节点的拒绝域上"),
789
+ "scene": scene_str}
790
+
791
+
792
+ # 生效条件:text 为字符串(假值按空串)、terms 为可迭代条目表时,逐条把其原样字面量与「去空白后的字符间容忍任意空白」形态从 text 中替换为空格;返回替换后的字符串(terms 为空即原样返回)。
793
+ def strip_boundary_terms(text: str, terms) -> str:
794
+ """从查询串里摘掉命中的拒绝域条目(只用于**边界问句**的资格判定)。
795
+
796
+ 为什么摘:用户问的就是「这些条件下不适用」——把这些词面留在资格判定的情境里,
797
+ 等于让节点因为「被问到自己的拒绝域」而 REJECT(自否定)。摘掉后资格判定问的
798
+ 是**除边界词面之外的**情境,命中的是用户的真实处境。
799
+ """
800
+ out = str(text or "")
801
+ for t in (terms or []):
802
+ s = str(t)
803
+ if not s:
804
+ continue
805
+ if s in out:
806
+ out = out.replace(s, " ")
807
+ ns = re.sub(r"\s+", "", s)
808
+ if ns and ns != s: # 条目内含空白 → 容忍任意空白形态
809
+ out = re.sub(r"\s*".join(re.escape(ch) for ch in ns), " ", out)
810
+ return out
811
+
812
+
813
+ # 生效条件:records 为 judge_with_boundary 返回体或 None 的可迭代时,逐条累加其 qualification.state(归一后属 accept/reject/defer/blindspot 者)、boundary.hit 与 recallable,返回 {"total","accept","reject","defer","blindspot","boundary_hit","recallable","self_negation"}——self_negation 为「边界问句下既边界命中又被资格 REJECT」的条数(**应为 0**:非 0 即双语义串味、自否定残留)。
814
+ def tally_boundary(records) -> dict:
815
+ """把逐条判定汇总成**分开计数**的读数(P2 检索面出数用)。"""
816
+ out = {"total": 0, "accept": 0, "reject": 0, "defer": 0, "blindspot": 0,
817
+ "boundary_hit": 0, "recallable": 0, "self_negation": 0}
818
+ for r in (records or []):
819
+ if not isinstance(r, dict):
820
+ continue
821
+ out["total"] += 1
822
+ qual = r.get("qualification") or {}
823
+ st = str(qual.get("state") or "").strip().lower()
824
+ if st in ("accept", "reject", "defer", "blindspot"):
825
+ out[st] += 1
826
+ bh = bool((r.get("boundary") or {}).get("hit"))
827
+ if bh:
828
+ out["boundary_hit"] += 1
829
+ if r.get("recallable"):
830
+ out["recallable"] += 1
831
+ # 自否定 = 边界问句(判据已摘词面重跑)却仍 REJECT——应为 0
832
+ if qual.get("boundary_suppressed") and st == "reject":
833
+ out["self_negation"] += 1
834
+ return out
835
+
836
+
608
837
  # 生效条件:当 `query` 为字符串时,返回含整句、≥2 字符分词及命中 `SYNONYM_GROUPS_WEIGHTED` 组加权项(同名取最大权重)的字典;空串返回 `{}`。
609
838
  def expand_query_terms_weighted(query: str) -> dict:
610
839
  """分级版查询扩展:返回 {词: 隶属度},隶属度 ∈ (0, 1]。
@@ -957,6 +1186,12 @@ class _DirtyDict(dict):
957
1186
  self.write_gen = 0
958
1187
  self.path_gen = {} # path → 该 path 最近一次标脏时的 write_gen
959
1188
  self.broad_gen = 0 # 最近一次无 path 可辨变更的 write_gen(整池兜底)
1189
+ # N230(2026-10-01):**落盘逐字见证**——nid → 标脏那一刻节点文件的
1190
+ # (st_mtime_ns, st_size)。`MdCG.__init__` 负责注入 `root`(本类不反查
1191
+ # 宿主,免得与 MdCG 的构造顺序耦合)。语义与用法见
1192
+ # `MdCG._dirty_entry_superseded`。
1193
+ self.root = None
1194
+ self.staged_stat = {}
960
1195
 
961
1196
  def _bump(self):
962
1197
  self.write_gen += 1
@@ -969,13 +1204,36 @@ class _DirtyDict(dict):
969
1204
  else:
970
1205
  self.broad_gen = self.write_gen
971
1206
 
1207
+ # 生效条件:k 为节点 id、v 为 entry;恒 pop 该 k 的旧见证,随后仅当 v 为含非空 path 字符串的 dict 且 self.root 为真、且 os.stat(root/path) 成功时记入 self.staged_stat[k]=(st_mtime_ns, st_size);其余情形(None tombstone / 无 path / stat 失败 / 无 root)不登记——不登记即「证不出陈旧」,重放按旧口径放行。
1208
+ def _note_witness(self, k, v):
1209
+ """落盘逐字见证的登记(N230)。
1210
+
1211
+ 为什么记 (mtime_ns, size):`_maybe_reload_index` 需要判「本实例这条
1212
+ 未 flush 的写入,是不是已经被他进程后来写下的**更新**记录盖过」。节点
1213
+ 文件是唯一真源——标脏时它长什么样,重放时再 stat 一次,不一样就说明
1214
+ 盘面已被别人改过(改写内容必然改 mtime_ns;NTFS 粒度 100ns)。
1215
+ stat 失败与无 path 一律不登记:**证不出陈旧就不当陈旧**——「本实例未
1216
+ 落盘写入不因重载从检索面消失」是既有不变量,宁可漏挡也不误杀。
1217
+ """
1218
+ self.staged_stat.pop(k, None)
1219
+ p = v.get("path") if isinstance(v, dict) else None
1220
+ if not (isinstance(p, str) and p and self.root):
1221
+ return
1222
+ try:
1223
+ st = os.stat(os.path.join(self.root, p))
1224
+ except OSError:
1225
+ return
1226
+ self.staged_stat[k] = (st.st_mtime_ns, st.st_size)
1227
+
972
1228
  def __setitem__(self, k, v):
973
1229
  self._bump()
974
1230
  self._note(v)
1231
+ self._note_witness(k, v)
975
1232
  super().__setitem__(k, v)
976
1233
 
977
1234
  def __delitem__(self, k):
978
1235
  self._bump()
1236
+ self.staged_stat.pop(k, None)
979
1237
  self.broad_gen = self.write_gen
980
1238
  super().__delitem__(k)
981
1239
 
@@ -996,26 +1254,31 @@ class _DirtyDict(dict):
996
1254
  self._bump()
997
1255
  if broad:
998
1256
  self.broad_gen = self.write_gen
1257
+ self.staged_stat.clear()
999
1258
  super().clear()
1000
1259
 
1001
1260
  def pop(self, k, *d):
1002
1261
  self._bump()
1262
+ self.staged_stat.pop(k, None)
1003
1263
  self.broad_gen = self.write_gen
1004
1264
  return super().pop(k, *d)
1005
1265
 
1006
1266
  def popitem(self):
1007
1267
  self._bump()
1008
1268
  self.broad_gen = self.write_gen
1269
+ self.staged_stat.clear() # 摘哪条不确定,保守清空(下一轮按「无见证」放行)
1009
1270
  return super().popitem()
1010
1271
 
1011
1272
  def setdefault(self, k, d=None):
1012
1273
  self._bump()
1013
1274
  self.broad_gen = self.write_gen
1275
+ self.staged_stat.pop(k, None)
1014
1276
  return super().setdefault(k, d)
1015
1277
 
1016
1278
  def update(self, *a, **k):
1017
1279
  self._bump()
1018
1280
  self.broad_gen = self.write_gen
1281
+ self.staged_stat.clear()
1019
1282
  super().update(*a, **k)
1020
1283
 
1021
1284
 
@@ -1174,6 +1437,11 @@ class MdCG:
1174
1437
  self.recent_log = os.path.join(self.root, "_recent.jsonl")
1175
1438
  self.autoflush = autoflush
1176
1439
  self._dirty = _DirtyDict()
1440
+ # N230:把数据根交给脏集——标脏时按 entry["path"] 落「逐字见证」
1441
+ # (见 _DirtyDict._note_witness / MdCG._dirty_entry_superseded)。
1442
+ self._dirty.root = self.root
1443
+ # 最近一次重载里被判定「已被盘面更新盖过」而未重放的条数(只作可观测读数)
1444
+ self._dirty_replay_superseded = 0
1177
1445
  # realpath 进程内缓存(性能批次,候选 a):root 解析一次 + rel→abs
1178
1446
  # 解析结果 memo 化,失效哨兵与 readcache 同源(见 _node_disk_path)。
1179
1447
  self._root_real = None
@@ -1324,7 +1592,37 @@ class MdCG:
1324
1592
  parts.append((fn, s.st_size, s.st_mtime_ns))
1325
1593
  return (snap, tuple(parts))
1326
1594
 
1327
- # 生效条件:stat 对比 _index_signature() 与 self._index_sig,相等(含双侧 None)即返回 False 不做任何事;不等则调 _load_index() 重载,OSError/ValueError 时静默放弃并返回 False(重载失败不阻塞读,沿用旧内存态);成功后把 self._dirty 重放回新索引(None=tombstone pop、否则覆盖,与 _load_index 的日志重放同语义——本实例未 flush 的写入不得因重载从检索面消失)并重算 buckets,替换 self.index、刷新 self._index_sig、返回 True;
1595
+ # 生效条件:self._dirty.staged_stat 中无该 nid 的见证、或 self._dirty[nid] 非含非空 path 的 dict 时返回 False;有见证时对 root/path 再 stat 一次——OSError(文件已不在,标脏时在)返回 True,成功则返回 (st_mtime_ns, st_size) 与见证不等的布尔(不等即 True)。
1596
+ def _dirty_entry_superseded(self, nid) -> bool:
1597
+ """本实例 `_dirty[nid]` 是否**已被盘面更新盖过**(N230:旧不得盖新)。
1598
+
1599
+ 判据是**节点文件的逐字见证**:标脏那一刻记下 `(st_mtime_ns, st_size)`,
1600
+ 重放前再 stat 一次;不相等 ⇒ 盘面已被他进程改写过 ⇒ 本实例这条是旧的,
1601
+ 重放必须放行盘面(`_maybe_reload_index` 据此跳过本条)。
1602
+
1603
+ 三条边界(都是有意的):
1604
+ · **证不出陈旧就不当陈旧**:无见证(无 path / stat 失败 / `_dirty` 未挂
1605
+ root)返回 False ⇒ 照旧重放——「本实例未落盘写入不因重载从检索面消失」
1606
+ 这条既有不变量优先于本缺陷的覆盖面。
1607
+ · **tombstone(`None`)不走本判据**:`_unstage` 立即 flush(删除不延迟到
1608
+ autoflush 阈值),故 tombstone 实际上极少跨重载存活;其语义一字不改。
1609
+ · **同内容改写**:他进程用同样的字节重写(size 同)时 mtime_ns 仍会变,
1610
+ 故仍判陈旧——放行的盘面条目与本条同义,结果无差。
1611
+ """
1612
+ st = self._dirty.staged_stat.get(nid)
1613
+ if st is None:
1614
+ return False
1615
+ e = self._dirty.get(nid)
1616
+ p = e.get("path") if isinstance(e, dict) else None
1617
+ if not p:
1618
+ return False
1619
+ try:
1620
+ cur = os.stat(os.path.join(self.root, p))
1621
+ except OSError:
1622
+ return True # 标脏时文件在、现在不在 ⇒ 盘面确已变
1623
+ return (cur.st_mtime_ns, cur.st_size) != st
1624
+
1625
+ # 生效条件:stat 对比 _index_signature() 与 self._index_sig,相等(含双侧 None)即返回 False 不做任何事;不等则调 _load_index() 重载,OSError/ValueError 时静默放弃并返回 False(重载失败不阻塞读,沿用旧内存态);成功后把 self._dirty 重放回新索引(None=tombstone pop、否则覆盖,与 _load_index 的日志重放同语义——本实例未 flush 的写入不得因重载从检索面消失;N230:见证失配者判为陈旧,**跳过重放**并计入 self._dirty_replay_superseded)并重算 buckets,替换 self.index、刷新 self._index_sig、返回 True;
1328
1626
  def _maybe_reload_index(self):
1329
1627
  """读路径入口的索引代际感知(P1b-2):签名变化才重载。
1330
1628
 
@@ -1340,6 +1638,14 @@ class MdCG:
1340
1638
  (flush)都改变签名 → 此时重载并重放分片日志(_load_index 既有行为),
1341
1639
  写入可见;本实例 _dirty 未 flush 的条目在重载后**重放回内存索引**——
1342
1640
  _stage 双写 index+_dirty 的「检索看得到未落盘写入」语义保持。
1641
+
1642
+ N230(2026-10-01,**旧盖新**):原实现无条件 `idx["nodes"][nid] = e`——
1643
+ 本实例标脏后**他进程重写过同一节点**时,重载取回的是更新记录,随即被
1644
+ 本地这条更旧的盖了回去(实测:盘面与新建读者实例都是 NEW,本实例索引
1645
+ 仍停在 OLD,`content_hash` 与盘面撕裂)。现按「**逐字见证**」判陈旧:
1646
+ `_dirty_entry_superseded` 为真即**放行盘面**(跳过本条重放),并在
1647
+ `self._dirty_replay_superseded` 留下条数读数。见证拿不到的条目照旧重放
1648
+ ——「本实例未落盘写入不因重载从检索侧消失」这条不变量一并保留。
1343
1649
  """
1344
1650
  sig = self._index_signature()
1345
1651
  if sig == self._index_sig:
@@ -1348,11 +1654,21 @@ class MdCG:
1348
1654
  idx = self._load_index()
1349
1655
  except (OSError, ValueError):
1350
1656
  return False # 重载失败不阻塞读:沿用旧内存态
1657
+ superseded = 0
1351
1658
  for nid, e in self._dirty.items():
1352
1659
  if e is None:
1353
1660
  idx["nodes"].pop(nid, None)
1354
- else:
1355
- idx["nodes"][nid] = e
1661
+ continue
1662
+ # N230(2026-10-01):**新记录必须能盖住旧记录,旧不得盖新**。
1663
+ # 重载后的 idx 来自快照(指纹校验过盘面)或全库扫描,两者都反映
1664
+ # **当前盘面**;本实例这条 `_dirty` 若在标脏后盘面已被改过(逐字
1665
+ # 见证失配),它就是旧的——放行盘面,不得盖回去。见证拿不到时
1666
+ # 照旧重放(见 _note_witness 的「证不出陈旧就不当陈旧」)。
1667
+ if self._dirty_entry_superseded(nid):
1668
+ superseded += 1
1669
+ continue
1670
+ idx["nodes"][nid] = e
1671
+ self._dirty_replay_superseded = superseded
1356
1672
  idx["buckets"] = self._count_buckets(idx["nodes"])
1357
1673
  self.index = idx
1358
1674
  self._index_sig = sig
@@ -1562,6 +1878,20 @@ class MdCG:
1562
1878
  "created_at": fm.get("created_at", 0),
1563
1879
  "verification_basis": fm.get("verification_basis"),
1564
1880
  "has_neg_conditions": nodefile.has_non_applicable(content),
1881
+ # ── P2-1(§5.1 六要素 6 行):后两行的**索引键**入快照 ──────────
1882
+ # 验证方式 → 后置条件词;不适用条件 → 拒绝域词。与既有
1883
+ # `time_window`/`observation_position` 同为**免读文件可判的扁指标量**
1884
+ # (同款理由:检索期不读盘;§5.4「全进默认检索」的取数面即此)。
1885
+ # 取值一律走 `nodefile` 的 CCG 行解析单点(检索面不另写正则)。
1886
+ nodefile.POSTCONDITION_TERMS_KEY:
1887
+ nodefile.ccg_element_terms(content, nodefile.POSTCONDITION_FIELD),
1888
+ nodefile.REJECTION_TERMS_KEY: rejection_index_terms(fm, content),
1889
+ # ── P4-2①(§7.2 卡点一):访问计数入快照 ────────────────────────
1890
+ # `access_count`/`last_access` 此前只记账(`_access.log` → 折叠落
1891
+ # frontmatter),检索想用只能读盘(违反「检索不写少读」)——故与
1892
+ # 上面两行同款:免读文件的扁指标量,供 `_score` 的刷新乘子取数。
1893
+ "access_count": fm.get("access_count", 0),
1894
+ "last_access": fm.get("last_access", 0),
1565
1895
  # 内容指纹走 nodefile 的唯一实现(两段式对账依赖同一算法)
1566
1896
  "content_hash": nodefile.content_hash(content),
1567
1897
  # 时空字段入索引快照:STG 查询免读文件(大域/目录索引的延伸)
@@ -2145,6 +2475,15 @@ class MdCG:
2145
2475
  "importance": importance, "created_at": fm["created_at"],
2146
2476
  "verification_basis": verification_basis,
2147
2477
  "has_neg_conditions": nodefile.has_non_applicable(sealed),
2478
+ # P2-1 / P4-2①:与 `_node_entry`(重建路径)**同口径**——写路径的
2479
+ # 定向 upsert 若漏这两组键,就得等下一次全量重建才补上(N137 同款
2480
+ # 「重建后有、写入后没有」的形态漂移)。
2481
+ nodefile.POSTCONDITION_TERMS_KEY:
2482
+ nodefile.ccg_element_terms(sealed, nodefile.POSTCONDITION_FIELD),
2483
+ nodefile.REJECTION_TERMS_KEY:
2484
+ rejection_index_terms(fm, sealed),
2485
+ "access_count": fm.get("access_count", 0),
2486
+ "last_access": fm.get("last_access", 0),
2148
2487
  "content_hash": hashlib.sha256(sealed.encode("utf-8")).hexdigest()[:12],
2149
2488
  "temporal": fm.get("temporal"),
2150
2489
  "spatial": fm.get("spatial"),
@@ -3031,6 +3370,67 @@ class MdCG:
3031
3370
  acc = "无情境可比(未做正条件确认)+ " + acc
3032
3371
  return {"state": STATE_ACCEPT, "reason": acc}
3033
3372
 
3373
+ # 生效条件:node_dict 为 {frontmatter, content}(缺键按空处理)、query 为字符串、context 为 dict 或 None;无条件先算边界命中(boundary_hit),再算资格判定——边界命中**且** query 为边界问句(is_boundary_query)时,资格判定改以「摘掉命中拒绝域条目后的 query」重跑(并在返回体的 qualification 上标 boundary_suppressed=True),其余情形资格判定原样跑 judge_qualification(node_dict, query, context);返回 {"qualification","boundary","counts","recallable","reason"}——counts 内 reject 与 boundary_hit **分开计数**,recallable 为「可召回可展示」(边界命中恒真;无边界命中时 = 资格态不是 REJECT)。
3374
+ @staticmethod
3375
+ def judge_with_boundary(node_dict, query: str, context=None) -> dict:
3376
+ """资格判定 + 拒绝域命中——**分开计数、分开呈现**(§5.2 / P0-4)。
3377
+
3378
+ 返回形状(P2 的检索面直接消费)::
3379
+
3380
+ {"qualification": {"state": ..., "reason": ...},
3381
+ "boundary": {"hit": bool, "terms": [...],
3382
+ "marker": "boundary_hit" | None, "reason": str},
3383
+ "counts": {"accept":0|1, "reject":0|1, "defer":0|1,
3384
+ "blindspot":0|1, "boundary_hit":0|1},
3385
+ "recallable": bool, # 与既有候选过滤同口径:state 不属
3386
+ # (REJECT, BLINDSPOT) 即可召回
3387
+ "reason": str}
3388
+
3389
+ **不变量(本判据的核心)**:`boundary.hit` 为真**不构成**资格否定——
3390
+ 边界问句下资格判定摘掉命中词面再跑,故「问该节点的拒绝域」不会把该节点
3391
+ 打回 REJECT(自否定);非边界问句下资格判定**原样**跑 `judge_qualification`,
3392
+ 负条件的灵敏度一字不变(情境里真的命中拒绝域 ⇒ 照旧 REJECT)。
3393
+
3394
+ **已接线(P2-2,2026-10-01)**:本函数与 `boundary_hit` 都**有生产调用方**——
3395
+
3396
+ · `MdCG._emit`(`md_cg/mdcg.py:4181`)=默认打分收口,结果卡带
3397
+ `boundary_hit` 标记 + `stat["boundary"]["counts"]` 分开计数;
3398
+ · `MdCos.search_rrf`(`md_cg/mdcos.py:1754`)=默认召回路径,同款标记与
3399
+ `boundary_counts`。
3400
+
3401
+ 两处都调**同一个** `judge_with_boundary`(边界标记一律经同族的
3402
+ `boundary_mark` 取,其体内复用 P0-4 的 `boundary_hit`)——**单点复用、
3403
+ 不另写第二份判据**;接线是否在位由 `md_cg/test_boundary_hit.py` 的 B5 组
3404
+ 断言钉死(该组在 P2-2 已把 P0-4 的「本轮零接线」断言改判为接线断言)。
3405
+ """
3406
+ node_dict = node_dict or {}
3407
+ bh = boundary_hit(node_dict, query, context)
3408
+ bq = is_boundary_query(query)
3409
+ if bh["hit"] and bq:
3410
+ qual = dict(MdCG.judge_qualification(
3411
+ node_dict, strip_boundary_terms(query, bh["terms"]), context))
3412
+ qual["boundary_suppressed"] = True
3413
+ else:
3414
+ qual = dict(MdCG.judge_qualification(node_dict, query, context))
3415
+ qual["boundary_suppressed"] = False
3416
+ st = str(qual.get("state") or "")
3417
+ counts = {"accept": int(st == STATE_ACCEPT),
3418
+ "reject": int(st == STATE_REJECT),
3419
+ "defer": int(st == STATE_DEFER),
3420
+ "blindspot": int(st == STATE_BLINDSPOT),
3421
+ "boundary_hit": int(bool(bh["hit"]))}
3422
+ # 可召回与**既有候选过滤同口径**(mdcos 的 `st in (REJECT, BLINDSPOT)`
3423
+ # 即剔除):这两态不进候选,其余(ACCEPT/DEFER)可召回。
3424
+ recallable = st not in (STATE_REJECT, STATE_BLINDSPOT)
3425
+ return {"qualification": {"state": qual.get("state"),
3426
+ "reason": qual.get("reason"),
3427
+ "boundary_suppressed": qual["boundary_suppressed"]},
3428
+ "boundary": {"hit": bh["hit"], "terms": bh["terms"],
3429
+ "marker": bh["marker"], "reason": bh["reason"]},
3430
+ "counts": counts, "recallable": recallable,
3431
+ "reason": ("边界命中(%s)+ 资格 %s" % (bh["marker"], st)
3432
+ if bh["hit"] else "无边界命中 + 资格 %s" % st)}
3433
+
3034
3434
  # ---------- 检索(性能阶梯 + 资格判定)----------
3035
3435
 
3036
3436
  # 生效条件:query strip 后为空即返回 ([], {"tier": None, "reason": "empty_query", "scanned": 0});非空时按 T0–T3 性能阶梯取候选(layer / session / branch / validity / view 为假值时对应维度不过滤),judge 为真时对每条结果附独立的四态资格判定;返回 (results, meta);
@@ -3068,9 +3468,10 @@ class MdCG:
3068
3468
  ValueError(fail-closed 只针对调用方误用)。
3069
3469
  """
3070
3470
  q = (query or "").strip()
3071
- # 批次 15 统一口径(unify.py):任意语言 query → 标准原子序列
3072
- # (统一翻译为中文→归一化到标准中文集→检索);纯中文原样、
3073
- # MDCG_UNIFY_QUERY=0 可关、失败静默原样
3471
+ # 批次 15 统一口径(unify.py,2026-09-30 收窄作用域):只译**英文内容**
3472
+ # → 标准原子序列,中文段逐字原样(不再整条归一);纯中文/无 ASCII 字母
3473
+ # 原样、归一层**缺省关**(2026-09-30 裁定,显式 MDCG_UNIFY_QUERY=1 才开)、
3474
+ # 失败静默原样。口径真源见 docs/hive/检索算法口径对照_v0.1.md
3074
3475
  from .semantic.unify import unify_query
3075
3476
  q = unify_query(q)
3076
3477
  if not q:
@@ -3257,7 +3658,9 @@ class MdCG:
3257
3658
  if in_bucket:
3258
3659
  docs = self._read_many(in_bucket, stat)
3259
3660
  # 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池
3260
- hits = [d for d in docs if self._like(d[2], d[1], terms)
3661
+ hits = [d for d in docs
3662
+ if self._like(d[2], d[1], terms,
3663
+ index_key_hits(d[0], terms, q))
3261
3664
  or (semantic_on() and d[1].get("semantic"))]
3262
3665
  out = try_stage(hits, TIER_BUCKET_LIKE)
3263
3666
  if out:
@@ -3283,7 +3686,8 @@ class MdCG:
3283
3686
  docs_r = self._read_many(reach_entries, stat)
3284
3687
  _dif = set(_rstat.get("reach_diffused_paths") or ())
3285
3688
  hits_r = [d for d in docs_r
3286
- if self._like(d[2], d[1], terms)
3689
+ if self._like(d[2], d[1], terms,
3690
+ index_key_hits(d[0], terms, q))
3287
3691
  or (semantic_on() and d[1].get("semantic"))
3288
3692
  or d[0].get("path") in _dif] # 图扩散补召回:无词面命中也放行进打分
3289
3693
  stat["pre_cap"] = len(hits_r) # 与 T2 同序:截断**前**的候选数
@@ -3384,11 +3788,19 @@ class MdCG:
3384
3788
  _s7_scan0 = stat["scanned"] # S7 窄化前的扫描基线(供 T3 兜底还原口径)
3385
3789
  docs_all = self._read_many(entries, stat)
3386
3790
  # 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池
3387
- hits = [d for d in docs_all if self._like(d[2], d[1], terms)
3791
+ hits = [d for d in docs_all
3792
+ if self._like(d[2], d[1], terms,
3793
+ index_key_hits(d[0], terms, q))
3388
3794
  or (semantic_on() and d[1].get("semantic"))]
3389
3795
  # ---- S3 图扩散激活(契约 §3 S3;flag 控,默认关)----
3390
- # 为何:edges 一直只被写入、检索从不使用(审计偏差 1)。这里从词法命中节点沿
3391
- # edges 双向扩散,把「关联但词面不重叠」的记忆作为独立一层候选(TIER_SPREAD)。
3796
+ # 为何:审计偏差 1 曾成立——「edges 一直只被写入、检索从不使用」。**该偏差
3797
+ # 已消除(P3-causal,2026-10-01)**:检索侧现有两条路读 edges——①
3798
+ # `search_rrf` 的 `chain` 路(因果路,复用 md_cg/chain.py,按裁定 9 进
3799
+ # 缺省集);② 本层 S3 扩散(`search` 的候选生成段,flag 控、默认关,产物
3800
+ # 是独立 tier TIER_SPREAD)。两者定位不同:chain 是「路内排名项」,
3801
+ # 本层是「新增候选层」(契约 §4.1 的「有意保留的差异」同款划界)。
3802
+ # 本层从词法命中节点沿 edges 双向扩散,把「关联但词面不重叠」的记忆作为
3803
+ # 独立一层候选(TIER_SPREAD)。
3392
3804
  # 约束:扩散只走索引里的 edges(不读文件);**不得引入未通过 S1/S2 门控**的节点;
3393
3805
  # 命中不足时自然落回原 T2/T3 路径(无召回损失)。
3394
3806
  if _s3 and hits:
@@ -3523,10 +3935,19 @@ class MdCG:
3523
3935
  return docs
3524
3936
 
3525
3937
  @staticmethod
3526
- # 生效条件:terms 为空时返回 False;否则任一 t 在 positive_body(content) 的小写串中出现,或该 t 的小写形式出现在 fm 的 tags(tags 取自 fm.get("tags") or [],缺键或假值按空列表拼接)小写串中即返回 True。
3527
- def _like(content, fm, terms):
3938
+ # 生效条件:terms 为空时返回 False;否则任一 t 在 positive_body(content) 的小写串中出现,或该 t 的小写形式出现在 fm 的 tags(tags 取自 fm.get("tags") or [],缺键或假值按空列表拼接)小写串中,或 index_keys 为真值(六要素后两行的索引键命中,见 index_key_hits)即返回 True。
3939
+ def _like(content, fm, terms, index_keys=None):
3528
3940
  # 负条件行(`# 不适用条件:`)是反例声明,不作召回键:命中它只应由
3529
3941
  # judge_qualification 走 REJECT,不能把节点召回。tags 仍参与匹配。
3942
+ #
3943
+ # P2-3(§5.4「六要素 6 行全进默认检索」):**后两行**(验证方式 /
3944
+ # 不适用条件)的**索引键**(`postcondition_terms`/`rejection_terms`,
3945
+ # 已在索引条目里)在此参与召回。`positive_body` 的立场**不变**——
3946
+ # 拒绝域仍不作普通问句的召回键(`index_key_hits` 只在**边界问句**上
3947
+ # 放开拒绝域召回;见函数说明)。index_keys 为 None(旧调用方)时
3948
+ # 行为与改动前逐位一致。
3949
+ if index_keys and index_keys.get("hit"):
3950
+ return True
3530
3951
  tags = " ".join(str(t) for t in (fm.get("tags") or []))
3531
3952
  body = nodefile.positive_body(content)
3532
3953
  # 双边小写化:英文大小写统一(中文无大小写不受影响)
@@ -3572,6 +3993,15 @@ class MdCG:
3572
3993
  if pools: # §七 降权:乘数只来自显式权重表(可复算)
3573
3994
  raw = max(0.0, min(1.0, raw * pooling.weight_of(
3574
3995
  fm.get("id") or e["path"], e, pools)))
3996
+ # ── P4(§7.1/§7.2):刷新与衰减乘子——**与上一行同一个乘子位** ──
3997
+ # 形态(已裁):score ← score × cred_factor(γ, Δt) × refresh(ac, la)。
3998
+ # 「不新开乘子链」= 就在这里乘,不另起一条对最终 RRF 分做后处理的链。
3999
+ # 取数全来自**索引条目** `e`(`_node_entry` 已落 created_at /
4000
+ # access_count / last_access),检索期**不读盘**(§7.2 卡点一)。
4001
+ # 核走唯一权威 time_core,γ 走 P3 已落的唯一读取点(见 freshness 模块头)。
4002
+ # 开关 `MDCG_FRESHNESS`(缺省开)=0 时乘子恒 1.0(与改动前逐位一致)。
4003
+ if freshness.enabled():
4004
+ raw = raw * freshness.entry_weight(e, fm=fm)[0]
3575
4005
  if _boost: # S4:层级加成(最后一步;上限仍夹在 1.0)
3576
4006
  raw = min(1.0, raw + _boost.get(str(fm.get("layer") or ""), 0.0))
3577
4007
  scored.append(({"id": fm.get("id") or e["path"], "frontmatter": fm,
@@ -3743,15 +4173,42 @@ class MdCG:
3743
4173
  stat.setdefault("gates", {})["s6"] = {
3744
4174
  "checked": len(_rows6), "by_track": _track6,
3745
4175
  "flagged": _flagged6, "rows": _rows6}
3746
- # 资格判定(与性能正交)
4176
+ # 资格判定(与性能正交)——**P2-2(§5.2)在此接线**:
4177
+ # 走 `MdCG.judge_with_boundary`(P0-4 已落的判据函数,**复用不另写一份**):
4178
+ # · 边界命中(拒绝域)→ 结果卡带 `boundary_hit` 标记(可召回、可展示);
4179
+ # · 该节点的**正例资格不受影响**:边界问句下资格判定先摘掉命中词面再跑,
4180
+ # 故「问它什么时候不适用」不会把它自己打成 REJECT(自否定);
4181
+ # · **分开计数、分开呈现**:`boundary_hit` 与资格 REJECT 各计各的
4182
+ # (stat["boundary"]["counts"],不混成一个数)。
4183
+ # 非边界问句下 judge_with_boundary 内部原样调 judge_qualification,
4184
+ # 故默认口径的 state 与改动前逐位一致(负条件灵敏度一字不动)。
3747
4185
  out = []
4186
+ _bnd = {"hit": 0, "terms": 0,
4187
+ "counts": {"accept": 0, "reject": 0, "defer": 0,
4188
+ "blindspot": 0}}
3748
4189
  for r in results:
3749
4190
  if judge:
3750
- qual = self.judge_qualification(r[0], stat.get("query") or "",
3751
- context)
4191
+ _jw = MdCG.judge_with_boundary(r[0], stat.get("query") or "",
4192
+ context)
4193
+ qual = dict(_jw["qualification"])
4194
+ _b = boundary_mark(r[0], stat.get("query") or "", context)
4195
+ if _b.get("hit"):
4196
+ _bnd["hit"] += 1
4197
+ _bnd["terms"] += len(_b.get("terms") or [])
4198
+ # 拒绝域命中走**独立字段**展示(不写进 qualification,
4199
+ # 否则下游按 state 判定的地方会把它读成资格结论)
4200
+ r[0]["boundary_hit"] = True
4201
+ r[0]["boundary_marker"] = _b.get("marker")
4202
+ r[0]["boundary_terms"] = list(_b.get("terms") or [])
4203
+ for _k in ("accept", "reject", "defer", "blindspot"):
4204
+ _bnd["counts"][_k] += int(
4205
+ (_jw.get("counts") or {}).get(_k) or 0)
3752
4206
  else:
3753
4207
  qual = {"state": None, "reason": "judge_disabled"}
3754
4208
  out.append((r[0], r[1], qual))
4209
+ # 边界读数只在**确有边界命中**时落键(与 S1/S2/S5「产生信息才落键」同规则)
4210
+ if _bnd["hit"]:
4211
+ stat["boundary"] = _bnd
3755
4212
  # 负覆盖提示条目**追加在尾部**(不是首条——旧注释「(首条)」与代码相反):
3756
4213
  # 它们在 `_neg_tail` 里已按 k 预算建好(①分数哨兵 ②计入 k ④位置=尾)。
3757
4214
  out.extend(_neg_tail)
@@ -3792,6 +4249,10 @@ class MdCG:
3792
4249
  # 分阶段审计仅在门控真的产生信息时落键(默认关闭 → meta 与改动前逐字节一致;契约 §5.3)
3793
4250
  if stat.get("gates"):
3794
4251
  meta["gates"] = stat["gates"]
4252
+ # P2-2(§5.2):拒绝域命中的**独立读数**(与资格 REJECT 分开计数)——
4253
+ # 只在确有边界命中时落键(无边界命中的路径 meta 键集合与改动前一致)。
4254
+ if stat.get("boundary"):
4255
+ meta["boundary"] = stat["boundary"]
3795
4256
  # 负覆盖尾审计(H10①②):仅在**确有提示条目入榜**时落键(无覆盖的路径
3796
4257
  # meta 键集合与改动前逐字节一致)。下游可据此把「提示」与「答案」分区:
3797
4258
  # 尾条目的负性判据是 card/qual 的 `negative_coverage`,本块是同一事实的
@@ -3944,12 +4405,28 @@ class MdCG:
3944
4405
  and os.path.exists(old_full)):
3945
4406
  os.remove(old_full)
3946
4407
  rel = os.path.relpath(new_path, self.root).replace("\\", "/")
4408
+ # N137(2026-10-01):本条 _stage 条目此前缺 edges / subgraph / 双时间轴
4409
+ # (trust.FROM/UNTIL/EFFECTIVE_FROM/EFFECTIVE_UNTIL)四组键——搬迁 =
4410
+ # 「删除源层条目 + 写入目标层条目」,缺键即让目标层条目**静默退化**:
4411
+ # ① 新检索路(P3-causal 的 chain 路走 index 的 edges 建邻接、P3-temporal
4412
+ # 走索引标量)搬迁后对它即不可达(节点还在,边/时刻没了);
4413
+ # ② trust.validity 的时效过滤免读盘判定失效(valid_from/until 不在快照)。
4414
+ # 写入口 `_node_entry`(_scan_nodes / rebuild 共用)与 `add()` 的 _stage
4415
+ # 条目都带这些键——本条与之对齐,杜绝「重建后有、搬迁后没有」的形态漂移。
3947
4416
  self._stage(node_id, _strip_empty_gate_fields({
3948
4417
  "path": rel, "layer": to_layer, "tags": fm.get("tags", []),
3949
4418
  "bucket": None, "importance": fm.get("importance", 0.5),
3950
4419
  "created_at": fm.get("created_at", 0),
3951
4420
  "verification_basis": fm.get("verification_basis"),
3952
4421
  "has_neg_conditions": nodefile.has_non_applicable(node["content"]),
4422
+ # P2-1 / P4-2①(与 `_node_entry` / `add()` 同口径;N137 的教训:
4423
+ # 搬迁条目缺键 = 新检索路对搬迁后的节点静默不可达)
4424
+ nodefile.POSTCONDITION_TERMS_KEY: nodefile.ccg_element_terms(
4425
+ node["content"], nodefile.POSTCONDITION_FIELD),
4426
+ nodefile.REJECTION_TERMS_KEY: rejection_index_terms(
4427
+ fm, node["content"]),
4428
+ "access_count": fm.get("access_count", 0),
4429
+ "last_access": fm.get("last_access", 0),
3953
4430
  "content_hash": hashlib.sha256(
3954
4431
  node["content"].encode("utf-8")).hexdigest()[:12],
3955
4432
  "temporal": fm.get("temporal"), "spatial": fm.get("spatial"),
@@ -3957,6 +4434,16 @@ class MdCG:
3957
4434
  "big_domain": fm.get("big_domain"),
3958
4435
  "observation_position": (fm.get("condition_space") or {}).get("observation_position"),
3959
4436
  "evidence_count": fm.get("evidence_count", 0),
4437
+ # N137:边域与嵌套子图(chain 路 / 递归展开的免读盘来源)
4438
+ "edges": fm.get("edges") or [],
4439
+ "subgraph": fm.get("subgraph"),
4440
+ # N137:双时间轴(trust.validity 的免读盘判定面,与 add/_node_entry 同口径)
4441
+ trust.STATE_FIELD: fm.get(trust.STATE_FIELD),
4442
+ trust.DEPS_FIELD: fm.get(trust.DEPS_FIELD),
4443
+ trust.FROM_FIELD: fm.get(trust.FROM_FIELD),
4444
+ trust.UNTIL_FIELD: fm.get(trust.UNTIL_FIELD),
4445
+ trust.EFFECTIVE_FROM_FIELD: fm.get(trust.EFFECTIVE_FROM_FIELD),
4446
+ trust.EFFECTIVE_UNTIL_FIELD: fm.get(trust.EFFECTIVE_UNTIL_FIELD),
3960
4447
  }))
3961
4448
  self.index["buckets"] = self._count_buckets(self.index["nodes"])
3962
4449
  return {"id": node_id, "from": from_layer, "to": to_layer,