@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/mdcos.py CHANGED
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import hashlib
22
22
  import json
23
+ import math
23
24
  import os
24
25
  import re
25
26
  import time
@@ -30,7 +31,9 @@ from .mdcg import (MdCG, expand_query_terms, bigrams, normalize_en, STATE_ACCEPT
30
31
  TIER_BUCKET_SCAN, TIER_GLOBAL_LIKE, TIER_GLOBAL_SCAN,
31
32
  GLOBAL_CAP, expand_query_terms_weighted,
32
33
  expand_query_terms_llm, en_zh_bigrams, semantic_on,
33
- cut_by_relevance, apply_retrieval_gates, NEG_ROUTE_LAYERS)
34
+ cut_by_relevance, apply_retrieval_gates, NEG_ROUTE_LAYERS,
35
+ # P2-3(§5.4):六要素后两行索引键的召回判据单点(mdcg 侧)
36
+ index_key_hits, boundary_mark)
34
37
  from . import (nodefile, routing, chain, subgraph, forgetting, protect,
35
38
  identity, consistency, metacognition, crypto, sustain,
36
39
  self_state, predict, evolution, weights, pooling,
@@ -64,6 +67,37 @@ DEFAULT_BUDGET = 1200 # recall 默认 token 预算
64
67
  # 0 = 关闭截断,回到"超大一律跳过"的旧行为。
65
68
  DEFAULT_MAX_ITEM_TOKENS = 250
66
69
 
70
+ # ---- P3-temporal:时间路的核与口径(设计稿 §6.3,裁定 12)-------------------
71
+ # 核**必须**来自唯一权威 `md_cg/whitebox_kb/aeis_core/time_core.py::cred_factor`
72
+ # (该模块 docstring 逐字点名 `exp(-t/τ)` 为 bug);本模块**不写任何指数核**。
73
+ #: γ(衰减率)可配 env —— 与 `MDCG_SPREAD_DECAY` 同款:缺失/非法即回落缺省。
74
+ TEMPORAL_GAMMA_ENV = "MDCG_TEMPORAL_GAMMA"
75
+ #: 半衰期真源 = `links.DECAY_DAYS`(30 天,`md_cg/links.py:53`)——不复制字面量,
76
+ #: 由 `temporal_gamma()` 现取;此处只写明「γ 缺省 = ln2 / 半衰期」这一换算。
77
+ #: 单位口径(**最易错处**):γ 是「每天」的速率,故 Δt 必须**以天为单位**喂入。
78
+ TEMPORAL_DT_UNIT = "day"
79
+ #: 分数下限(floor):`cred_factor(gamma, dt, floor, 1.0)` 的 floor。
80
+ #: 取值理由:0.5**10 ≈ 9.77e-4 < 1e-3 —— 30 天半衰期下 300 天(10 个半衰期)
81
+ #: 之后衰减已落到 1e-3 以下,继续区分无信息量;floor=1e-3 让**老节点/缺
82
+ #: created_at 的节点**仍带非零分进入本路,而不是被乘成 0 后不可召回
83
+ #:(读数见 `md_cg/test_time_core_lint.py` 的 G4 floor 组)。
84
+ TEMPORAL_SCORE_FLOOR = 0.001
85
+
86
+
87
+ # 生效条件:environ(缺省 os.environ)里 TEMPORAL_GAMMA_ENV 为真值且 float() 可解析且 > 0 时返回该值;缺失/空串/不可解析/非正数一律回落 `math.log(2)/links.DECAY_DAYS`(缺省半衰期 30 天)。
88
+ def temporal_gamma(environ=None) -> float:
89
+ """时间邻近度衰减率 γ(**每天**)的唯一读取点。"""
90
+ raw = (environ or os.environ).get(TEMPORAL_GAMMA_ENV)
91
+ if raw:
92
+ try:
93
+ g = float(raw)
94
+ except (TypeError, ValueError):
95
+ g = 0.0
96
+ if g > 0:
97
+ return g
98
+ from .links import DECAY_DAYS # 半衰期唯一真源(links.py:53,30 天)
99
+ return math.log(2.0) / float(DECAY_DAYS)
100
+
67
101
 
68
102
  # 生效条件:text 为假值(None/空串)时返回 0,否则按「CJK 0.6/字 + 其余 /4」计算并返回 int(cjk*0.6 + other/4) + 1。
69
103
  def est_tokens(text: str) -> int:
@@ -846,8 +880,9 @@ class MdCGOS(MdCG):
846
880
  详见 _candidates。
847
881
  """
848
882
  q = (query or "").strip()
849
- # 批次 15 统一口径(unify.py):任意语言 query → 标准原子序列;
850
- # 与 MdCG.search/search_rrf 三入口同口径(MDCG_UNIFY_QUERY=0 可关)
883
+ # 批次 15 统一口径(unify.py,2026-09-30 收窄作用域):只译**英文内容**
884
+ # → 标准原子序列,中文段逐字原样(不再整条归一);与
885
+ # MdCG.search/search_rrf 三入口同口径(归一层缺省关,显式 =1 才开)
851
886
  from .semantic.unify import unify_query
852
887
  q = unify_query(q)
853
888
  if not q:
@@ -940,7 +975,9 @@ class MdCGOS(MdCG):
940
975
  if in_bucket:
941
976
  docs = self._read_many(in_bucket, stat)
942
977
  # 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池
943
- hits = [d for d in docs if self._like(d[2], d[1], terms)
978
+ hits = [d for d in docs
979
+ if self._like(d[2], d[1], terms,
980
+ index_key_hits(d[0], terms, q))
944
981
  or (semantic_on() and d[1].get("semantic"))]
945
982
  out = try_stage(hits, TIER_BUCKET_LIKE)
946
983
  if out:
@@ -966,7 +1003,8 @@ class MdCGOS(MdCG):
966
1003
  docs_r = self._read_many(reach_entries, stat)
967
1004
  _dif = set(_rstat.get("reach_diffused_paths") or ())
968
1005
  hits_r = [d for d in docs_r
969
- if self._like(d[2], d[1], terms)
1006
+ if self._like(d[2], d[1], terms,
1007
+ index_key_hits(d[0], terms, q))
970
1008
  or (semantic_on() and d[1].get("semantic"))
971
1009
  or d[0].get("path") in _dif] # 图扩散补召回:无词面命中也放行进打分
972
1010
  stat["pre_cap"] = len(hits_r) # 与 T2 同序:截断**前**的候选数
@@ -998,7 +1036,9 @@ class MdCGOS(MdCG):
998
1036
  # 截断依据=相关度(同 MdCG.search:cap 值不变,改的是拿什么排序)
999
1037
  docs_all = self._read_many(entries, stat)
1000
1038
  # 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池
1001
- hits = [d for d in docs_all if self._like(d[2], d[1], terms)
1039
+ hits = [d for d in docs_all
1040
+ if self._like(d[2], d[1], terms,
1041
+ index_key_hits(d[0], terms, q))
1002
1042
  or (semantic_on() and d[1].get("semantic"))]
1003
1043
  stat["pre_cap"] = len(hits)
1004
1044
  stat["cap"] = GLOBAL_CAP
@@ -1046,7 +1086,9 @@ class MdCGOS(MdCG):
1046
1086
  docs = self._read_many(entries, stat)
1047
1087
  # 语义资格(MDCG_SEMANTIC=1):fm.semantic 节点无条件入池——
1048
1088
  # 语义摘要=检索面(设想核心),否则摘要层只在 LIKE 全空时生效
1049
- hits = [d for d in docs if self._like(d[2], d[1], terms)
1089
+ hits = [d for d in docs
1090
+ if self._like(d[2], d[1], terms,
1091
+ index_key_hits(d[0], terms, query))
1050
1092
  or (semantic_on() and d[1].get("semantic"))]
1051
1093
  if not hits:
1052
1094
  # 兜底池(LIKE 全空 = 无相关度信号):截断依据=importance/created_at
@@ -1158,7 +1200,8 @@ class MdCGOS(MdCG):
1158
1200
  # 生效条件:当 seeds 非空且 seed_map 非空时,用 relation_types 或 CHAIN_TYPES_DEFAULT、max_depth 或 MAX_DEPTH_DEFAULT、decay 调用 chain.expand_from_seeds,仅保留 best 中仍存在于 entries(按 id(e))的节点,读取成功者加入 scored 并按分数降序返回 (scored, prov);seeds/seed_map/best 为空返回 ([], {});
1159
1201
  def _path_chain(self, query, entries, seeds, context=None,
1160
1202
  relation_types=None, max_depth=None, decay=0.9):
1161
- """关系链路径:沿 causal/sequential/applies_to 边**多跳**扩散。
1203
+ """关系链路径(= 设计稿 §6.2 的**因果路**):沿 causal/sequential/
1204
+ applies_to 边**多跳**扩散。
1162
1205
 
1163
1206
  理论依据:`causal` = 条件依赖因果(A 是 B 成立的条件),
1164
1207
  `dex_chain` 沿 causal 边正向展开、每步标注条件,**链 = 条件序列**。
@@ -1170,6 +1213,13 @@ class MdCGOS(MdCG):
1170
1213
  本路按边类型权重(causal .85 / sequential .60 …)、逐跳乘边置信度、
1171
1214
  默认 5 跳、visited 剪枝——「检索使用关系链」的落地。
1172
1215
 
1216
+ **边类型集可配(P3-causal)**:relation_types 缺省不再写死
1217
+ `CHAIN_TYPES_DEFAULT`,而走 `chain.chain_types_from_env()`(env
1218
+ `MDCG_CHAIN_TYPES`,缺省仍 = `CHAIN_TYPES_DEFAULT`)。动因:叙事/
1219
+ 文档语料(按章切出的 doc_ref 库)里**没有 causal 边**,只有
1220
+ `reference` 与父章节结构边 `part_of`——写死即本路在这类库上恒空。
1221
+ 未登记进 `EDGE_WEIGHTS` 的形态被读取点剔除(不静默走未知边)。
1222
+
1173
1223
  返回 (scored, prov);prov[nid] 带该节点**最强链**的节点序列与条件序列,
1174
1224
  使「为什么召回它」可审计。
1175
1225
  """
@@ -1182,7 +1232,8 @@ class MdCGOS(MdCG):
1182
1232
  seed_map[nid] = float(s)
1183
1233
  if not seed_map:
1184
1234
  return [], {}
1185
- rels = tuple(relation_types) if relation_types else chain.CHAIN_TYPES_DEFAULT
1235
+ rels = (tuple(relation_types) if relation_types
1236
+ else chain.chain_types_from_env())
1186
1237
  depth = chain.MAX_DEPTH_DEFAULT if max_depth is None else max_depth
1187
1238
  best = chain.expand_from_seeds(self, seed_map, relation_types=rels,
1188
1239
  max_depth=depth, decay=decay)
@@ -1209,6 +1260,65 @@ class MdCGOS(MdCG):
1209
1260
  scored.sort(key=lambda x: (-x[1], str(x[0].get("id") or "")))
1210
1261
  return scored, prov
1211
1262
 
1263
+ def _path_temporal(self, entries, q_start, q_end, start_op, end_op,
1264
+ now=None):
1265
+ """时间路(P3-temporal,设计稿 §6.3):按**时间邻近度**排序的排名项。
1266
+
1267
+ 口径(§〇.3-12 已裁,逐条钉在此处):
1268
+
1269
+ * **核走唯一权威**:`time_core.cred_factor(gamma, dt, floor, 1.0)`
1270
+ ——本模块**不写任何指数核**(`exp(-t/τ)`、`×(1-factor)`、EMA 保持率
1271
+ 均是 time_core docstring 点名的 bug 形态)。
1272
+ * **γ 缺省 = ln2 / 30 天**(半衰期 30 天,与 `links.py:53` 同刻度),
1273
+ env `MDCG_TEMPORAL_GAMMA` 可覆盖;唯一读取点 `temporal_gamma()`。
1274
+ * **Δt 取 `created_at`**(写入时刻,主链必有)。
1275
+ * **单位必须显式是「天」**:γ 是「每天」的速率,Δt 先除以 86400 再喂
1276
+ ——喂秒会让衰减快 86400 倍(守卫 `test_time_core_lint.py` G3 组有该反证)。
1277
+ * **加 floor**(`TEMPORAL_SCORE_FLOOR`):老节点/缺 `created_at` 的节点
1278
+ 不被乘成 0 而永不可召回。
1279
+ * **不做多跳扩散**:时间天然「1 跳」,本路是**排名项**(与 S4 层级加成
1280
+ 同类),不是扩散路。
1281
+ * **种子不来自词法命中**:时间类问句常词面零重叠,故种子取
1282
+ **时间算子/区间命中集**——`entries`(已过 S1/S2 门控 ∧ 已过
1283
+ `trust.filter_by_time` 的索引标量过滤),**不 `_scan` 全库**、
1284
+ 不读正文即可算分(`created_at` 在索引快照里)。
1285
+
1286
+ ⚠ **理论边界标注**:理论原文 `exp(-γ·t)`(`docs/theory/智能论3.4.md:2122-2125`
1287
+ DEV-005)说的是 **confidence 衰减**;把它用于「时间邻近度」是**工程口径
1288
+ 的类比迁移**,不是理论原话——同一核形状、不同语义(新近度权重)。
1289
+ """
1290
+ if not any(x is not None for x in (q_start, q_end, start_op, end_op)):
1291
+ return [] # 无时间算子/区间 → 本路不启用(默认查询零变更)
1292
+ from .whitebox_kb.aeis_core import time_core as _tc # 懒导入(唯一权威)
1293
+ gamma = temporal_gamma()
1294
+ ref = q_end if q_end is not None else q_start
1295
+ if ref is None:
1296
+ ref = now if now is not None else time.time()
1297
+ out = []
1298
+ for e in entries:
1299
+ try:
1300
+ created = float(e.get("created_at") or 0.0)
1301
+ except (TypeError, ValueError):
1302
+ created = 0.0
1303
+ if created <= 0:
1304
+ # 写入时刻缺失:不猜测时刻,判「距参照无穷远」——floor 兜住
1305
+ #(仍可召回,不因缺字段整条消失)。
1306
+ dt_days = float("inf")
1307
+ else:
1308
+ # **单位=天**:86400 秒/天,显式换算(γ 是每天速率)
1309
+ dt_days = abs(created - float(ref)) / 86400.0
1310
+ score = _tc.cred_factor(gamma, dt_days, TEMPORAL_SCORE_FLOOR, 1.0)
1311
+ fm, c = self._read(e)
1312
+ if c is None:
1313
+ continue
1314
+ c = self._open_content(fm.get("id"), fm, c)
1315
+ if c is None:
1316
+ continue
1317
+ out.append(({"id": fm.get("id") or e["path"], "frontmatter": fm,
1318
+ "content": c, "path": e["path"]}, round(score, 6)))
1319
+ out.sort(key=lambda x: (-x[1], str(x[0].get("id") or "")))
1320
+ return out
1321
+
1212
1322
  # 生效条件:以 expand or expand_query_terms_weighted 作扩展函数并对 query 调用(结果为假值按 {} 处理),从其中 pop "__source__"(缺键为 "whitebox")得 source,tw 为空返回 ([], source),否则对 entries 中存在 _weighted_coverage>0 的条目按 0.6·cov+0.3·aff+0.1·ctx_aff(context 非 None 时以 route_key(ctx, ctx.get("tags")) 得 ctx_domain,context 非 dict 时用 {})打分,返回 (out, source)。
1213
1323
  def _path_fuzzy(self, query, entries, context=None, expand=None):
1214
1324
  """模糊路径(分级隶属度):返回 (scored, source)。
@@ -1343,7 +1453,9 @@ class MdCGOS(MdCG):
1343
1453
  # 生效条件:query 去空白为空→empty_query、候选为空→no_candidates;否则按 paths 各路召回后融合(fusion=="max" 取各路最大贡献、否则求和),recall_only 中的路只以 0 分补池不参与打分,judge 与 judge_ranking 同时为真时对 fused 前 max(k*2,10) 条做资格裁决(REJECT/BLINDSPOT 剔除、DEFER 降权 0.5),否则直接取 fused 前 k;validity 真值时候选层剔除已过期节点,且 query 缓存按 validity 分键不串口径。
1344
1454
  def search_rrf(self, query: str, k: int = 20, layer: str = None,
1345
1455
  context=None, roles=None, include_work: bool = False,
1346
- judge: bool = True, paths=("lexical", "bucket", "entity", "graph"),
1456
+ judge: bool = True,
1457
+ paths=("lexical", "bucket", "entity", "graph", "chain",
1458
+ "temporal"),
1347
1459
  record: bool = True, query_expand=None,
1348
1460
  path_weights=None, recall_only=None, fusion: str = "sum",
1349
1461
  goal_text=None, judge_ranking: bool = False,
@@ -1358,14 +1470,28 @@ class MdCGOS(MdCG):
1358
1470
  多路共同确认的记忆排在单路命中之前(对齐 noema 的 Fusion Recall)。
1359
1471
  meta 含 per_path(各路的候选数与来源),可审计。
1360
1472
 
1361
- paths: 基线四路(词法/条件桶/实体/图扩展)+ 两条**显式启用**的增量路:
1473
+ paths: 基线四路(词法/条件桶/实体/图扩展)+ 增量路:
1362
1474
  "fuzzy" 词表驱动(同义词组分级隶属度 + 大域 IDF)
1363
1475
  "semantic" 条件结构驱动(CCG 生效条件 + condition_space 四槽;
1364
1476
  不适用条件被整词命中即从本路剔除——条件级负路由)
1365
1477
  "goal" 目标定向(第 5 篇第 3 章):以活跃目标文本(或显式
1366
1478
  goal_text)扩展查询,给「与当前目标相关」的记忆加权;
1367
1479
  无活跃目标时本路为空,等价于未启用。
1368
- 缺省 ("lexical","bucket","entity","graph"),既有行为完全不变。
1480
+ "chain" **因果路**(P3-causal,设计稿 §6.2):以词法/实体命中为
1481
+ 种子沿 edges 多跳扩散(复用 `md_cg/chain.py`:14 类边权、
1482
+ MAX_DEPTH 5、decay 0.9)。**代价口径(P3 遗留 ⑥ 如实
1483
+ 降级,2026-10-01):不读正文,但邻接构建为 O(N) 索引
1484
+ 条目级**(`chain.adjacency` 遍历全部索引节点;冷建代价
1485
+ 读数见 `scripts/p2p4_probe.py` R7)——原表述「不走全表」
1486
+ 与实测不符,已改正。
1487
+ "temporal" **时间路**(P3-temporal,设计稿 §6.3):按时间邻近度排序
1488
+ 的**排名项**,核走 `time_core.cred_factor`;无时间算子/
1489
+ 区间时恒空。
1490
+ 缺省 = ("lexical","bucket","entity","graph","chain","temporal")
1491
+ —— 后两路按裁定 9「全进默认检索」进缺省集;**每路可单独关**:
1492
+ 显式传不含该路的 paths 即关(既有调用方口径一字不变)。
1493
+ 两条新路在**无 edges / 无时间参数**的库上自然为空(零多算),
1494
+ 故纯词法查询的返回与改动前一致。
1369
1495
  path_weights: {路名: 权重};缺省全部 1.0 → 与既有等权 RRF 完全一致。
1370
1496
  用于压低**同质路**的贡献:等权融合下,两路对同一批候选给出不一致
1371
1497
  排序时,RRF 会双重奖励「两路都靠前」的干扰项,把强路的 top-1 挤掉。
@@ -1375,6 +1501,8 @@ class MdCGOS(MdCG):
1375
1501
  sum 奖励「多路共识」,但会系统性低估**单路独有**候选:当强路漏掉目标、
1376
1502
  弱路捞到时,目标的单路贡献必然低于任何「两路都有排名」的干扰项。
1377
1503
  max 只认「最好的一次排名」,不奖励共识,适合「任一路捞到即可」的召回。
1504
+ 因果路是**单路独有召回型**,MCP 面(`mdcg_recall`)按其缺省 max
1505
+ (先例 `mcp_server.py` 的 fuzzy/semantic/goal 同款表达式)。
1378
1506
  judge_ranking: 白箱终排(证据防火墙,显式启用)。融合排序只产生候选
1379
1507
  (语义负责「不要漏」),资格裁决决定最终优先级(白箱负责「不要错」):
1380
1508
  REJECT / BLINDSPOT 剔除,DEFER 降权 ×0.5,ACCEPT 保位。候选池取
@@ -1399,8 +1527,9 @@ class MdCGOS(MdCG):
1399
1527
  本入口的对应物是 reach——不在本函数内。
1400
1528
  """
1401
1529
  q = (query or "").strip()
1402
- # 批次 15 统一口径(unify.py):任意语言 query → 标准原子序列;
1403
- # 归一后的 q 进热路径缓存键(归一确定 → 缓存不串味)
1530
+ # 批次 15 统一口径(unify.py,2026-09-30 收窄作用域):只译**英文内容**
1531
+ # → 标准原子序列(中文段逐字原样);归一后的 q 进热路径缓存键
1532
+ # (归一确定 → 缓存不串味)
1404
1533
  from .semantic.unify import unify_query
1405
1534
  q = unify_query(q)
1406
1535
  if not q:
@@ -1479,6 +1608,13 @@ class MdCGOS(MdCG):
1479
1608
  ranked = {} # path -> [(node, score)]
1480
1609
  fuzzy_source = None
1481
1610
  chain_prov = {}
1611
+ # P3-temporal:时间路的参照窗(与 `_candidates` 同一真源 `trust.check_time_args`
1612
+ # /`trust.parse_time`;未启用时留 None → 本路恒空,默认查询零变更)。
1613
+ # 只读索引标量(created_at 在索引快照里),不 `_scan` 全库、不读正文。
1614
+ _t_en, _t_ax, _t_why = trust.check_time_args(
1615
+ start_time, end_time, start_operator, end_operator, time_axis)
1616
+ _t_qs, _t_qe = ((trust.parse_time(start_time), trust.parse_time(end_time))
1617
+ if _t_en else (None, None))
1482
1618
  if "lexical" in paths:
1483
1619
  ranked["lexical"] = self._lexical(q, entries, stat)
1484
1620
  if "bucket" in paths:
@@ -1490,6 +1626,12 @@ class MdCGOS(MdCG):
1490
1626
  if "chain" in paths:
1491
1627
  seeds = (ranked.get("lexical") or []) + (ranked.get("entity") or [])
1492
1628
  ranked["chain"], chain_prov = self._path_chain(q, entries, seeds, context)
1629
+ if "temporal" in paths:
1630
+ # P3-temporal:时间路(排名项)。时间算子/区间未启用时恒空——默认
1631
+ # 查询(无时间参数)零变更;启用时种子取「已过 S1/S2 门控 ∧ 已过
1632
+ # 时间算子过滤」的 entries(索引标量直读,不 _scan 全库)。
1633
+ ranked["temporal"] = self._path_temporal(
1634
+ entries, _t_qs, _t_qe, start_operator, end_operator)
1493
1635
  if "fuzzy" in paths:
1494
1636
  ranked["fuzzy"], fuzzy_source = self._path_fuzzy(
1495
1637
  q, entries, context, expand=query_expand)
@@ -1582,12 +1724,50 @@ class MdCGOS(MdCG):
1582
1724
  fused = fused_all[:k]
1583
1725
 
1584
1726
  results = []
1727
+ # P2-2(§5.2):默认召回路径上的**拒绝域双语义**——与 `MdCG._emit` 同款
1728
+ # 接线(同一个判据函数 `MdCG.judge_with_boundary`,此处不另写一份):
1729
+ # 边界命中 ⇒ 卡片带 `boundary_hit` 标记(可召回可展示);资格不受影响;
1730
+ # 边界命中数与资格 REJECT 数**分开计数**(boundary_counts)。
1731
+ boundary_counts = {"hit": 0, "terms": 0,
1732
+ "counts": {"accept": 0, "reject": 0, "defer": 0,
1733
+ "blindspot": 0}}
1585
1734
  for nid, fs in fused:
1586
1735
  node, s = node_by_id[nid]
1587
- qual = (quals.get(nid)
1588
- or (self.judge_qualification(node, q, context) if judge
1589
- else {"state": None, "reason": "judge_disabled"}))
1736
+ if quals.get(nid):
1737
+ # judge_ranking(白箱终排)已算过资格 → 不重复判定,边界标记
1738
+ # 单独取(同一个 P0-4 判据函数 `MdCG.boundary_hit`)
1739
+ qual = quals[nid]
1740
+ _b = boundary_mark(node, q, context)
1741
+ _c = {"accept": 0, "reject": 0, "defer": 0, "blindspot": 0}
1742
+ _st = str(qual.get("state") or "").lower()
1743
+ if _st in _c:
1744
+ _c[_st] = 1
1745
+ if _b.get("hit"):
1746
+ boundary_counts["hit"] += 1
1747
+ boundary_counts["terms"] += len(_b.get("terms") or [])
1748
+ node["boundary_hit"] = True
1749
+ node["boundary_marker"] = _b.get("marker")
1750
+ node["boundary_terms"] = list(_b.get("terms") or [])
1751
+ for _k in _c:
1752
+ boundary_counts["counts"][_k] += _c[_k]
1753
+ elif judge:
1754
+ _jw = MdCG.judge_with_boundary(node, q, context)
1755
+ qual = dict(_jw["qualification"])
1756
+ _b = boundary_mark(node, q, context)
1757
+ if _b.get("hit"):
1758
+ boundary_counts["hit"] += 1
1759
+ boundary_counts["terms"] += len(_b.get("terms") or [])
1760
+ node["boundary_hit"] = True
1761
+ node["boundary_marker"] = _b.get("marker")
1762
+ node["boundary_terms"] = list(_b.get("terms") or [])
1763
+ for _k in ("accept", "reject", "defer", "blindspot"):
1764
+ boundary_counts["counts"][_k] += int(
1765
+ (_jw.get("counts") or {}).get(_k) or 0)
1766
+ else:
1767
+ qual = {"state": None, "reason": "judge_disabled"}
1590
1768
  results.append((node, round(fs, 6), qual, prov.get(nid, [])))
1769
+ _bnd_meta = ({"boundary": boundary_counts}
1770
+ if boundary_counts["hit"] else {})
1591
1771
  if record and results:
1592
1772
  self.record_access([r[0]["id"] for r in results], "RRF")
1593
1773
  # 热路径:写 query 结果缓存 —— **同样受 `_time_on` 约束**。
@@ -1603,7 +1783,7 @@ class MdCGOS(MdCG):
1603
1783
  "expand_source": fuzzy_source,
1604
1784
  "goal_used": goal_used,
1605
1785
  "provenance": prov, **_tf_meta,
1606
- **_gates_meta}, k=k, layer=layer,
1786
+ **_gates_meta, **_bnd_meta}, k=k, layer=layer,
1607
1787
  session=session, branch=branch, validity=validity,
1608
1788
  view=view, extra=_cache_extra)
1609
1789
  return results, {"tier": "RRF", "scanned": stat["scanned"],
@@ -1614,7 +1794,7 @@ class MdCGOS(MdCG):
1614
1794
  "expand_source": fuzzy_source,
1615
1795
  "goal_used": goal_used,
1616
1796
  "provenance": prov, **_tf_meta,
1617
- **_gates_meta}
1797
+ **_gates_meta, **_bnd_meta}
1618
1798
 
1619
1799
  # ================= 7. budget-driven pack =================
1620
1800
 
@@ -1638,11 +1818,12 @@ class MdCGOS(MdCG):
1638
1818
  动机:旧策略是「跳过超大、继续试更小的」——预算紧张时形成**逆向淘汰**,
1639
1819
  越有价值的详实条目越容易被排除(实测 budget=1500 时 16 条被刷、只装 2 条)。
1640
1820
 
1641
- paths/query_expand 缺省时行为与既有完全一致(默认四路、纯白箱扩展);
1642
- 显式传 paths 才启用新路,例如
1821
+ paths/query_expand 缺省时行为 = 既有四路 + P3 两条图检索路(chain/temporal,
1822
+ 按裁定 9 进缺省集;无 edges / 无时间参数时自然为空);
1823
+ 显式传 paths 才启用第 5/6 路,例如
1643
1824
  ("lexical","bucket","entity","graph","fuzzy") 词表驱动
1644
1825
  ("lexical","bucket","entity","graph","semantic") 条件结构驱动
1645
- (… 七路全开 ) 三者叠加
1826
+ (… 八路全开 ) 叠加
1646
1827
  include_recent=True 时,把近期事件窗口(第 5 篇第 3 章)附在包后,
1647
1828
  保证当前任务的连续性;它不参与 RRF 正排,但计入 token 预算。
1648
1829
  返回 {pack: [...], tokens_used, budget, skipped: [...], recent: [...], meta}
package/md_cg/nodefile.py CHANGED
@@ -637,4 +637,77 @@ def cond_terms(text: str) -> list[str]:
637
637
  if seg not in seen:
638
638
  seen.add(seg)
639
639
  out.append(seg)
640
- return out
640
+ return out
641
+
642
+
643
+ # ---- §5.1 六要素的**索引角色**:那张表从 4 行长成 6 行(P2-1)------------------
644
+ #
645
+ # 理论真源 `docs/theory/智能论3.4.md:3053-3059` 的「要素 | 标记 | 内容 |
646
+ # **图的索引角色**」表**只有 4 行**(功能名/生效条件/子功能/执行)。v0.4 §5.1
647
+ # 依裁定 2 把表补成 6 行,后两行为**新增检索维度**;本常量即那 6 行的
648
+ # **代码侧唯一真源**(检索面、索引条目、守卫三处共用一份,禁止各写一份)。
649
+ #
650
+ # 表的两列语义:
651
+ # · 字段名 = CCG 六要素之一(`CCG_MARKS` 的子集,顺序即理论表序);
652
+ # · 索引键 = 该要素在检索面上的**键名**(前 4 行既有,后 2 行本批新增)。
653
+ # 「索引键」进索引条目(`MdCG._node_entry`)成为**免读文件的扁指标量**——
654
+ # 与既有 `time_window` / `observation_position` 同款理由(检索期不读盘)。
655
+ CCG_INDEX_ROLES = (
656
+ ("功能名", "语义符号", "已有"),
657
+ ("生效条件", "条件词", "已有(S2 门控)"),
658
+ ("子功能", "结构词", "已有"),
659
+ ("执行", "机制词", "已有"),
660
+ ("验证方式", "后置条件词", "P2-1 新增:按验证手段检索"),
661
+ ("不适用条件", "拒绝域词", "P2-1 新增:按边界检索(boundary_hit)"),
662
+ )
663
+
664
+ #: 新增两行的**字段名 → 索引键名**映射(索引条目里的扁平键;单一真源)。
665
+ #: 键名刻意带 `_terms` 后缀:它们是**词项列表**(扁指标量),不是正文行原样。
666
+ POSTCONDITION_FIELD = "验证方式"
667
+ REJECTION_FIELD = "不适用条件"
668
+ POSTCONDITION_TERMS_KEY = "postcondition_terms"
669
+ REJECTION_TERMS_KEY = "rejection_terms"
670
+ INDEX_TERMS_KEYS = (POSTCONDITION_TERMS_KEY, REJECTION_TERMS_KEY)
671
+
672
+ #: 索引词项的最小长度。口径与 `mdcg.NEG_MIN_TERM`(负条件判据的词长下限)
673
+ #: **同值同义**:单字符碎片(的/与/3)不是检索键。两处不可各自取值——
674
+ #: `md_cg/test_p2_six_elements.py` 有交叉断言钉住两常量相等。
675
+ ELEMENT_TERM_MIN = 2
676
+
677
+ #: 索引词项的切分面:槽分隔(;;)、短语分隔(,,、/)、括号与空白。
678
+ #: 为什么与 `cond_terms` 不同:那两个要素是**自由文本行**(不是条件空间四槽
679
+ #: 合成串),没有「槽标签:」前缀,故不需要剥标签那一步;切分面本身同族。
680
+ _ELEMENT_TERM_SPLIT_RE = re.compile(r"[;;,,、/()()\[\]【】{}\s]+")
681
+
682
+
683
+ # 生效条件:value 为假值(None/空串/纯空白)时按空文本处理返回 [];否则按「;;,,、/()()[]【】{}空白」切分、逐段 strip、丢弃长度 < ELEMENT_TERM_MIN 的段与纯数字段、命中 is_dep_sentinel(空值语义哨兵)或 is_placeholder_text(骨架占位)的段、以及已入选的重复段,返回保序去重的 out;
684
+ def element_terms_from_text(value, min_len: int = ELEMENT_TERM_MIN) -> list[str]:
685
+ """要素文本 → 索引词项(**确定性切分,无语义猜测**)。
686
+
687
+ 这是「六要素索引键」的**切分单点**:`MdCG._node_entry` 与守卫共用。
688
+ 只做形态切分 + 空值语义剔除,不做任何同义/近义扩展(同 `cond_terms` 纪律)。
689
+ """
690
+ out, seen = [], set()
691
+ for seg in _ELEMENT_TERM_SPLIT_RE.split(str(value or "")):
692
+ seg = seg.strip().strip("。..::")
693
+ if len(seg) < int(min_len) or seg.isdigit():
694
+ continue
695
+ if is_dep_sentinel(seg) or is_placeholder_text(seg):
696
+ continue
697
+ if seg not in seen:
698
+ seen.add(seg)
699
+ out.append(seg)
700
+ return out
701
+
702
+
703
+ # 生效条件:field_name 为 CCG 要素名且 content 含该行时,取该行值(走 ccg_field_value 单点)→ 经 element_terms_from_text 切分成词项列表;该行缺失/值为空时返回 [];
704
+ def ccg_element_terms(content: str, field_name: str,
705
+ min_len: int = ELEMENT_TERM_MIN) -> list[str]:
706
+ """CCG 要素文本 → 索引词项(**取值走既有单点**,检索面不得另写正则)。
707
+
708
+ 取值**必须**经 `ccg_field_value`(仓内 CCG 行解析的唯一真源:冒号可有可无、
709
+ 首个命中行为准、与写入闸门 `data/policy.json` 同一行语义);本函数只在其上
710
+ 叠加**切分**,绝不自己 `re.match(r"^#\\s*验证方式")` ——那会立刻长出第二套
711
+ 行语义(`ccg_mark_present` 的取单点动因即此类分叉)。
712
+ """
713
+ return element_terms_from_text(ccg_field_value(content, field_name), min_len)
@@ -31,6 +31,28 @@ from . import zh_en_atoms
31
31
  WIN = 6 # 组合共现窗口(token 距离,探针实证口径)
32
32
  _ATOMS_ZH = None
33
33
 
34
+ # 中文段判据(单点,2026-09-30 检索归一层作用域收窄时立):
35
+ # 中文 = CJK 统一表意文字基本区 U+4E00–U+9FFF。与 en_normalizer 分词字符类
36
+ # `[A-Za-z\u4e00-\u9fff]` 的中文区间同区间;rust/src/text.rs::is_zh 是同判据的
37
+ # Rust 侧单点(两侧归一层共用同一判据;改动须两侧同步,否则 rank_parity 漂移)。
38
+ # 消费方:unify.py::unify_query(中文段逐字保留)、rust/src/atoms.rs::Atoms::unify。
39
+ ZH_LO, ZH_HI = "\u4e00", "\u9fff"
40
+
41
+
42
+ # 生效条件:单参 ch 为长度恰为 1 的 str 时返回 ZH_LO <= ch <= ZH_HI(CJK 基本区);长度 != 1(空串/多字符串)一律返回 False(2026-09-30 清理批次把边界收紧为「长度恰为 1」——此前只在 ch 为空串时为假,多字符串按字典序比较可能被误判为真)。
43
+ def is_zh_char(ch):
44
+ """中文段判据(单点):CJK 统一表意文字基本区 U+4E00–U+9FFF。
45
+
46
+ 边界约定(2026-09-30 清理批次乙2):**仅长度恰为 1 的串可为真**——
47
+ 空串与长度 >1 的串一律 False。此前只有空串为假(链式比较
48
+ `ZH_LO <= ch` 对空串即假),而多字符串按字典序比较:`is_zh_char("中文")`
49
+ 判真(首字符在区间即真)=判据外溢。两侧调用点清点结果:本函数只有
50
+ `unify.py::_runs` 与 `test_unify_scope.py::zh_runs` 两处消费,均逐字符
51
+ 传入 ⇒ 本收紧对存量行为零变更;Rust 侧 `text.rs::is_zh` 收 `char`,
52
+ 天然无空/多字符二态,边界语义两侧一致(docstring 已互指)。
53
+ """
54
+ return len(ch) == 1 and ZH_LO <= ch <= ZH_HI
55
+
34
56
 
35
57
  # 生效条件:模块级常量 `_ATOMS_ZH` 为 None 时按 `__file__` 所在目录的 atoms.json 取 `data.get("atoms", [])` 各项 `zh` 建集合并缓存,非 None 时直接返回 `_ATOMS_ZH`。
36
58
  def atoms_zh():
@@ -1,45 +1,96 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """灵枢 · 检索 query 统一归一层(批次 15,2026-09-23 使用者口径定案)
4
-
5
- 口径:**统一翻译为中文 → 归一化到标准中文集(atoms.json)→ 检索**。
6
- 任何语言的 query 先归一为标准原子序列(空格 join)再进检索各路:
7
- - 英文:en_normalizer(时态还原→停用词剔除→en→zh 语素映射)→
8
- segment 展开为标准原子(「牛肉」→「牛 肉」,与 doc 侧 fm.semantic
9
- 原子形态同构);
10
- - 中文:segment 贪心最长匹配归一到 atoms.json 标准概念;
11
- - 纯中文 query **原样返回**——segment+join+bigrams(去空白) 后与原文
12
- 等价,归一只对跨语 query 有信息增益(beef 与「牛肉」零共享字符,
13
- 形态归一救不了跨语),纯中文零变更纪律;
3
+ """灵枢 · 检索 query 统一归一层(批次 15 立;2026-09-30 使用者裁定收窄作用域)
4
+
5
+ 口径:**只对英文内容做翻译归一,中文内容原样不动**。
6
+ query 先按「中文段 / 非中文段」切开,只有**非中文段里的英文**进归一链路:
7
+ - 非中文段(含 ASCII 字母):en_normalizer(时态还原→停用词剔除→en→zh 语素
8
+ 映射)→ segment 展开为标准原子(「牛肉」→「牛 肉」,与 doc 侧
9
+ fm.semantic 原子形态同构);
10
+ - 中文段(CJK 统一表意文字基本区 U+4E00–U+9FFF,判据单点 =
11
+ `canonical.is_zh_char`):**逐字保留、绝不切分、绝不送 segment**;
12
+ - 非英文段(数字/标点等,段内无 ASCII 字母):原样保留(不是英文即不翻译);
13
+ - 两段按原顺序以单空格 join,空白(段内/段间)一律折叠为单空格;
14
14
  - 专名/未登录词原样保留(词表边界 unknown_keep,非错误)。
15
15
 
16
- 开关:MDCG_UNIFY_QUERY 默认 "1"(2026-09-23 使用者拍板口径转正);
17
- "=0" 显式关回退。两侧同步:rust/mcdg-eval 读同一 env + 同一份
18
- atoms.json(scripts/rank_parity.py --dataset 对拍在两侧同开关态进行,
19
- A/B 差异归档为转正证据)。
16
+ 为什么收窄(2026-09-30 定因,实测于 locomo-zh-500 公开题池 500 题 / 567 池、
17
+ eval_common jaccard + GLOBAL_CAP 解除、k=5):旧口径「query 含任一 ASCII 字母
18
+ 即整条归一」会把**中夹英** query 的中文部分逐字切开(「自我接纳」→
19
+ 「自 我 接 纳」、「慈善跑」→「慈 善 跑」);500 题里含 ASCII 字母的 145 条
20
+ **全部**也含中文(无纯英文 query)。单点实测:整条归一 lexical hit@1 = 94.4% /
21
+ lexical,fuzzy = 87.2%;只译英文片段 lexical = 95.8% / lexical,fuzzy = 96.6%;
22
+ 归一层全关 lexical = 96.4% / lexical,fuzzy = 97.2%。
23
+
24
+ 开关:MDCG_UNIFY_QUERY **默认关**(2026-09-30 使用者裁定,缺省由 "1" 翻为 "0")。
25
+ 定因:本层本职是让**英文** query 命中中文节点,对中文检索池是**纯开销**——
26
+ locomo-zh-500 公开题池(池 567 / 题 500)缺省关态 lexical hit@1 96.4% /
27
+ lexical+fuzzy 97.2%,开态 95.8% / 96.6%(各跑两遍逐位相同)。
28
+
29
+ 三态判据(两侧同语义,唯一开态 = 显式 "1"):
30
+
31
+ MDCG_UNIFY_QUERY Python unify_on() Rust Atoms::from_env()
32
+ 未设 False None(不载入)
33
+ "0" False None(不载入)
34
+ "1" True Some(Atoms)(载入)
35
+
36
+ 显式 "1" 仍能开回(英文对照路要用);两侧同源 en_zh_map.json
37
+ (scripts/rank_parity.py --dataset 对拍在两侧同开关态进行)。
20
38
 
21
39
  归一失败(semantic 模块缺失等)静默原样返回——与 en_zh_terms 同降级
22
40
  风格,不阻断检索主链路。
41
+
42
+ 两侧同构:rust/src/atoms.rs::Atoms::unify 同语义、同中文判据;逐位对拍 fixture
43
+ = `md_cg/semantic/unify_fixture.json`(本侧守卫 md_cg/test_unify_scope.py 与
44
+ Rust 单测 unify_mixed_fixture_matches_python 钉同一份期望值)。
23
45
  """
24
46
  import os
25
47
  import re
26
48
 
27
- # 生效条件:无 required 形参,锚定环境变量名 MDCG_UNIFY_QUERY;当 os.environ.get("MDCG_UNIFY_QUERY", "1") == "1" 时返回 True,否则返回 False。
49
+ from .canonical import is_zh_char
50
+
51
+ # 生效条件:无 required 形参,锚定环境变量名 MDCG_UNIFY_QUERY;**未设**时取缺省 "0"(2026-09-30 使用者裁定:缺省关),当 os.environ.get("MDCG_UNIFY_QUERY", "0") == "1" 时返回 True,其余(未设 / "0" / 任何非 "1" 取值)返回 False。
28
52
  def unify_on() -> bool:
29
- """统一归一层开关(默认开=口径定案转正;=0 显式关回退)。"""
30
- return os.environ.get("MDCG_UNIFY_QUERY", "1") == "1"
53
+ """统一归一层开关(**默认关**;显式 =1 才开,=0/未设一律关)。"""
54
+ return os.environ.get("MDCG_UNIFY_QUERY", "0") == "1"
55
+
56
+
57
+ # 生效条件:单参 text 为任意字符串(空串返回空列表),按 canonical.is_zh_char 逐字符判中文段,相邻同判据字符归并为一个连续段,返回 [(是否中文段, 段文本), ...] 的保序列表。
58
+ def _runs(text):
59
+ """按中文判据切连续段:[(True, "中文段"), (False, "非中文段"), ...]。"""
60
+ out = []
61
+ cur = None
62
+ buf = []
63
+ for ch in text:
64
+ k = is_zh_char(ch)
65
+ if buf and k != cur:
66
+ out.append((cur, "".join(buf)))
67
+ buf = []
68
+ cur = k
69
+ buf.append(ch)
70
+ if buf:
71
+ out.append((cur, "".join(buf)))
72
+ return out
31
73
 
32
74
 
33
- # 生效条件:text 为 None/空串或开关关闭或不含 [A-Za-z] 时原样返回;否则经 canonical.query_atoms 归一为标准原子序列并以单空格 join 返回(归一产物为空或抛异常时原样返回)。
75
+ # 生效条件:text 为 None/空串、或开关 MDCG_UNIFY_QUERY 取值不为 "1"(含**未设**——缺省 "0",2026-09-30 起关)、或 strip 后不含 [A-Za-z] 时,原样返回 text(未 strip 的入参);否则按中文段(canonical.is_zh_char)切分——中文段与不含 ASCII 字母的非中文段逐字保留、含 ASCII 字母的非中文段经 canonical.query_atoms 归一——各段折空白后按原顺序单空格 join;归一产物为空(trim 后)或抛异常时原样返回 text。
34
76
  def unify_query(text):
35
- """检索入口统一归一:任意语言 query → 标准原子序列。"""
77
+ """检索入口统一归一:只译英文内容,中文段逐字原样。"""
36
78
  t = (text or "").strip()
37
79
  if not t or not unify_on() or not re.search(r"[A-Za-z]", t):
38
80
  return text
39
81
  try:
40
82
  from .canonical import query_atoms
41
- atoms = query_atoms(t)
83
+ parts = []
84
+ for is_zh, run in _runs(t):
85
+ if is_zh or not re.search(r"[A-Za-z]", run):
86
+ seg = run # 中文段 / 非英文段:逐字保留
87
+ else:
88
+ atoms = query_atoms(run) # 非中文段里的英文:既有链路
89
+ seg = " ".join(a for a in atoms if a)
90
+ seg = " ".join(seg.split()) # 段内空白折叠为单空格
91
+ if seg:
92
+ parts.append(seg)
42
93
  except Exception:
43
94
  return text
44
- u = " ".join(a for a in atoms if a).strip()
95
+ u = " ".join(parts)
45
96
  return u if u else text