@furongjun1999/dsh-memory 0.6.1 → 0.7.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.
- package/README.md +49 -18
- package/docs/README.md +1 -0
- package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +223 -0
- package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.1.md +340 -0
- package/docs/eval/issue50_/345/205/203/346/225/260/346/215/256/351/200/217/344/274/240/344/270/216/345/205/234/345/272/225_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +437 -0
- package/docs/eval/issue50_/345/215/212/351/207/215/345/244/215/345/276/205/345/256/232/345/244/215/346/240/270_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +488 -0
- package/docs/eval/issue50_/345/276/205/345/256/232/345/244/215/346/240/270/345/205/245/351/230/237_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +428 -0
- package/docs/eval/issue50_/350/257/273/351/235/242/344/277/235/346/212/244/345/217/252/350/256/244/346/230/276/345/274/217/346/235/245/346/272/220_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +271 -0
- package/docs/eval/issue50_/351/207/215/350/246/201/345/272/246/345/220/214/346/272/220/344/270/216/344/277/235/346/212/244/350/257/255/344/271/211_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +306 -0
- package/docs/eval/issue51_/344/270/200/351/224/256/345/256/211/350/243/205/345/244/261/350/264/245_/345/275/222/345/261/236/345/210/244/345/256/232_v1.0.md +58 -0
- package/docs/eval/issue52_/346/235/241/344/273/266/345/205/210/350/241/214/344/270/216/346/210/252/346/226/255/345/217/257/350/247/202/346/265/213_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +360 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/241/346/241/243/344/275/215/345/215/225/344/270/200/345/205/245/345/217/243_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +651 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/242/345/217/230/346/233/264/345/215/225/344/270/216/345/233/236/346/273/232/345/216/237/350/257/255_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +439 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/243/345/207/206/345/205/245/350/257/273/346/225/260_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1337 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/244/346/224/266/345/256/230/344/270/216/345/205/250/351/223/276/351/252/214/346/224/266_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1817 -0
- 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
- 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
- package/docs/eval//345/217/221/345/270/20309_/346/243/200/347/264/242/351/235/242/344/270/211/346/211/271/346/224/266/345/217/243_v1.0.md +66 -0
- 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
- package/docs/eval//347/254/2543/345/261/202stg/347/273/223/346/236/204/347/264/242/345/274/225_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +250 -0
- 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
- 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
- package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +88 -0
- 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
- package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +47 -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
- package/docs/plans/stg/346/235/241/344/273/266/345/214/226/344/270/216/347/273/223/346/236/204/347/264/242/345/274/225_/350/256/276/350/256/241_v0.1.md +124 -0
- 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
- package/docs/plans//350/256/260/345/277/206/350/207/252/345/244/204/347/220/206/344/270/211/346/241/243/350/207/252/346/262/273_/350/256/276/350/256/241_v0.2.md +207 -0
- package/md_cg/admission.py +718 -0
- package/md_cg/autonomy_modes.py +642 -0
- package/md_cg/bench_e2e_locomo_qa.py +11 -4
- package/md_cg/chain.py +47 -0
- package/md_cg/consistency.py +133 -8
- package/md_cg/forgetting.py +38 -6
- package/md_cg/freshness.py +527 -0
- package/md_cg/generation.py +409 -0
- package/md_cg/hotcache.py +4 -1
- package/md_cg/lifecycle.py +30 -2
- package/md_cg/mcp_server.py +191 -26
- package/md_cg/mdcg.py +594 -39
- package/md_cg/mdcos.py +869 -38
- package/md_cg/nodefile.py +74 -1
- package/md_cg/protect.py +79 -4
- package/md_cg/provenance.py +1 -1
- package/md_cg/review_cli.py +31 -2
- package/md_cg/rollback.py +411 -0
- package/md_cg/rollback_cli.py +104 -0
- package/md_cg/semantic/canonical.py +22 -0
- package/md_cg/semantic/unify.py +73 -22
- package/md_cg/semantic/unify_fixture.json +25 -0
- package/md_cg/sleep.py +1297 -0
- package/md_cg/stg.py +400 -50
- package/md_cg/stgidx.py +281 -0
- package/md_cg/sustain.py +146 -9
- package/md_cg/test_auto_defaults.py +424 -0
- package/md_cg/test_autonomy_admission.py +1098 -0
- package/md_cg/test_autonomy_modes.py +1972 -0
- package/md_cg/test_b1_auto_id_multiproc.py +7 -0
- package/md_cg/test_b1b2_write_face.py +7 -0
- package/md_cg/test_b3_merge_keeps_content.py +7 -0
- package/md_cg/test_boundary_hit.py +410 -0
- package/md_cg/test_cons200_scan_selection.py +791 -0
- package/md_cg/test_en_pipeline.py +22 -13
- package/md_cg/test_generation_guard.py +352 -0
- package/md_cg/test_h4_sustain_snapshot.py +14 -4
- package/md_cg/test_i50a_half_dup_defer.py +408 -0
- package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
- package/md_cg/test_i50c_meta_passthrough.py +608 -0
- package/md_cg/test_i50d_importance_source.py +531 -0
- package/md_cg/test_i50e_readside_protection.py +656 -0
- package/md_cg/test_issue39_utf8_stdio.py +9 -1
- package/md_cg/test_issue52_scan_condition_first.py +677 -0
- package/md_cg/test_linkref.py +7 -0
- package/md_cg/test_mode_parity.py +1393 -0
- package/md_cg/test_mutation_rollback.py +805 -0
- package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
- package/md_cg/test_n212_n213_n224_generation_gates.py +14 -8
- package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
- package/md_cg/test_n230_dirty_replay.py +375 -0
- package/md_cg/test_p2_mcp.py +8 -0
- package/md_cg/test_p2_six_elements.py +463 -0
- package/md_cg/test_p3_legacy_closure.py +433 -0
- package/md_cg/test_p4_freshness.py +680 -0
- package/md_cg/test_p8_subgraph_chain.py +14 -1
- package/md_cg/test_p9_forget_protect.py +7 -0
- package/md_cg/test_p9c_dedup_hints.py +7 -0
- package/md_cg/test_policy_required_ccg.py +5 -1
- package/md_cg/test_protocol.py +7 -0
- package/md_cg/test_rank_parity_score_mode.py +6 -0
- package/md_cg/test_semantic_canonical.py +5 -3
- package/md_cg/test_sleep.py +611 -0
- package/md_cg/test_sleep_p1.py +784 -0
- package/md_cg/test_stgidx_index_parity.py +1020 -0
- package/md_cg/test_time_core_lint.py +968 -0
- package/md_cg/test_unify_default_off.py +701 -0
- package/md_cg/test_unify_scope.py +182 -0
- package/md_cg/test_writelimit.py +36 -9
- package/md_cg/test_writepipe.py +5 -2
- package/md_cg/weights.py +15 -1
- package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
- package/md_cg/writepipe.py +101 -2
- package/package.json +3 -2
- package/skills/plugin.json +1 -1
- 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
|
|
284
|
-
的 `unify_query`
|
|
285
|
-
2026-09-
|
|
286
|
-
|
|
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
|
|
@@ -1183,6 +1451,12 @@ class MdCG:
|
|
|
1183
1451
|
# (自身写入已由 _stage 落进内存索引,计入签名会让每次自身 flush 都被
|
|
1184
1452
|
# 判成「他进程变化」→ 自身写入触发全量自重载)。flush() 里登记。
|
|
1185
1453
|
self._own_shard = None
|
|
1454
|
+
# 第 3 层结构索引(设计稿 §3.2):装载期内存倒排,**首次 stg 访问时**才构建
|
|
1455
|
+
# (惰性);写路径经 `_set_index_entry`/`_remove_index_entry` 同钩增量维护,
|
|
1456
|
+
# 整体换索引的路径(重载/compact/rebuild)经 `_invalidate_stg_index` 失效后
|
|
1457
|
+
# 由下次访问重建。占用面:1.7 万节点 ≈ 三张小表 + 位序/分类两表(内存派生,
|
|
1458
|
+
# 不改磁盘格式)。
|
|
1459
|
+
self._stg_index = None
|
|
1186
1460
|
self.index = self._load_index()
|
|
1187
1461
|
# P1b-2(2026-09-26,DSH 在役复验):索引只在本行装载一次,此后
|
|
1188
1462
|
# read/get/search 全走内存态——其它进程(review_cli autoflush=1)
|
|
@@ -1324,7 +1598,37 @@ class MdCG:
|
|
|
1324
1598
|
parts.append((fn, s.st_size, s.st_mtime_ns))
|
|
1325
1599
|
return (snap, tuple(parts))
|
|
1326
1600
|
|
|
1327
|
-
# 生效条件:
|
|
1601
|
+
# 生效条件:self._dirty.staged_stat 中无该 nid 的见证、或 self._dirty[nid] 非含非空 path 的 dict 时返回 False;有见证时对 root/path 再 stat 一次——OSError(文件已不在,标脏时在)返回 True,成功则返回 (st_mtime_ns, st_size) 与见证不等的布尔(不等即 True)。
|
|
1602
|
+
def _dirty_entry_superseded(self, nid) -> bool:
|
|
1603
|
+
"""本实例 `_dirty[nid]` 是否**已被盘面更新盖过**(N230:旧不得盖新)。
|
|
1604
|
+
|
|
1605
|
+
判据是**节点文件的逐字见证**:标脏那一刻记下 `(st_mtime_ns, st_size)`,
|
|
1606
|
+
重放前再 stat 一次;不相等 ⇒ 盘面已被他进程改写过 ⇒ 本实例这条是旧的,
|
|
1607
|
+
重放必须放行盘面(`_maybe_reload_index` 据此跳过本条)。
|
|
1608
|
+
|
|
1609
|
+
三条边界(都是有意的):
|
|
1610
|
+
· **证不出陈旧就不当陈旧**:无见证(无 path / stat 失败 / `_dirty` 未挂
|
|
1611
|
+
root)返回 False ⇒ 照旧重放——「本实例未落盘写入不因重载从检索面消失」
|
|
1612
|
+
这条既有不变量优先于本缺陷的覆盖面。
|
|
1613
|
+
· **tombstone(`None`)不走本判据**:`_unstage` 立即 flush(删除不延迟到
|
|
1614
|
+
autoflush 阈值),故 tombstone 实际上极少跨重载存活;其语义一字不改。
|
|
1615
|
+
· **同内容改写**:他进程用同样的字节重写(size 同)时 mtime_ns 仍会变,
|
|
1616
|
+
故仍判陈旧——放行的盘面条目与本条同义,结果无差。
|
|
1617
|
+
"""
|
|
1618
|
+
st = self._dirty.staged_stat.get(nid)
|
|
1619
|
+
if st is None:
|
|
1620
|
+
return False
|
|
1621
|
+
e = self._dirty.get(nid)
|
|
1622
|
+
p = e.get("path") if isinstance(e, dict) else None
|
|
1623
|
+
if not p:
|
|
1624
|
+
return False
|
|
1625
|
+
try:
|
|
1626
|
+
cur = os.stat(os.path.join(self.root, p))
|
|
1627
|
+
except OSError:
|
|
1628
|
+
return True # 标脏时文件在、现在不在 ⇒ 盘面确已变
|
|
1629
|
+
return (cur.st_mtime_ns, cur.st_size) != st
|
|
1630
|
+
|
|
1631
|
+
# 生效条件: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
1632
|
def _maybe_reload_index(self):
|
|
1329
1633
|
"""读路径入口的索引代际感知(P1b-2):签名变化才重载。
|
|
1330
1634
|
|
|
@@ -1340,6 +1644,14 @@ class MdCG:
|
|
|
1340
1644
|
(flush)都改变签名 → 此时重载并重放分片日志(_load_index 既有行为),
|
|
1341
1645
|
写入可见;本实例 _dirty 未 flush 的条目在重载后**重放回内存索引**——
|
|
1342
1646
|
_stage 双写 index+_dirty 的「检索看得到未落盘写入」语义保持。
|
|
1647
|
+
|
|
1648
|
+
N230(2026-10-01,**旧盖新**):原实现无条件 `idx["nodes"][nid] = e`——
|
|
1649
|
+
本实例标脏后**他进程重写过同一节点**时,重载取回的是更新记录,随即被
|
|
1650
|
+
本地这条更旧的盖了回去(实测:盘面与新建读者实例都是 NEW,本实例索引
|
|
1651
|
+
仍停在 OLD,`content_hash` 与盘面撕裂)。现按「**逐字见证**」判陈旧:
|
|
1652
|
+
`_dirty_entry_superseded` 为真即**放行盘面**(跳过本条重放),并在
|
|
1653
|
+
`self._dirty_replay_superseded` 留下条数读数。见证拿不到的条目照旧重放
|
|
1654
|
+
——「本实例未落盘写入不因重载从检索侧消失」这条不变量一并保留。
|
|
1343
1655
|
"""
|
|
1344
1656
|
sig = self._index_signature()
|
|
1345
1657
|
if sig == self._index_sig:
|
|
@@ -1348,13 +1660,27 @@ class MdCG:
|
|
|
1348
1660
|
idx = self._load_index()
|
|
1349
1661
|
except (OSError, ValueError):
|
|
1350
1662
|
return False # 重载失败不阻塞读:沿用旧内存态
|
|
1663
|
+
superseded = 0
|
|
1351
1664
|
for nid, e in self._dirty.items():
|
|
1352
1665
|
if e is None:
|
|
1353
1666
|
idx["nodes"].pop(nid, None)
|
|
1354
|
-
|
|
1355
|
-
|
|
1667
|
+
continue
|
|
1668
|
+
# N230(2026-10-01):**新记录必须能盖住旧记录,旧不得盖新**。
|
|
1669
|
+
# 重载后的 idx 来自快照(指纹校验过盘面)或全库扫描,两者都反映
|
|
1670
|
+
# **当前盘面**;本实例这条 `_dirty` 若在标脏后盘面已被改过(逐字
|
|
1671
|
+
# 见证失配),它就是旧的——放行盘面,不得盖回去。见证拿不到时
|
|
1672
|
+
# 照旧重放(见 _note_witness 的「证不出陈旧就不当陈旧」)。
|
|
1673
|
+
if self._dirty_entry_superseded(nid):
|
|
1674
|
+
superseded += 1
|
|
1675
|
+
continue
|
|
1676
|
+
idx["nodes"][nid] = e
|
|
1677
|
+
self._dirty_replay_superseded = superseded
|
|
1356
1678
|
idx["buckets"] = self._count_buckets(idx["nodes"])
|
|
1357
1679
|
self.index = idx
|
|
1680
|
+
# 第 3 层结构索引随整体换索引一并失效:重载取回的是**另一份**快照
|
|
1681
|
+
# (他进程写的新记录/新盘面),旧表按身份判代际必然不符——显式丢表,
|
|
1682
|
+
# 下次 stg 访问按新快照重建(惰性失效重建,与 rebuild/compact 同路)。
|
|
1683
|
+
self._invalidate_stg_index()
|
|
1358
1684
|
self._index_sig = sig
|
|
1359
1685
|
# 旧索引代里的派生缓存一并失效:query 结果缓存(hotcache,默认关)
|
|
1360
1686
|
# 缓存的是旧候选池上的结果;解析读缓存(readcache,默认开)按 path
|
|
@@ -1469,13 +1795,20 @@ class MdCG:
|
|
|
1469
1795
|
atomic_write(self.index_path, json.dumps(idx, ensure_ascii=False))
|
|
1470
1796
|
ShardedLog.clear(self.index_log_dir)
|
|
1471
1797
|
self.index = idx
|
|
1798
|
+
# 第 3 层结构索引随快照替换失效(快照重建过,旧表与它的划分关系不再
|
|
1799
|
+
# 有据可依):下次 stg 访问重建;不在此处立即重算——compact 是收尾路
|
|
1800
|
+
# 径,重建成本挪到真正需要索引的那次读(惰性失效重建,同一选)。
|
|
1801
|
+
self._invalidate_stg_index()
|
|
1472
1802
|
# P1b-2:自己写了快照 → 主动刷新签名,防止后续读路径把自己的
|
|
1473
1803
|
# compact 误判为「他进程写入」而做一次无谓重载(自写自载循环)。
|
|
1474
1804
|
self._index_sig = self._index_signature()
|
|
1475
1805
|
return idx
|
|
1476
1806
|
|
|
1477
|
-
# 生效条件:self._dirty 非空时才在 FileLock(index_path) 下(必要时新建 ShardedLog)逐条 append、清空 _dirty 并关闭分片句柄;self._dirty
|
|
1807
|
+
# 生效条件:self._dirty 非空时才在 FileLock(index_path) 下(必要时新建 ShardedLog)逐条 append、清空 _dirty 并关闭分片句柄;self._dirty 为空时立即返回、不写任何记录;**不动 self.index、不失效第 3 层结构索引**——flush 只落索引派生物(分片日志),内存快照与其划分表本就不变(「flush 后表与快照一致」由守卫钉住);
|
|
1478
1808
|
def flush(self):
|
|
1809
|
+
# 第 3 层结构索引(stgidx):flush **不失效**——它不改内存索引(记录早在
|
|
1810
|
+
# `_stage` 时就已同钩进表),若每次 flush 都丢表,惰性重建会在写读交替
|
|
1811
|
+
# 负载下每轮重扫(与 readcache「写谁失效谁」同款纪律)。
|
|
1479
1812
|
if not self._dirty:
|
|
1480
1813
|
return
|
|
1481
1814
|
# 世代/互斥防线(批次9,M3.3 落地):append 必须与 compact/rebuild 的
|
|
@@ -1527,7 +1860,7 @@ class MdCG:
|
|
|
1527
1860
|
self._log.close()
|
|
1528
1861
|
self._log = None
|
|
1529
1862
|
|
|
1530
|
-
# 生效条件:以节点文件绝对路径 p、其所在层目录名 layer、解析出的 fm 与 content 为入参,构造含 path(相对 root 正斜杠)/layer(fm 回落 layer)/role/content_kind/session/tags/bucket(父目录名,等于层名时 None)/bucket_zh/importance/created_at/verification_basis/has_neg_conditions/content_hash/temporal/spatial/time_window/lifecycle 与 trust 状态字段/branch_id/branched_from/evidence_count/big_domain/observation_position/subgraph/edges/protected/protection_reason/immutable/self_state/derived_from/derived_relation 各键的条目,经 _strip_empty_gate_fields 清洗后返回;
|
|
1863
|
+
# 生效条件:以节点文件绝对路径 p、其所在层目录名 layer、解析出的 fm 与 content 为入参,构造含 path(相对 root 正斜杠)/layer(fm 回落 layer)/role/content_kind/session/tags/bucket(父目录名,等于层名时 None)/bucket_zh/importance/importance_source(issue50-d:fm 键缺省 None)/created_at/verification_basis/has_neg_conditions/content_hash/temporal/spatial/time_window/lifecycle 与 trust 状态字段/branch_id/branched_from/evidence_count/big_domain/observation_position/subgraph/edges/protected/protection_reason/immutable/self_state/derived_from/derived_relation 各键的条目,经 _strip_empty_gate_fields 清洗后返回;
|
|
1531
1864
|
def _node_entry(self, p, layer, fm, content):
|
|
1532
1865
|
"""单节点索引条目——_scan_nodes 与定向 upsert 共用的**唯一真源**。
|
|
1533
1866
|
|
|
@@ -1559,9 +1892,27 @@ class MdCG:
|
|
|
1559
1892
|
# S1b 跨语言收敛免读文件可判。
|
|
1560
1893
|
"bucket_zh": fm.get("bucket_zh") or None,
|
|
1561
1894
|
"importance": fm.get("importance", 0.5),
|
|
1895
|
+
# issue50-d(2026-10-02):重要度**来源**入快照("hint"|"heuristic",
|
|
1896
|
+
# 旧库无此键 → None)——「这条 0.8 是显式声明还是启发式分」免读
|
|
1897
|
+
# 文件可判(与既有扁平键同理由:检索期/审计期不读盘)。
|
|
1898
|
+
"importance_source": fm.get("importance_source"),
|
|
1562
1899
|
"created_at": fm.get("created_at", 0),
|
|
1563
1900
|
"verification_basis": fm.get("verification_basis"),
|
|
1564
1901
|
"has_neg_conditions": nodefile.has_non_applicable(content),
|
|
1902
|
+
# ── P2-1(§5.1 六要素 6 行):后两行的**索引键**入快照 ──────────
|
|
1903
|
+
# 验证方式 → 后置条件词;不适用条件 → 拒绝域词。与既有
|
|
1904
|
+
# `time_window`/`observation_position` 同为**免读文件可判的扁指标量**
|
|
1905
|
+
# (同款理由:检索期不读盘;§5.4「全进默认检索」的取数面即此)。
|
|
1906
|
+
# 取值一律走 `nodefile` 的 CCG 行解析单点(检索面不另写正则)。
|
|
1907
|
+
nodefile.POSTCONDITION_TERMS_KEY:
|
|
1908
|
+
nodefile.ccg_element_terms(content, nodefile.POSTCONDITION_FIELD),
|
|
1909
|
+
nodefile.REJECTION_TERMS_KEY: rejection_index_terms(fm, content),
|
|
1910
|
+
# ── P4-2①(§7.2 卡点一):访问计数入快照 ────────────────────────
|
|
1911
|
+
# `access_count`/`last_access` 此前只记账(`_access.log` → 折叠落
|
|
1912
|
+
# frontmatter),检索想用只能读盘(违反「检索不写少读」)——故与
|
|
1913
|
+
# 上面两行同款:免读文件的扁指标量,供 `_score` 的刷新乘子取数。
|
|
1914
|
+
"access_count": fm.get("access_count", 0),
|
|
1915
|
+
"last_access": fm.get("last_access", 0),
|
|
1565
1916
|
# 内容指纹走 nodefile 的唯一实现(两段式对账依赖同一算法)
|
|
1566
1917
|
"content_hash": nodefile.content_hash(content),
|
|
1567
1918
|
# 时空字段入索引快照:STG 查询免读文件(大域/目录索引的延伸)
|
|
@@ -1649,6 +2000,10 @@ class MdCG:
|
|
|
1649
2000
|
atomic_write(self.index_path, json.dumps(idx, ensure_ascii=False))
|
|
1650
2001
|
ShardedLog.clear(self.index_log_dir)
|
|
1651
2002
|
self.index = idx
|
|
2003
|
+
# 第 3 层结构索引随全量重建失效(新快照 = 新盘面语义):下次 stg 访问
|
|
2004
|
+
# 重建——重建后的划分不变量由守卫钉住(写/删/rebuild/compact 后三表与
|
|
2005
|
+
# 快照一致)。
|
|
2006
|
+
self._invalidate_stg_index()
|
|
1652
2007
|
# P1b-2:rebuild 同 compact——写完快照即刷新签名(自写不自载)。
|
|
1653
2008
|
self._index_sig = self._index_signature()
|
|
1654
2009
|
# broad=True:rebuild 以盘面扫描为准——ccgc/crosscheck/backfill 等
|
|
@@ -1716,7 +2071,8 @@ class MdCG:
|
|
|
1716
2071
|
derived_from=None, relation: str = provenance.DEFAULT_RELATION,
|
|
1717
2072
|
semantic: str = None, depends_on=None, valid_from=None,
|
|
1718
2073
|
valid_until=None, effective_from=None, effective_until=None,
|
|
1719
|
-
believed_at=None, verification_state: str = None,
|
|
2074
|
+
believed_at=None, verification_state: str = None,
|
|
2075
|
+
importance_source: str = None, **extra) -> str:
|
|
1720
2076
|
"""写入一个节点。
|
|
1721
2077
|
|
|
1722
2078
|
verification_basis: 外部验证基底(白箱信任的硬门槛),
|
|
@@ -1906,6 +2262,11 @@ class MdCG:
|
|
|
1906
2262
|
"evidence_count": 0, "positive_evidence": 0, "negative_evidence": 0,
|
|
1907
2263
|
}
|
|
1908
2264
|
fm.update(extra)
|
|
2265
|
+
# issue50-d(2026-10-02):重要度**来源**声明("hint"|"heuristic")落
|
|
2266
|
+
# frontmatter——下游免读文件即可判「这条 0.8 是显式声明还是闸门启发式
|
|
2267
|
+
# 分」。缺省 None 不落键(既有调用方 fm 形态逐位不变,零回归)。
|
|
2268
|
+
if importance_source:
|
|
2269
|
+
fm["importance_source"] = importance_source
|
|
1909
2270
|
if bucket_zh:
|
|
1910
2271
|
fm["bucket_zh"] = bucket_zh
|
|
1911
2272
|
# S1 大域先验:写入时固化「内容 → 大域」(契约 §3 S1)。
|
|
@@ -2105,8 +2466,14 @@ class MdCG:
|
|
|
2105
2466
|
fm["derived_from"] = parents
|
|
2106
2467
|
fm["derived_relation"] = derived_rel
|
|
2107
2468
|
# 重要性 ≥0.7 自动打保护标记(对齐 tool_table:≥0.7 触发不可遗忘保护)
|
|
2469
|
+
# issue50-d(2026-10-02):自动保护只认**显式声明**——调用方随 add
|
|
2470
|
+
# 下传 importance_source 时,"heuristic"(闸门启发式分)过线**只落分、
|
|
2471
|
+
# 不打位**(remember_gated 的裁决面文案已如实说「未落保护」);
|
|
2472
|
+
# importance_source 缺省 None(add 的全部既有调用方都不传)⇒ 本条件
|
|
2473
|
+
# 恒真、既有行为逐位不变,零回归由构造保证。
|
|
2108
2474
|
if (float(importance or 0.0) >= protect.AUTO_PROTECT_IMPORTANCE
|
|
2109
|
-
and not fm.get("protected")
|
|
2475
|
+
and not fm.get("protected")
|
|
2476
|
+
and importance_source != "heuristic"):
|
|
2110
2477
|
fm["protected"] = True
|
|
2111
2478
|
fm["protection_reason"] = (f"importance={float(importance):.2f}"
|
|
2112
2479
|
f"≥{protect.AUTO_PROTECT_IMPORTANCE}")
|
|
@@ -2143,8 +2510,20 @@ class MdCG:
|
|
|
2143
2510
|
"layer": layer, "tags": tags, "bucket": bucket,
|
|
2144
2511
|
"bucket_zh": bucket_zh or None,
|
|
2145
2512
|
"importance": importance, "created_at": fm["created_at"],
|
|
2513
|
+
# issue50-d:重要度来源入快照(与 _node_entry 同口径——写入后即可
|
|
2514
|
+
# 免读文件判「0.8 是显式 hint 还是启发式」,不等全量重建)。
|
|
2515
|
+
"importance_source": fm.get("importance_source"),
|
|
2146
2516
|
"verification_basis": verification_basis,
|
|
2147
2517
|
"has_neg_conditions": nodefile.has_non_applicable(sealed),
|
|
2518
|
+
# P2-1 / P4-2①:与 `_node_entry`(重建路径)**同口径**——写路径的
|
|
2519
|
+
# 定向 upsert 若漏这两组键,就得等下一次全量重建才补上(N137 同款
|
|
2520
|
+
# 「重建后有、写入后没有」的形态漂移)。
|
|
2521
|
+
nodefile.POSTCONDITION_TERMS_KEY:
|
|
2522
|
+
nodefile.ccg_element_terms(sealed, nodefile.POSTCONDITION_FIELD),
|
|
2523
|
+
nodefile.REJECTION_TERMS_KEY:
|
|
2524
|
+
rejection_index_terms(fm, sealed),
|
|
2525
|
+
"access_count": fm.get("access_count", 0),
|
|
2526
|
+
"last_access": fm.get("last_access", 0),
|
|
2148
2527
|
"content_hash": hashlib.sha256(sealed.encode("utf-8")).hexdigest()[:12],
|
|
2149
2528
|
"temporal": fm.get("temporal"),
|
|
2150
2529
|
"spatial": fm.get("spatial"),
|
|
@@ -2634,26 +3013,33 @@ class MdCG:
|
|
|
2634
3013
|
atomic_write(self.recent_log, "")
|
|
2635
3014
|
return len(recs)
|
|
2636
3015
|
|
|
2637
|
-
# 生效条件:无条件把 entry
|
|
3016
|
+
# 生效条件:无条件把 entry 记进 _dirty[node_id] 并经 _set_index_entry 写进 index["nodes"][node_id](桶计数与 stg 结构索引同钩,见该函数);当 len(self._dirty) >= self.autoflush 时调 flush()(autoflush 为 0 时每次标脏都立即 flush);
|
|
2638
3017
|
def _stage(self, node_id, entry):
|
|
2639
3018
|
self._dirty[node_id] = entry
|
|
2640
|
-
self.
|
|
2641
|
-
if entry.get("bucket"):
|
|
2642
|
-
self.index["buckets"][entry["bucket"]] = \
|
|
2643
|
-
self.index["buckets"].get(entry["bucket"], 0) + 1
|
|
3019
|
+
self._set_index_entry(node_id, entry)
|
|
2644
3020
|
if len(self._dirty) >= self.autoflush:
|
|
2645
3021
|
self.flush()
|
|
2646
3022
|
|
|
2647
|
-
#
|
|
2648
|
-
def
|
|
2649
|
-
"""
|
|
3023
|
+
# 生效条件:无条件把 entry 写入 index["nodes"][node_id](同键覆写 = dict 原地更新,物理位序不变);entry.get("bucket") 为真值时该桶计数 +1;self._stg_index 非 None(第 3 层结构索引已构建)时同钩增量增/改该 id,为 None(未构建/已失效)时**不构建**——首次 stg 访问才构建,写路径零额外成本;返回 None。
|
|
3024
|
+
def _set_index_entry(self, node_id, entry):
|
|
3025
|
+
"""索引条目写入的**单点**(桶计数 + stg 结构索引同钩)。
|
|
2650
3026
|
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
`
|
|
2654
|
-
|
|
2655
|
-
而够不着它,`check` 的 dangling 永不归零(本机实测累积数百条)。
|
|
3027
|
+
「同钩」是设计稿 §3.2 的字面要求:写路径的增量维护若与桶计数分家,
|
|
3028
|
+
两处口径必然漂开(本仓既有教训:同一判据写两份 = 迟早各漏一次)。
|
|
3029
|
+
无效化(rebuild/compact/重载)后 `_stg_index` 为 None ⇒ 这里退化为
|
|
3030
|
+
无操作,读侧「首次需要时构建」的惰性路径接管。
|
|
2656
3031
|
"""
|
|
3032
|
+
self.index["nodes"][node_id] = entry
|
|
3033
|
+
b = entry.get("bucket")
|
|
3034
|
+
if b:
|
|
3035
|
+
self.index["buckets"][b] = self.index["buckets"].get(b, 0) + 1
|
|
3036
|
+
ix = getattr(self, "_stg_index", None)
|
|
3037
|
+
if ix is not None:
|
|
3038
|
+
ix.add(node_id, entry)
|
|
3039
|
+
|
|
3040
|
+
# 生效条件:无条件 pop index["nodes"][node_id](不存在则无操作);被 pop 的条目有真值 bucket 时该桶计数 -1,减后 <=0 则删除该桶键;self._stg_index 非 None 时同钩摘除该 id(未构建即不动);返回被摘除的条目(不存在返回 None)。
|
|
3041
|
+
def _remove_index_entry(self, node_id):
|
|
3042
|
+
"""索引条目摘除的**单点**(与 `_set_index_entry` 对称:桶计数 + 结构索引同钩)。"""
|
|
2657
3043
|
e = self.index["nodes"].pop(node_id, None)
|
|
2658
3044
|
if e and e.get("bucket"):
|
|
2659
3045
|
b = e["bucket"]
|
|
@@ -2662,6 +3048,27 @@ class MdCG:
|
|
|
2662
3048
|
self.index["buckets"][b] = left
|
|
2663
3049
|
else:
|
|
2664
3050
|
self.index["buckets"].pop(b, None)
|
|
3051
|
+
ix = getattr(self, "_stg_index", None)
|
|
3052
|
+
if ix is not None:
|
|
3053
|
+
ix.remove(node_id)
|
|
3054
|
+
return e
|
|
3055
|
+
|
|
3056
|
+
# 生效条件:恒把 self._stg_index 置 None(丢弃已构建的第 3 层结构索引)——凡**整体替换 self.index** 的路径(重载/compact/rebuild)调它:表按身份判代际(`StgIndex.index_obj is self.index`),显式失效让下次 stg 访问重新按新快照构建(惰性失效重建,设计稿 §3.2 二选一之选);恒返回 None、不抛。
|
|
3057
|
+
def _invalidate_stg_index(self):
|
|
3058
|
+
"""第 3 层结构索引的失效单点(整体换索引的三处编排点都调它)。"""
|
|
3059
|
+
self._stg_index = None
|
|
3060
|
+
|
|
3061
|
+
# 生效条件:先经 _remove_index_entry 摘除 index["nodes"][node_id](桶计数与 stg 结构索引同钩),随后无论是否命中都把 _dirty[node_id] 置 None(删除 tombstone)并立即调 flush() 持久化;
|
|
3062
|
+
def _unstage(self, node_id):
|
|
3063
|
+
"""摘除索引条目并**持久化**——与 `_stage` 对称的删除原语。
|
|
3064
|
+
|
|
3065
|
+
只 pop 内存索引是不够的:`close()` 注释里记过同构的坑(已有
|
|
3066
|
+
`_index.json` 的根重开不重扫目录),于是「删掉的条目」会在
|
|
3067
|
+
`_index_log` 重放时复活成**幽灵条目**(索引有条目、节点文件不存在)。
|
|
3068
|
+
幽灵条目的代价:检索白跑候选、`ref action=prune` 因 `cg.get` 取不回
|
|
3069
|
+
而够不着它,`check` 的 dangling 永不归零(本机实测累积数百条)。
|
|
3070
|
+
"""
|
|
3071
|
+
self._remove_index_entry(node_id)
|
|
2665
3072
|
self._dirty[node_id] = None # None = 删除记录,随 flush 落分片日志
|
|
2666
3073
|
self.flush() # 删除不可延迟到 autoflush 阈值
|
|
2667
3074
|
|
|
@@ -3031,6 +3438,67 @@ class MdCG:
|
|
|
3031
3438
|
acc = "无情境可比(未做正条件确认)+ " + acc
|
|
3032
3439
|
return {"state": STATE_ACCEPT, "reason": acc}
|
|
3033
3440
|
|
|
3441
|
+
# 生效条件: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)。
|
|
3442
|
+
@staticmethod
|
|
3443
|
+
def judge_with_boundary(node_dict, query: str, context=None) -> dict:
|
|
3444
|
+
"""资格判定 + 拒绝域命中——**分开计数、分开呈现**(§5.2 / P0-4)。
|
|
3445
|
+
|
|
3446
|
+
返回形状(P2 的检索面直接消费)::
|
|
3447
|
+
|
|
3448
|
+
{"qualification": {"state": ..., "reason": ...},
|
|
3449
|
+
"boundary": {"hit": bool, "terms": [...],
|
|
3450
|
+
"marker": "boundary_hit" | None, "reason": str},
|
|
3451
|
+
"counts": {"accept":0|1, "reject":0|1, "defer":0|1,
|
|
3452
|
+
"blindspot":0|1, "boundary_hit":0|1},
|
|
3453
|
+
"recallable": bool, # 与既有候选过滤同口径:state 不属
|
|
3454
|
+
# (REJECT, BLINDSPOT) 即可召回
|
|
3455
|
+
"reason": str}
|
|
3456
|
+
|
|
3457
|
+
**不变量(本判据的核心)**:`boundary.hit` 为真**不构成**资格否定——
|
|
3458
|
+
边界问句下资格判定摘掉命中词面再跑,故「问该节点的拒绝域」不会把该节点
|
|
3459
|
+
打回 REJECT(自否定);非边界问句下资格判定**原样**跑 `judge_qualification`,
|
|
3460
|
+
负条件的灵敏度一字不变(情境里真的命中拒绝域 ⇒ 照旧 REJECT)。
|
|
3461
|
+
|
|
3462
|
+
**已接线(P2-2,2026-10-01)**:本函数与 `boundary_hit` 都**有生产调用方**——
|
|
3463
|
+
|
|
3464
|
+
· `MdCG._emit`(`md_cg/mdcg.py:4181`)=默认打分收口,结果卡带
|
|
3465
|
+
`boundary_hit` 标记 + `stat["boundary"]["counts"]` 分开计数;
|
|
3466
|
+
· `MdCos.search_rrf`(`md_cg/mdcos.py:1754`)=默认召回路径,同款标记与
|
|
3467
|
+
`boundary_counts`。
|
|
3468
|
+
|
|
3469
|
+
两处都调**同一个** `judge_with_boundary`(边界标记一律经同族的
|
|
3470
|
+
`boundary_mark` 取,其体内复用 P0-4 的 `boundary_hit`)——**单点复用、
|
|
3471
|
+
不另写第二份判据**;接线是否在位由 `md_cg/test_boundary_hit.py` 的 B5 组
|
|
3472
|
+
断言钉死(该组在 P2-2 已把 P0-4 的「本轮零接线」断言改判为接线断言)。
|
|
3473
|
+
"""
|
|
3474
|
+
node_dict = node_dict or {}
|
|
3475
|
+
bh = boundary_hit(node_dict, query, context)
|
|
3476
|
+
bq = is_boundary_query(query)
|
|
3477
|
+
if bh["hit"] and bq:
|
|
3478
|
+
qual = dict(MdCG.judge_qualification(
|
|
3479
|
+
node_dict, strip_boundary_terms(query, bh["terms"]), context))
|
|
3480
|
+
qual["boundary_suppressed"] = True
|
|
3481
|
+
else:
|
|
3482
|
+
qual = dict(MdCG.judge_qualification(node_dict, query, context))
|
|
3483
|
+
qual["boundary_suppressed"] = False
|
|
3484
|
+
st = str(qual.get("state") or "")
|
|
3485
|
+
counts = {"accept": int(st == STATE_ACCEPT),
|
|
3486
|
+
"reject": int(st == STATE_REJECT),
|
|
3487
|
+
"defer": int(st == STATE_DEFER),
|
|
3488
|
+
"blindspot": int(st == STATE_BLINDSPOT),
|
|
3489
|
+
"boundary_hit": int(bool(bh["hit"]))}
|
|
3490
|
+
# 可召回与**既有候选过滤同口径**(mdcos 的 `st in (REJECT, BLINDSPOT)`
|
|
3491
|
+
# 即剔除):这两态不进候选,其余(ACCEPT/DEFER)可召回。
|
|
3492
|
+
recallable = st not in (STATE_REJECT, STATE_BLINDSPOT)
|
|
3493
|
+
return {"qualification": {"state": qual.get("state"),
|
|
3494
|
+
"reason": qual.get("reason"),
|
|
3495
|
+
"boundary_suppressed": qual["boundary_suppressed"]},
|
|
3496
|
+
"boundary": {"hit": bh["hit"], "terms": bh["terms"],
|
|
3497
|
+
"marker": bh["marker"], "reason": bh["reason"]},
|
|
3498
|
+
"counts": counts, "recallable": recallable,
|
|
3499
|
+
"reason": ("边界命中(%s)+ 资格 %s" % (bh["marker"], st)
|
|
3500
|
+
if bh["hit"] else "无边界命中 + 资格 %s" % st)}
|
|
3501
|
+
|
|
3034
3502
|
# ---------- 检索(性能阶梯 + 资格判定)----------
|
|
3035
3503
|
|
|
3036
3504
|
# 生效条件:query strip 后为空即返回 ([], {"tier": None, "reason": "empty_query", "scanned": 0});非空时按 T0–T3 性能阶梯取候选(layer / session / branch / validity / view 为假值时对应维度不过滤),judge 为真时对每条结果附独立的四态资格判定;返回 (results, meta);
|
|
@@ -3068,9 +3536,10 @@ class MdCG:
|
|
|
3068
3536
|
ValueError(fail-closed 只针对调用方误用)。
|
|
3069
3537
|
"""
|
|
3070
3538
|
q = (query or "").strip()
|
|
3071
|
-
# 批次 15 统一口径(unify.py
|
|
3072
|
-
#
|
|
3073
|
-
# MDCG_UNIFY_QUERY=
|
|
3539
|
+
# 批次 15 统一口径(unify.py,2026-09-30 收窄作用域):只译**英文内容**
|
|
3540
|
+
# → 标准原子序列,中文段逐字原样(不再整条归一);纯中文/无 ASCII 字母
|
|
3541
|
+
# 原样、归一层**缺省关**(2026-09-30 裁定,显式 MDCG_UNIFY_QUERY=1 才开)、
|
|
3542
|
+
# 失败静默原样。口径真源见 docs/hive/检索算法口径对照_v0.1.md
|
|
3074
3543
|
from .semantic.unify import unify_query
|
|
3075
3544
|
q = unify_query(q)
|
|
3076
3545
|
if not q:
|
|
@@ -3257,7 +3726,9 @@ class MdCG:
|
|
|
3257
3726
|
if in_bucket:
|
|
3258
3727
|
docs = self._read_many(in_bucket, stat)
|
|
3259
3728
|
# 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池
|
|
3260
|
-
hits = [d for d in docs
|
|
3729
|
+
hits = [d for d in docs
|
|
3730
|
+
if self._like(d[2], d[1], terms,
|
|
3731
|
+
index_key_hits(d[0], terms, q))
|
|
3261
3732
|
or (semantic_on() and d[1].get("semantic"))]
|
|
3262
3733
|
out = try_stage(hits, TIER_BUCKET_LIKE)
|
|
3263
3734
|
if out:
|
|
@@ -3283,7 +3754,8 @@ class MdCG:
|
|
|
3283
3754
|
docs_r = self._read_many(reach_entries, stat)
|
|
3284
3755
|
_dif = set(_rstat.get("reach_diffused_paths") or ())
|
|
3285
3756
|
hits_r = [d for d in docs_r
|
|
3286
|
-
if self._like(d[2], d[1], terms
|
|
3757
|
+
if self._like(d[2], d[1], terms,
|
|
3758
|
+
index_key_hits(d[0], terms, q))
|
|
3287
3759
|
or (semantic_on() and d[1].get("semantic"))
|
|
3288
3760
|
or d[0].get("path") in _dif] # 图扩散补召回:无词面命中也放行进打分
|
|
3289
3761
|
stat["pre_cap"] = len(hits_r) # 与 T2 同序:截断**前**的候选数
|
|
@@ -3384,11 +3856,19 @@ class MdCG:
|
|
|
3384
3856
|
_s7_scan0 = stat["scanned"] # S7 窄化前的扫描基线(供 T3 兜底还原口径)
|
|
3385
3857
|
docs_all = self._read_many(entries, stat)
|
|
3386
3858
|
# 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池
|
|
3387
|
-
hits = [d for d in docs_all
|
|
3859
|
+
hits = [d for d in docs_all
|
|
3860
|
+
if self._like(d[2], d[1], terms,
|
|
3861
|
+
index_key_hits(d[0], terms, q))
|
|
3388
3862
|
or (semantic_on() and d[1].get("semantic"))]
|
|
3389
3863
|
# ---- S3 图扩散激活(契约 §3 S3;flag 控,默认关)----
|
|
3390
|
-
#
|
|
3391
|
-
# edges
|
|
3864
|
+
# 为何:审计偏差 1 曾成立——「edges 一直只被写入、检索从不使用」。**该偏差
|
|
3865
|
+
# 已消除(P3-causal,2026-10-01)**:检索侧现有两条路读 edges——①
|
|
3866
|
+
# `search_rrf` 的 `chain` 路(因果路,复用 md_cg/chain.py,按裁定 9 进
|
|
3867
|
+
# 缺省集);② 本层 S3 扩散(`search` 的候选生成段,flag 控、默认关,产物
|
|
3868
|
+
# 是独立 tier TIER_SPREAD)。两者定位不同:chain 是「路内排名项」,
|
|
3869
|
+
# 本层是「新增候选层」(契约 §4.1 的「有意保留的差异」同款划界)。
|
|
3870
|
+
# 本层从词法命中节点沿 edges 双向扩散,把「关联但词面不重叠」的记忆作为
|
|
3871
|
+
# 独立一层候选(TIER_SPREAD)。
|
|
3392
3872
|
# 约束:扩散只走索引里的 edges(不读文件);**不得引入未通过 S1/S2 门控**的节点;
|
|
3393
3873
|
# 命中不足时自然落回原 T2/T3 路径(无召回损失)。
|
|
3394
3874
|
if _s3 and hits:
|
|
@@ -3523,10 +4003,19 @@ class MdCG:
|
|
|
3523
4003
|
return docs
|
|
3524
4004
|
|
|
3525
4005
|
@staticmethod
|
|
3526
|
-
# 生效条件:terms 为空时返回 False;否则任一 t 在 positive_body(content) 的小写串中出现,或该 t 的小写形式出现在 fm 的 tags(tags 取自 fm.get("tags") or []
|
|
3527
|
-
def _like(content, fm, terms):
|
|
4006
|
+
# 生效条件:terms 为空时返回 False;否则任一 t 在 positive_body(content) 的小写串中出现,或该 t 的小写形式出现在 fm 的 tags(tags 取自 fm.get("tags") or [],缺键或假值按空列表拼接)小写串中,或 index_keys 为真值(六要素后两行的索引键命中,见 index_key_hits)即返回 True。
|
|
4007
|
+
def _like(content, fm, terms, index_keys=None):
|
|
3528
4008
|
# 负条件行(`# 不适用条件:`)是反例声明,不作召回键:命中它只应由
|
|
3529
4009
|
# judge_qualification 走 REJECT,不能把节点召回。tags 仍参与匹配。
|
|
4010
|
+
#
|
|
4011
|
+
# P2-3(§5.4「六要素 6 行全进默认检索」):**后两行**(验证方式 /
|
|
4012
|
+
# 不适用条件)的**索引键**(`postcondition_terms`/`rejection_terms`,
|
|
4013
|
+
# 已在索引条目里)在此参与召回。`positive_body` 的立场**不变**——
|
|
4014
|
+
# 拒绝域仍不作普通问句的召回键(`index_key_hits` 只在**边界问句**上
|
|
4015
|
+
# 放开拒绝域召回;见函数说明)。index_keys 为 None(旧调用方)时
|
|
4016
|
+
# 行为与改动前逐位一致。
|
|
4017
|
+
if index_keys and index_keys.get("hit"):
|
|
4018
|
+
return True
|
|
3530
4019
|
tags = " ".join(str(t) for t in (fm.get("tags") or []))
|
|
3531
4020
|
body = nodefile.positive_body(content)
|
|
3532
4021
|
# 双边小写化:英文大小写统一(中文无大小写不受影响)
|
|
@@ -3572,6 +4061,15 @@ class MdCG:
|
|
|
3572
4061
|
if pools: # §七 降权:乘数只来自显式权重表(可复算)
|
|
3573
4062
|
raw = max(0.0, min(1.0, raw * pooling.weight_of(
|
|
3574
4063
|
fm.get("id") or e["path"], e, pools)))
|
|
4064
|
+
# ── P4(§7.1/§7.2):刷新与衰减乘子——**与上一行同一个乘子位** ──
|
|
4065
|
+
# 形态(已裁):score ← score × cred_factor(γ, Δt) × refresh(ac, la)。
|
|
4066
|
+
# 「不新开乘子链」= 就在这里乘,不另起一条对最终 RRF 分做后处理的链。
|
|
4067
|
+
# 取数全来自**索引条目** `e`(`_node_entry` 已落 created_at /
|
|
4068
|
+
# access_count / last_access),检索期**不读盘**(§7.2 卡点一)。
|
|
4069
|
+
# 核走唯一权威 time_core,γ 走 P3 已落的唯一读取点(见 freshness 模块头)。
|
|
4070
|
+
# 开关 `MDCG_FRESHNESS`(缺省开)=0 时乘子恒 1.0(与改动前逐位一致)。
|
|
4071
|
+
if freshness.enabled():
|
|
4072
|
+
raw = raw * freshness.entry_weight(e, fm=fm)[0]
|
|
3575
4073
|
if _boost: # S4:层级加成(最后一步;上限仍夹在 1.0)
|
|
3576
4074
|
raw = min(1.0, raw + _boost.get(str(fm.get("layer") or ""), 0.0))
|
|
3577
4075
|
scored.append(({"id": fm.get("id") or e["path"], "frontmatter": fm,
|
|
@@ -3743,15 +4241,42 @@ class MdCG:
|
|
|
3743
4241
|
stat.setdefault("gates", {})["s6"] = {
|
|
3744
4242
|
"checked": len(_rows6), "by_track": _track6,
|
|
3745
4243
|
"flagged": _flagged6, "rows": _rows6}
|
|
3746
|
-
#
|
|
4244
|
+
# 资格判定(与性能正交)——**P2-2(§5.2)在此接线**:
|
|
4245
|
+
# 走 `MdCG.judge_with_boundary`(P0-4 已落的判据函数,**复用不另写一份**):
|
|
4246
|
+
# · 边界命中(拒绝域)→ 结果卡带 `boundary_hit` 标记(可召回、可展示);
|
|
4247
|
+
# · 该节点的**正例资格不受影响**:边界问句下资格判定先摘掉命中词面再跑,
|
|
4248
|
+
# 故「问它什么时候不适用」不会把它自己打成 REJECT(自否定);
|
|
4249
|
+
# · **分开计数、分开呈现**:`boundary_hit` 与资格 REJECT 各计各的
|
|
4250
|
+
# (stat["boundary"]["counts"],不混成一个数)。
|
|
4251
|
+
# 非边界问句下 judge_with_boundary 内部原样调 judge_qualification,
|
|
4252
|
+
# 故默认口径的 state 与改动前逐位一致(负条件灵敏度一字不动)。
|
|
3747
4253
|
out = []
|
|
4254
|
+
_bnd = {"hit": 0, "terms": 0,
|
|
4255
|
+
"counts": {"accept": 0, "reject": 0, "defer": 0,
|
|
4256
|
+
"blindspot": 0}}
|
|
3748
4257
|
for r in results:
|
|
3749
4258
|
if judge:
|
|
3750
|
-
|
|
3751
|
-
|
|
4259
|
+
_jw = MdCG.judge_with_boundary(r[0], stat.get("query") or "",
|
|
4260
|
+
context)
|
|
4261
|
+
qual = dict(_jw["qualification"])
|
|
4262
|
+
_b = boundary_mark(r[0], stat.get("query") or "", context)
|
|
4263
|
+
if _b.get("hit"):
|
|
4264
|
+
_bnd["hit"] += 1
|
|
4265
|
+
_bnd["terms"] += len(_b.get("terms") or [])
|
|
4266
|
+
# 拒绝域命中走**独立字段**展示(不写进 qualification,
|
|
4267
|
+
# 否则下游按 state 判定的地方会把它读成资格结论)
|
|
4268
|
+
r[0]["boundary_hit"] = True
|
|
4269
|
+
r[0]["boundary_marker"] = _b.get("marker")
|
|
4270
|
+
r[0]["boundary_terms"] = list(_b.get("terms") or [])
|
|
4271
|
+
for _k in ("accept", "reject", "defer", "blindspot"):
|
|
4272
|
+
_bnd["counts"][_k] += int(
|
|
4273
|
+
(_jw.get("counts") or {}).get(_k) or 0)
|
|
3752
4274
|
else:
|
|
3753
4275
|
qual = {"state": None, "reason": "judge_disabled"}
|
|
3754
4276
|
out.append((r[0], r[1], qual))
|
|
4277
|
+
# 边界读数只在**确有边界命中**时落键(与 S1/S2/S5「产生信息才落键」同规则)
|
|
4278
|
+
if _bnd["hit"]:
|
|
4279
|
+
stat["boundary"] = _bnd
|
|
3755
4280
|
# 负覆盖提示条目**追加在尾部**(不是首条——旧注释「(首条)」与代码相反):
|
|
3756
4281
|
# 它们在 `_neg_tail` 里已按 k 预算建好(①分数哨兵 ②计入 k ④位置=尾)。
|
|
3757
4282
|
out.extend(_neg_tail)
|
|
@@ -3792,6 +4317,10 @@ class MdCG:
|
|
|
3792
4317
|
# 分阶段审计仅在门控真的产生信息时落键(默认关闭 → meta 与改动前逐字节一致;契约 §5.3)
|
|
3793
4318
|
if stat.get("gates"):
|
|
3794
4319
|
meta["gates"] = stat["gates"]
|
|
4320
|
+
# P2-2(§5.2):拒绝域命中的**独立读数**(与资格 REJECT 分开计数)——
|
|
4321
|
+
# 只在确有边界命中时落键(无边界命中的路径 meta 键集合与改动前一致)。
|
|
4322
|
+
if stat.get("boundary"):
|
|
4323
|
+
meta["boundary"] = stat["boundary"]
|
|
3795
4324
|
# 负覆盖尾审计(H10①②):仅在**确有提示条目入榜**时落键(无覆盖的路径
|
|
3796
4325
|
# meta 键集合与改动前逐字节一致)。下游可据此把「提示」与「答案」分区:
|
|
3797
4326
|
# 尾条目的负性判据是 card/qual 的 `negative_coverage`,本块是同一事实的
|
|
@@ -3944,12 +4473,28 @@ class MdCG:
|
|
|
3944
4473
|
and os.path.exists(old_full)):
|
|
3945
4474
|
os.remove(old_full)
|
|
3946
4475
|
rel = os.path.relpath(new_path, self.root).replace("\\", "/")
|
|
4476
|
+
# N137(2026-10-01):本条 _stage 条目此前缺 edges / subgraph / 双时间轴
|
|
4477
|
+
# (trust.FROM/UNTIL/EFFECTIVE_FROM/EFFECTIVE_UNTIL)四组键——搬迁 =
|
|
4478
|
+
# 「删除源层条目 + 写入目标层条目」,缺键即让目标层条目**静默退化**:
|
|
4479
|
+
# ① 新检索路(P3-causal 的 chain 路走 index 的 edges 建邻接、P3-temporal
|
|
4480
|
+
# 走索引标量)搬迁后对它即不可达(节点还在,边/时刻没了);
|
|
4481
|
+
# ② trust.validity 的时效过滤免读盘判定失效(valid_from/until 不在快照)。
|
|
4482
|
+
# 写入口 `_node_entry`(_scan_nodes / rebuild 共用)与 `add()` 的 _stage
|
|
4483
|
+
# 条目都带这些键——本条与之对齐,杜绝「重建后有、搬迁后没有」的形态漂移。
|
|
3947
4484
|
self._stage(node_id, _strip_empty_gate_fields({
|
|
3948
4485
|
"path": rel, "layer": to_layer, "tags": fm.get("tags", []),
|
|
3949
4486
|
"bucket": None, "importance": fm.get("importance", 0.5),
|
|
3950
4487
|
"created_at": fm.get("created_at", 0),
|
|
3951
4488
|
"verification_basis": fm.get("verification_basis"),
|
|
3952
4489
|
"has_neg_conditions": nodefile.has_non_applicable(node["content"]),
|
|
4490
|
+
# P2-1 / P4-2①(与 `_node_entry` / `add()` 同口径;N137 的教训:
|
|
4491
|
+
# 搬迁条目缺键 = 新检索路对搬迁后的节点静默不可达)
|
|
4492
|
+
nodefile.POSTCONDITION_TERMS_KEY: nodefile.ccg_element_terms(
|
|
4493
|
+
node["content"], nodefile.POSTCONDITION_FIELD),
|
|
4494
|
+
nodefile.REJECTION_TERMS_KEY: rejection_index_terms(
|
|
4495
|
+
fm, node["content"]),
|
|
4496
|
+
"access_count": fm.get("access_count", 0),
|
|
4497
|
+
"last_access": fm.get("last_access", 0),
|
|
3953
4498
|
"content_hash": hashlib.sha256(
|
|
3954
4499
|
node["content"].encode("utf-8")).hexdigest()[:12],
|
|
3955
4500
|
"temporal": fm.get("temporal"), "spatial": fm.get("spatial"),
|
|
@@ -3957,6 +4502,16 @@ class MdCG:
|
|
|
3957
4502
|
"big_domain": fm.get("big_domain"),
|
|
3958
4503
|
"observation_position": (fm.get("condition_space") or {}).get("observation_position"),
|
|
3959
4504
|
"evidence_count": fm.get("evidence_count", 0),
|
|
4505
|
+
# N137:边域与嵌套子图(chain 路 / 递归展开的免读盘来源)
|
|
4506
|
+
"edges": fm.get("edges") or [],
|
|
4507
|
+
"subgraph": fm.get("subgraph"),
|
|
4508
|
+
# N137:双时间轴(trust.validity 的免读盘判定面,与 add/_node_entry 同口径)
|
|
4509
|
+
trust.STATE_FIELD: fm.get(trust.STATE_FIELD),
|
|
4510
|
+
trust.DEPS_FIELD: fm.get(trust.DEPS_FIELD),
|
|
4511
|
+
trust.FROM_FIELD: fm.get(trust.FROM_FIELD),
|
|
4512
|
+
trust.UNTIL_FIELD: fm.get(trust.UNTIL_FIELD),
|
|
4513
|
+
trust.EFFECTIVE_FROM_FIELD: fm.get(trust.EFFECTIVE_FROM_FIELD),
|
|
4514
|
+
trust.EFFECTIVE_UNTIL_FIELD: fm.get(trust.EFFECTIVE_UNTIL_FIELD),
|
|
3960
4515
|
}))
|
|
3961
4516
|
self.index["buckets"] = self._count_buckets(self.index["nodes"])
|
|
3962
4517
|
return {"id": node_id, "from": from_layer, "to": to_layer,
|