@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.
Files changed (105) hide show
  1. package/README.md +49 -18
  2. package/docs/README.md +1 -0
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +88 -0
  24. 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
  25. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +47 -11
  26. 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
  27. 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
  28. 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
  29. 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
  30. package/md_cg/admission.py +718 -0
  31. package/md_cg/autonomy_modes.py +642 -0
  32. package/md_cg/bench_e2e_locomo_qa.py +11 -4
  33. package/md_cg/chain.py +47 -0
  34. package/md_cg/consistency.py +133 -8
  35. package/md_cg/forgetting.py +38 -6
  36. package/md_cg/freshness.py +527 -0
  37. package/md_cg/generation.py +409 -0
  38. package/md_cg/hotcache.py +4 -1
  39. package/md_cg/lifecycle.py +30 -2
  40. package/md_cg/mcp_server.py +191 -26
  41. package/md_cg/mdcg.py +594 -39
  42. package/md_cg/mdcos.py +869 -38
  43. package/md_cg/nodefile.py +74 -1
  44. package/md_cg/protect.py +79 -4
  45. package/md_cg/provenance.py +1 -1
  46. package/md_cg/review_cli.py +31 -2
  47. package/md_cg/rollback.py +411 -0
  48. package/md_cg/rollback_cli.py +104 -0
  49. package/md_cg/semantic/canonical.py +22 -0
  50. package/md_cg/semantic/unify.py +73 -22
  51. package/md_cg/semantic/unify_fixture.json +25 -0
  52. package/md_cg/sleep.py +1297 -0
  53. package/md_cg/stg.py +400 -50
  54. package/md_cg/stgidx.py +281 -0
  55. package/md_cg/sustain.py +146 -9
  56. package/md_cg/test_auto_defaults.py +424 -0
  57. package/md_cg/test_autonomy_admission.py +1098 -0
  58. package/md_cg/test_autonomy_modes.py +1972 -0
  59. package/md_cg/test_b1_auto_id_multiproc.py +7 -0
  60. package/md_cg/test_b1b2_write_face.py +7 -0
  61. package/md_cg/test_b3_merge_keeps_content.py +7 -0
  62. package/md_cg/test_boundary_hit.py +410 -0
  63. package/md_cg/test_cons200_scan_selection.py +791 -0
  64. package/md_cg/test_en_pipeline.py +22 -13
  65. package/md_cg/test_generation_guard.py +352 -0
  66. package/md_cg/test_h4_sustain_snapshot.py +14 -4
  67. package/md_cg/test_i50a_half_dup_defer.py +408 -0
  68. package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
  69. package/md_cg/test_i50c_meta_passthrough.py +608 -0
  70. package/md_cg/test_i50d_importance_source.py +531 -0
  71. package/md_cg/test_i50e_readside_protection.py +656 -0
  72. package/md_cg/test_issue39_utf8_stdio.py +9 -1
  73. package/md_cg/test_issue52_scan_condition_first.py +677 -0
  74. package/md_cg/test_linkref.py +7 -0
  75. package/md_cg/test_mode_parity.py +1393 -0
  76. package/md_cg/test_mutation_rollback.py +805 -0
  77. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
  78. package/md_cg/test_n212_n213_n224_generation_gates.py +14 -8
  79. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
  80. package/md_cg/test_n230_dirty_replay.py +375 -0
  81. package/md_cg/test_p2_mcp.py +8 -0
  82. package/md_cg/test_p2_six_elements.py +463 -0
  83. package/md_cg/test_p3_legacy_closure.py +433 -0
  84. package/md_cg/test_p4_freshness.py +680 -0
  85. package/md_cg/test_p8_subgraph_chain.py +14 -1
  86. package/md_cg/test_p9_forget_protect.py +7 -0
  87. package/md_cg/test_p9c_dedup_hints.py +7 -0
  88. package/md_cg/test_policy_required_ccg.py +5 -1
  89. package/md_cg/test_protocol.py +7 -0
  90. package/md_cg/test_rank_parity_score_mode.py +6 -0
  91. package/md_cg/test_semantic_canonical.py +5 -3
  92. package/md_cg/test_sleep.py +611 -0
  93. package/md_cg/test_sleep_p1.py +784 -0
  94. package/md_cg/test_stgidx_index_parity.py +1020 -0
  95. package/md_cg/test_time_core_lint.py +968 -0
  96. package/md_cg/test_unify_default_off.py +701 -0
  97. package/md_cg/test_unify_scope.py +182 -0
  98. package/md_cg/test_writelimit.py +36 -9
  99. package/md_cg/test_writepipe.py +5 -2
  100. package/md_cg/weights.py +15 -1
  101. package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
  102. package/md_cg/writepipe.py +101 -2
  103. package/package.json +3 -2
  104. package/skills/plugin.json +1 -1
  105. package/utf8_boot.py +237 -0
package/md_cg/stg.py CHANGED
@@ -11,9 +11,24 @@
11
11
  timeline(...) 按时间排序
12
12
  anchors(...) 落在给定时间窗 / 空间范围内的节点
13
13
  consistency() 时空字段自洽性检查
14
+
15
+ **分层(issue #52 线)**:
16
+ · 第 1/2 层(已收口):条件先于限额 + 截断可观测——`_scan` 只做「遍历 +
17
+ layer/可见性过滤」,条件过滤与 `_cap_hits` 截断归各接口。
18
+ · 第 3 层(本模块本轮):结构索引直取(`flag MDCG_STG_INDEX`,**默认关**)——
19
+ 条件维(session/layer/time_window)经 `md_cg/stgidx` 的三张内存倒排表直取
20
+ 子集,**只改取数面、不改语义面**;表缺失/代际不符一律回退第 1 层全量遍历
21
+ 并在返回体 `index` 读数里带 reason(禁止静默)。设计稿与签收记录:
22
+ `docs/plans/stg条件化与结构索引_设计_v0.1.md`。
23
+ · 条件资格首验(`flag MDCG_STG_QUALIFY`,**默认关**):对返回条目复用 read 面
24
+ 单点 `MdCG.judge_qualification`,结果随返回体带出(`qualification` 字段)——
25
+ **只上报不过滤**(契约 §3.4;硬过滤另立裁定)。
14
26
  """
15
27
  from __future__ import annotations
16
28
 
29
+ import os
30
+
31
+ from . import stgidx
17
32
  from . import trust
18
33
 
19
34
  TIME_RELATIONS = ("before", "after", "equals", "contains", "during", "overlaps")
@@ -115,45 +130,238 @@ def _node(cg, node_id):
115
130
  "content": n.get("content") or ""}
116
131
 
117
132
 
118
- # 生效条件:cg.index["nodes"] 存在时按 list(...items())[:max_scan] 遍历,layer 为真值时仅保留 e.get("layer")==layer 的条目(layer 为假值不筛层),e 含 "temporal" 或 "spatial" 键时直接以快照字段构造 frontmatter、否则调用 cg._read(e) 且在 fm 为 None 时跳过;返回 out 列表(max_scan=None 切片取全部,0 时为空);
119
- def _scan(cg, layer=None, max_scan=5000):
133
+ # 生效条件:nid/e 为一条快照条目、layer 为层过滤值时——layer 为真值且 e.get("layer") != layer 即返回 None;cg 带可调用的 _readable(MdCGSecure)且判不可见即返回 None;e 含 "temporal" 或 "spatial" 键时以快照字段构造 frontmatter,否则调用 cg._read(e) 且在 fm 为 None 时返回 None;返回 {"id","frontmatter","layer","path"}。
134
+ def _scan_one(cg, nid, e, layer=None):
135
+ """单条目 → 候选条目(`_scan` 的逐条实现**单点**:全量遍历与索引子集共用)。
136
+
137
+ 第 3 层只换「候选面怎么来」(全量快照 vs 索引子集),逐条的构造与过滤
138
+ 一字不改地留在这一处——等价性(flag 开/关逐位一致)由构造保证,而不是
139
+ 靠两条路径各自对齐。
140
+ """
141
+ if layer and e.get("layer") != layer:
142
+ return None
143
+ _sec = getattr(cg, "_readable", None)
144
+ if _sec is not None and not _sec(e):
145
+ return None
146
+ if "temporal" in e or "spatial" in e:
147
+ # 效力轴四键必须一并从快照带出:否则 `time_axis="effective"` 在快照
148
+ # 路径上永远「不可判定」(静默全空,比报错更难查)。旧索引快照无这些
149
+ # 键时 `.get` 得 None → 不可判定,是本轴**如实降级**而非误判。
150
+ fm = {"temporal": e.get("temporal"), "spatial": e.get("spatial"),
151
+ # 会话归属必须一并从快照带出:timeline 的会话过滤与归属回带都
152
+ # 走这条快照路径,缺键 → 本会话视图静默全空(比报错更难查)。
153
+ "session": e.get("session"),
154
+ trust.EFFECTIVE_FROM_FIELD: e.get(trust.EFFECTIVE_FROM_FIELD),
155
+ trust.EFFECTIVE_UNTIL_FIELD: e.get(trust.EFFECTIVE_UNTIL_FIELD),
156
+ trust.FROM_FIELD: e.get(trust.FROM_FIELD),
157
+ trust.UNTIL_FIELD: e.get(trust.UNTIL_FIELD),
158
+ "condition_space": {"time_window": e.get("time_window")}}
159
+ else:
160
+ fm, _content = cg._read(e)
161
+ if fm is None:
162
+ return None
163
+ return {"id": nid, "frontmatter": fm, "layer": e.get("layer"),
164
+ "path": e.get("path")}
165
+
166
+
167
+ # 生效条件:nodes 为 None 时**逐条**遍历 cg.index["nodes"] 全部条目(不按索引序切片、不读正文);nodes 为 (nid, entry) 对的序列时只遍历该序列(第 3 层索引子集,序由调用方保证=索引物理序);两种形态都逐条经 _scan_one(layer 过滤 + 可见性 + 条目化同一单点);返回 out 列表(全部 layer/可见性命中,**不做截断**——截断由各接口在条件过滤之后经 _cap_hits 执行,issue #52);
168
+ def _scan(cg, layer=None, nodes=None):
120
169
  """遍历节点:时空字段直接读索引快照(不读文件,O(1)/节点)。
121
170
 
122
171
  索引为旧快照(无 temporal/spatial 键)时回退读文件,保证兼容;
123
172
  正文一律不在此加载——预览按需读,避免全库 IO。
124
173
  授权单点(issue #35 会话隔离定稿):cg 带 `_readable`(MdCGSecure)
125
174
  时逐条过读可见性——密级 × 会话绑定档在此与 _candidates 同口径,
126
- stg 各 op(timeline/relation/anchors)不得成为绕过路径。
175
+ stg 各 op(timeline/relation/anchors)不得成为绕过路径。可见性判定
176
+ **先于一切**(含截断):不可见节点既不进候选、也不占 kept 名额。
177
+
178
+ issue #52(条件先行于限额):旧实现在此处 `list(index["nodes"].items())[:max_scan]`
179
+ ——按 id 字典序在**条件过滤之前**砍尾巴,库超 max_scan 后(a)本会话记忆等条件
180
+ 命中若落在切片外即被永久排除(新写入节点恰在索引尾部)、(b)选面是无意义的 id
181
+ 序、(c)截断完全静默、「读不全」与「读不到」不可区分、(d)被排除集合随索引
182
+ 重排漂移。现改为:本函数只做「遍历 + layer/可见性过滤」,条件过滤与截断都归
183
+ 各接口(条件先行,截断经 `_cap_hits` 只作用于条件命中集)。
184
+ 全量快照遍历是 O(N) 内存操作(历代实测 1.7 万节点 ≈0.04s),是本修正的既定代价。
185
+ 迭代仍取 `list(...)` 快照(H-4(a) 并发纪律:裸迭代在并写索引下会
186
+ RuntimeError)——去掉的只是切片,不是快照。
187
+
188
+ 第 3 层(本批):`nodes` 非 None 时只遍历**索引直取的子集**(条件维由
189
+ `stgidx` 三张表先收窄),逐条仍走 `_scan_one` 同一单点——取数面收窄、
190
+ 语义面(layer/可见性/条目化)逐位不变。
127
191
  """
128
192
  out = []
129
- _sec = getattr(cg, "_readable", None)
130
- for nid, e in list(cg.index["nodes"].items())[:max_scan]:
131
- if layer and e.get("layer") != layer:
132
- continue
133
- if _sec is not None and not _sec(e):
134
- continue
135
- if "temporal" in e or "spatial" in e:
136
- # 效力轴四键必须一并从快照带出:否则 `time_axis="effective"` 在快照
137
- # 路径上永远「不可判定」(静默全空,比报错更难查)。旧索引快照无这些
138
- # 键时 `.get` 得 None → 不可判定,是本轴**如实降级**而非误判。
139
- fm = {"temporal": e.get("temporal"), "spatial": e.get("spatial"),
140
- # 会话归属必须一并从快照带出:timeline 的会话过滤与归属回带都
141
- # 走这条快照路径,缺键 → 本会话视图静默全空(比报错更难查)。
142
- "session": e.get("session"),
143
- trust.EFFECTIVE_FROM_FIELD: e.get(trust.EFFECTIVE_FROM_FIELD),
144
- trust.EFFECTIVE_UNTIL_FIELD: e.get(trust.EFFECTIVE_UNTIL_FIELD),
145
- trust.FROM_FIELD: e.get(trust.FROM_FIELD),
146
- trust.UNTIL_FIELD: e.get(trust.UNTIL_FIELD),
147
- "condition_space": {"time_window": e.get("time_window")}}
148
- else:
149
- fm, _content = cg._read(e)
150
- if fm is None:
151
- continue
152
- out.append({"id": nid, "frontmatter": fm, "layer": e.get("layer"),
153
- "path": e.get("path")})
193
+ if nodes is not None:
194
+ for nid, e in list(nodes):
195
+ n = _scan_one(cg, nid, e, layer)
196
+ if n is not None:
197
+ out.append(n)
198
+ return out
199
+ for nid, e in list(cg.index["nodes"].items()):
200
+ n = _scan_one(cg, nid, e, layer)
201
+ if n is not None:
202
+ out.append(n)
203
+ return out
204
+
205
+
206
+ # 生效条件:hits 为条件命中序列、max_scan 为 None 或可 int 化的单次扫描限额(None 视同不截断)、recency_key 为把单条命中映射到「越大越近期」可比较键的可调用对象时——len(hits) <= cap(cap = max_scan 为 None 时取 len(hits),否则 int(max_scan),负数归 0)返回 (hits 原序, False);超限返回 (sorted(hits, key=recency_key, reverse=True)[:cap], True)。recency_key 由各接口按自己的时间轴构造且须含 id 稳定终键(同一批键下结果与索引物理序无关)。
207
+ def _cap_hits(hits, max_scan, recency_key):
208
+ """条件命中集截断(issue #52 第 1/2 层):只作用于**条件命中集**。
209
+
210
+ 调用点恒在条件过滤之后(条件先行);不过限时不改序、不标记(逐位兼容)。
211
+ 兜底选序=时间倒序优先保留近期——**不是**主修法:主修法是明确检索条件
212
+ 与建立条件索引(docs/plans/stg条件化与结构索引_设计_v0.1.md);选序只保证
213
+ 截断真的发生时,先抛掉的是最久远/最不可判定者,而不是索引尾部。
214
+ """
215
+ n = len(hits)
216
+ cap = n if max_scan is None else int(max_scan)
217
+ if cap < 0:
218
+ cap = 0
219
+ if n <= cap:
220
+ return hits, False
221
+ return sorted(hits, key=recency_key, reverse=True)[:cap], True
222
+
223
+
224
+ # 生效条件:h 为 timeline 命中元组 (start, end, id, layer, session);返回 (start, end, id)——「越大越近期」,id 终键保证与索引物理序无关。
225
+ def _tl_recent(h):
226
+ return (h[0], h[1], h[2])
227
+
228
+
229
+ # 生效条件:h 为 anchors 命中 dict(含 "time" 区间或 None)时返回 (时间可判定否, start, end, id)——时间不可判定者键最小(倒序保留时最先被截),id 终键保证与索引物理序无关。
230
+ def _an_recent(h):
231
+ iv = h.get("time")
232
+ if iv is None:
233
+ return (False, 0.0, 0.0, str(h.get("id")))
234
+ return (True, iv[0], iv[1], str(h.get("id")))
235
+
236
+
237
+ # 生效条件:n 为 _scan 条目、time_axis 为时间轴名时返回 (时间可判定否, start, end, id)(口径同 _an_recent,区间按 time_axis 轴经 _interval 取;非法轴由 _interval 抛 ValueError,不吞错)。
238
+ def _co_recent(n, time_axis):
239
+ iv = _interval(n["frontmatter"], time_axis)
240
+ if iv is None:
241
+ return (False, 0.0, 0.0, str(n["id"]))
242
+ return (True, iv[0], iv[1], str(n["id"]))
243
+
244
+
245
+ # 截断可操作提示(issue #52 第 2 层:禁止静默)——文案必须含「细化生效条件/不适用条件」与「建立条件索引」语义;数值放大(调 max_scan)不是修法,故明文劝阻。
246
+ _CAP_HINT = ("条件命中 %s 条超过单次扫描限额 max_scan=%s,已按时间倒序保留近期 %s 条"
247
+ "(截断不静默):请细化生效条件/不适用条件(如 session/layer/time_window)"
248
+ "收窄命中集,或按设计稿建立条件索引(docs/plans/stg条件化与结构索引_设计_v0.1.md)"
249
+ "——不要调大 max_scan 数值。")
250
+
251
+
252
+ # 生效条件:out 为接口返回体(dict)、scanned 为候选遍历数(layer/可见性过滤后、截断前)、hits 为条件命中总数(截断前)、kept 为截断后保留数、truncated 为其布尔标记、max_scan 为本次限额——恒把 scanned/kept/truncated 三键并入 out;truncated 为真时再并入 hint(_CAP_HINT 插值 hits/max_scan/kept);index_meta 非 None 时把其并入 out["index"](第 3 层读数;**flag 关时为 None、不落键**——关臂返回体与第 1/2 层逐位一致);返回 out。
253
+ def _with_scan_reads(out, *, scanned, hits, kept, truncated, max_scan,
254
+ index_meta=None):
255
+ """读数与截断标记的统一出口(timeline/anchors/consistency 同一口径)。"""
256
+ out.update({"scanned": scanned, "kept": kept, "truncated": truncated})
257
+ if truncated:
258
+ out["hint"] = _CAP_HINT % (hits, max_scan, kept)
259
+ if index_meta is not None:
260
+ out["index"] = index_meta
154
261
  return out
155
262
 
156
263
 
264
+ # ---------------------------------------------------------------------------
265
+ # 第 3 层:结构索引(flag MDCG_STG_INDEX,默认关)——只改取数面
266
+ # ---------------------------------------------------------------------------
267
+
268
+ #: 条件索引开关(契约 §4:先 flag 化、逐项验证、再讨论默认开启)。
269
+ _INDEX_ENV = "MDCG_STG_INDEX"
270
+ #: 条件资格首验开关(只上报不过滤)。
271
+ _QUALIFY_ENV = "MDCG_STG_QUALIFY"
272
+
273
+
274
+ # 生效条件:环境变量 name 取值属 ("1","true","True") 时返回 True,其余(含未设/其它值)返回 False——默认关的开关一律走本判据(与 MDCG_LEGACY_ENV_AUTH 同形)。
275
+ def _flag_on(name):
276
+ return os.environ.get(name) in ("1", "true", "True")
277
+
278
+
279
+ # 生效条件:cg._stg_index 为已构建的 StgIndex 且其 index_obj is cg.index、not broken、len(pos) == len(cg.index["nodes"]) 时返回 (bundle, None);否则返回 (None, reason)——reason ∈ tables_missing(快照不可用/构建失败)/generation_mismatch(表绑的是**另一份**快照:陈旧表当场丢弃,下次访问按新快照重建)/table_snapshot_mismatch(表与快照键数不等:同上丢弃);未构建时按 cg.index 惰性构建一次并挂回 cg._stg_index(首次 stg 需要时构建,写路径零成本)。
280
+ def _index_bundle(cg):
281
+ """取(或首次构建)结构索引;任何不可用情形都带 reason 返回,**恒不静默**。"""
282
+ ix = getattr(cg, "_stg_index", None)
283
+ nodes = (getattr(cg, "index", None) or {}).get("nodes")
284
+ if not isinstance(nodes, dict):
285
+ return None, "tables_missing"
286
+ if ix is not None:
287
+ if getattr(ix, "index_obj", None) is not cg.index:
288
+ # 代际不符(整体换过快照却没走失效点):本次**回退**第 1 层并在
289
+ # meta 上报;陈旧表当场丢弃 ⇒ 下次访问按新快照重建(自愈)。
290
+ cg._stg_index = None
291
+ return None, "generation_mismatch"
292
+ if ix.broken or len(ix.pos) != len(nodes):
293
+ cg._stg_index = None
294
+ return None, "table_snapshot_mismatch"
295
+ return ix, None
296
+ try:
297
+ ix = stgidx.build(cg.index)
298
+ except Exception: # noqa: BLE001 构建失败即回退
299
+ return None, "build_failed"
300
+ if ix is None:
301
+ return None, "tables_missing"
302
+ cg._stg_index = ix
303
+ return ix, None
304
+
305
+
306
+ # 生效条件:ix 为已校验的 StgIndex、served 为其实际服务的维名元组、full 为快照节点总数——恒返回 meta dict(path=index、index_hit=len(served)、index_miss=0、fallback=None、size=表规模、full_nodes=full)。
307
+ def _index_meta_hit(ix, served, full):
308
+ return {"enabled": True, "path": "index", "index_hit": len(served),
309
+ "index_miss": 0, "fallback": None, "full_nodes": full,
310
+ "size": ix.size()}
311
+
312
+
313
+ # 生效条件:reason 为非空回退原因字符串、want 为本次查询**想要**索引服务的维数、full 为快照节点总数(未知时 None)、ix 为可用/不可用的 StgIndex(未知时 None)——恒返回 meta dict(path=full、index_hit=0、index_miss=want、fallback=reason、size=表规模或 None、full_nodes=full)。
314
+ def _index_meta_full(reason, want, full, ix=None):
315
+ return {"enabled": True, "path": "full", "index_hit": 0,
316
+ "index_miss": want, "fallback": reason, "full_nodes": full,
317
+ "size": ix.size() if ix is not None else None}
318
+
319
+
320
+ # 生效条件:session/layer 为维值或 stgidx.MISSING(**MISSING 表示该维不参与;None 是合法维值**=unassigned 桶)、time_range 为 (lo,hi) 或 None、blocked 为「有条件下但该维不可索引」时的回退原因(无则 None);flag 关闭时返回 (None, None)(关臂不落 index 键、不碰索引);否则按 表缺失/代际不符/表-快照不符/迭代期旧快照条目/无条件维 逐一回退并回报 reason,可服务时返回 (pairs(索引物理序的 (nid, entry) 序列), meta(path=index))。
321
+ def _index_pairs(cg, *, session=stgidx.MISSING, layer=stgidx.MISSING,
322
+ time_range=None, blocked=None):
323
+ """条件维 → 索引子集;返回 (pairs | None, meta | None)。
324
+
325
+ **回退即回报**(契约:表缺失/代际不符 ⇒ 回退第 1 层全量遍历,安全降级且
326
+ 可观测,禁止静默):本函数返回 None 的每一条路径都带非空 reason。
327
+ """
328
+ if not _flag_on(_INDEX_ENV):
329
+ return None, None
330
+ want = ((session is not stgidx.MISSING) + (layer is not stgidx.MISSING)
331
+ + (time_range is not None) + (1 if blocked else 0))
332
+ nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
333
+ full = len(nodes)
334
+ ix, reason = _index_bundle(cg)
335
+ if ix is None:
336
+ return None, _index_meta_full(reason, want, full)
337
+ ids, served = ix.subset(session=session, layer=layer,
338
+ time_range=time_range)
339
+ if not served:
340
+ return None, _index_meta_full(blocked or "no_condition_dimension",
341
+ want, full, ix)
342
+ if len(ids) >= full:
343
+ # 子集 == 全量:索引**未收窄**任何面(如层条件恰好覆盖全库)——走索引臂
344
+ # 只会多付一次候选拷贝与逐条取件(5320 节点实测约 +20%),不如直接走
345
+ # 第 1 层全量(设计稿 §3.1「触碰数降到子集」的诚实版:没收窄就别绕)。
346
+ return None, _index_meta_full("no_convergence", want, full, ix)
347
+ pairs = []
348
+ for nid in ids:
349
+ e = nodes.get(nid)
350
+ if e is None:
351
+ # 表里有 id、快照里没有(长度校验放行不了的残余不一致):
352
+ # 宁慢不丢召回——整查询回退,并丢表待重建。
353
+ cg._stg_index = None
354
+ return None, _index_meta_full("table_snapshot_mismatch", want,
355
+ full)
356
+ if "temporal" not in e and "spatial" not in e:
357
+ # 迭代期旧快照条目:`_scan` 会**读文件**取 fm,该节点的 session/
358
+ # layer/时间口径来自盘面而非条目——条目键的分类不保证与之一致
359
+ # (会话视图下会漏召回)。故整查询回退(宁慢不丢召回,可观测)。
360
+ return None, _index_meta_full("entry_file_read", want, full, ix)
361
+ pairs.append((nid, e))
362
+ return pairs, _index_meta_hit(ix, served, full)
363
+
364
+
157
365
  # 生效条件:cg.index["nodes"].get(node_id) 缺失或为假值时返回 "";否则 cg._readable 可调用且对其返回假值或抛异常时返回 PLACEHOLDER_DENIED;cg._read(e) 的 frontmatter 为 None 时返回 "";content 非密文时返回 content[:n](n 默认 200);content 为密文时,cg._open_content 可调用且取到非 None 且非密文的 opened 才返回 opened[:n],opened 为 None、抛异常或仍为密文时返回 PLACEHOLDER_LOCKED。
158
366
  def _preview(cg, node_id, n=200):
159
367
  """按需读单个节点正文做预览(只发生在最终返回的条目上)。
@@ -193,6 +401,69 @@ def _preview(cg, node_id, n=200):
193
401
  return opened[:n]
194
402
 
195
403
 
404
+ # 生效条件:cg 有 index["nodes"][node_id] 且(cg._readable 可调用时)该条目过可见性判定、cg._read 取回 fm 非 None 时返回 {"frontmatter": fm, "content": content}——content 为密文且 cg._open_content 可解出非密文时才用解出的明文,仍为密文/解不开亦返回 None;条目不存在、被读隔离拦下、fm 取不回一律返回 None(**拿不到可判定的正文就不做判定**,不猜测)。
405
+ def _qual_node_dict(cg, node_id):
406
+ """资格判定的 node_dict——取件与脱敏口径与 `_preview` **同一份**(判定不越权)。
407
+
408
+ 密文不可解 ⇒ 返回 None ⇒ 该条不附 qualification:**不伪造状态**(若照密文
409
+ 判 CCG 完整性,会把「无密钥」误报成「要素不全」,那是拿不到证据时的假话)。
410
+ """
411
+ from . import crypto
412
+ e = cg.index["nodes"].get(node_id)
413
+ if not e:
414
+ return None
415
+ guard = getattr(cg, "_readable", None)
416
+ if callable(guard):
417
+ try:
418
+ if not guard(e):
419
+ return None
420
+ except Exception: # noqa: BLE001
421
+ return None
422
+ fm, content = cg._read(e)
423
+ if fm is None:
424
+ return None
425
+ content = content or ""
426
+ if crypto.is_encrypted(content):
427
+ opener = getattr(cg, "_open_content", None)
428
+ opened = None
429
+ if callable(opener):
430
+ try:
431
+ opened = opener(node_id, fm, content)
432
+ except Exception: # noqa: BLE001
433
+ opened = None
434
+ if opened is None or crypto.is_encrypted(opened):
435
+ return None
436
+ content = opened
437
+ return {"frontmatter": fm, "content": content}
438
+
439
+
440
+ # 生效条件:items 为接口最终返回条目序列、context 为情境 dict、query 为情境问句(stg 无自由问句,恒传入 "")时——_QUALIFY_ENV 未启用或 items 为空即原样返回 items;启用时逐条取 _qual_node_dict(None 即该条**不附** qualification,不伪造状态),取到则调 read 面单点 MdCG.judge_qualification(node_dict, query, context) 并把其返回原样挂到 entry["qualification"](判定抛异常该条亦不附);返回 items。**只上报不过滤**:不动成员、不动次序、不动截断面。
441
+ def _attach_qualification(cg, items, context, query=""):
442
+ """条件资格首验(设计稿 §3.4,`MDCG_STG_QUALIFY=1` 显式开)。
443
+
444
+ 语义与 read 面**同口径**:直接复用 `MdCG.judge_qualification` 单点,本模块
445
+ 不另写一份资格判据(契约 §五:不引入第二套条件解析)。stg 无自由问句,
446
+ 情境取自**本次 stg 调用自身的条件面**(op/session/layer/time_window/bbox/
447
+ time_axis),故 query 恒为 ""。
448
+
449
+ 契约红线:**只上报不过滤**——硬过滤(与 read 面同权)另立裁定;本函数的
450
+ 调用点在各接口返回体成型**之后**,任何筛选/截断都不经过它。
451
+ """
452
+ if not items or not _flag_on(_QUALIFY_ENV):
453
+ return items
454
+ from .mdcg import MdCG
455
+ for it in items:
456
+ nid = it.get("id")
457
+ nd = _qual_node_dict(cg, nid) if nid else None
458
+ if nd is None:
459
+ continue
460
+ try:
461
+ it["qualification"] = MdCG.judge_qualification(nd, query, context)
462
+ except Exception: # noqa: BLE001 判定异常不伪状态
463
+ continue
464
+ return items
465
+
466
+
196
467
  # 生效条件:cg 上 _node(cg, a_id) 与 _node(cg, b_id) 均返回真值时返回含 a_id/b_id、时间关系、空间关系和 time_known/space_known 的 dict(两侧时间区间均按 time_axis 轴取,见 _interval;time_axis 非法经 trust.time_axis_of 抛 ValueError);任一 _node 结果为假值时返回 {"error":"node_not_found","missing":[...]};
197
468
  def relation(cg, a_id, b_id, time_axis="observed"):
198
469
  """两节点间的时空关系(a 相对 b)。`time_axis` 决定时间区间取哪条轴(见 `_interval`)。"""
@@ -252,7 +523,7 @@ def _view_session(session):
252
523
  return _normalize_session(s)
253
524
 
254
525
 
255
- # 生效条件:以 _scan(cg,layer=layer,max_scan=max_scan) 为范围,session 经 _view_session 归一后(None/空/"*"=跨会话不过滤,其它值=归一后的具体会话)非跨会话时仅保留 frontmatter.session 精确相等的节点,_interval(n["frontmatter"], time_axis) 为 None 的节点被跳过,其余按 (start,end) 以 reverse=bool(desc) 排序,返回 count=全部命中数、limit=传入 limit、session=生效的会话过滤值(跨会话时为 None)、items 为排序后前 limit 项(limit=0 时为空列表)且每项附 session 归属与 _preview(cg,id)(time_axis 缺省 observed,与旧行为逐位一致;非法轴抛 ValueError)。
526
+ # 生效条件:以 _scan(cg,layer=layer) 为候选(全部 layer/可见性命中),session 经 _view_session 归一后(None/空/"*"=跨会话不过滤,其它值=归一后的具体会话)非跨会话时仅保留 frontmatter.session 精确相等的节点,_interval(n["frontmatter"], time_axis) 为 None 的节点被跳过——**条件命中集到此确定**;随后才截断:命中集超过 max_scan(默认 5000 不变)时按 (start,end,id) 时间倒序保留近期 max_scan 条并标记 truncated;保留集按 (start,end,id) 以 reverse=bool(desc) 排序,返回 count=条件命中总数(截断前,**不再受索引切片影响**)、limit=传入 limit、session=生效的会话过滤值(跨会话时为 None)、scanned=候选遍历数、kept=截断后保留数、truncated=截断标记(截断时另有 hint)、items 为排序后前 limit 项(limit=0 时为空列表)且每项附 session 归属与 _preview(cg,id)(time_axis 缺省 observed,与旧行为逐位一致;非法轴抛 ValueError)。
256
527
  def timeline(cg, layer=None, limit=50, desc=True, max_scan=5000,
257
528
  time_axis="observed", session=None):
258
529
  """按时间排序的节点列表。`time_axis` 决定排序依据的时间区间(见 `_interval`)。
@@ -267,11 +538,24 @@ def timeline(cg, layer=None, limit=50, desc=True, max_scan=5000,
267
538
  「写进去查不出」;返回体 `session` 回带的是**归一后**的生效值。
268
539
  `items` 一并回带 `session`:跨会话视图下「这条是哪个会话做的」必须可辨,
269
540
  否则「能读到所有会话做了什么」只剩内容、丢了归属。
541
+
542
+ issue #52(条件先行 + 截断可观测):`max_scan`(默认 5000,数值不变)
543
+ **只在条件命中集上生效**——旧实现把它当索引序切片(在 session/时间条件
544
+ 过滤**之前**砍尾巴),本会话记忆因落在切片外而整片消失且没有任何标记;
545
+ 现在 `count` 恒为条件命中总数(不受切片影响),命中集超限才按时间倒序
546
+ 保留近期,并在返回体上报 `truncated`/`scanned`/`kept`/`hint`。
270
547
  """
271
548
  sid = _view_session(session)
272
549
  cross = sid in ("", "*") # 跨会话:显式 "*" 与缺省同义
550
+ # 第 3 层:会话/层两维由结构索引直取(跨会话视图下会话维不参与——它本就
551
+ # 「不过滤」;具体会话值走 by_session[归一后 sid],与基线等值比较同一把尺)。
552
+ pairs, imeta = _index_pairs(
553
+ cg, session=stgidx.MISSING if cross else sid,
554
+ layer=layer if layer else stgidx.MISSING)
555
+ scanned = 0
273
556
  items = []
274
- for n in _scan(cg, layer=layer, max_scan=max_scan):
557
+ for n in _scan(cg, layer=layer, nodes=pairs):
558
+ scanned += 1
275
559
  fm = n["frontmatter"] or {}
276
560
  if not cross and fm.get("session") != sid:
277
561
  continue
@@ -279,18 +563,34 @@ def timeline(cg, layer=None, limit=50, desc=True, max_scan=5000,
279
563
  if iv is None:
280
564
  continue
281
565
  items.append((iv[0], iv[1], n["id"], n["layer"], fm.get("session")))
566
+ hits = len(items) # 条件命中总数(截断前,count 的口径)
567
+ items, truncated = _cap_hits(items, max_scan, _tl_recent)
568
+ kept = len(items)
282
569
  items.sort(key=lambda x: (x[0], x[1], x[2]), reverse=bool(desc))
283
- return {"count": len(items), "limit": limit,
284
- "session": None if cross else sid,
285
- "items": [{"id": i, "layer": l, "start": s, "end": e,
286
- "session": sn, "preview": _preview(cg, i)}
287
- for s, e, i, l, sn in items[:limit]]}
288
-
289
-
290
- # 生效条件:time_window 为长度 2 的 list/tuple 时 q_t=(float(time_window[0]),float(time_window[1]))(元素不可转 float 会直接抛异常,源码未捕获),bbox 为长度 4 的 list/tuple 时同理构造 q_b;q_t 与 q_b 均为 None 时返回 {"error":"need_time_window_or_bbox"};否则扫描节点、每节点时间区间按 _interval(fm, time_axis) 取(time_axis 缺省 observed 与旧行为逐位一致,非法轴抛 ValueError),并要求时间关系在 during/contains/overlaps/equals、空间关系在 inside/contains/overlaps/equals(提供查询侧才检查),返回 hits[:limit](limit=None 取全部,0/False 取空);
570
+ out_items = _attach_qualification(
571
+ cg, [{"id": i, "layer": l, "start": s, "end": e,
572
+ "session": sn, "preview": _preview(cg, i)}
573
+ for s, e, i, l, sn in items[:limit]],
574
+ {"stg": "timeline", "session": None if cross else sid,
575
+ "layer": layer, "time_axis": time_axis})
576
+ return _with_scan_reads(
577
+ {"count": hits, "limit": limit,
578
+ "session": None if cross else sid, "items": out_items},
579
+ scanned=scanned, hits=hits, kept=kept, truncated=truncated,
580
+ max_scan=max_scan, index_meta=imeta)
581
+
582
+
583
+ # 生效条件:time_window 为长度 2 的 list/tuple 时 q_t=(float(time_window[0]),float(time_window[1]))(元素不可转 float 会直接抛异常,源码未捕获),bbox 为长度 4 的 list/tuple 时同理构造 q_b;q_t 与 q_b 均为 None 时返回 {"error":"need_time_window_or_bbox"};否则以 _scan(cg,layer=layer) 为候选逐节点取 _interval(fm, time_axis)(time_axis 缺省 observed 与旧行为逐位一致,非法轴抛 ValueError)与 _bbox,要求时间关系在 during/contains/overlaps/equals、空间关系在 inside/contains/overlaps/equals(提供查询侧才检查)——**条件命中集到此确定**;随后才截断:命中集超过 max_scan(默认 5000 不变)时按 (时间可判定否,start,end,id) 倒序保留近期 max_scan 条并标记 truncated(无时间区间者最先被截);返回 count=条件命中总数(截断前)、query、scanned=候选遍历数、kept=截断后保留数、truncated=截断标记(截断时另有 hint)、items=hits[:limit](limit=None 取全部,0/False 取空)且每条附 preview;
291
584
  def anchors(cg, time_window=None, bbox=None, layer=None, limit=50, max_scan=5000,
292
585
  time_axis="observed"):
293
- """落在给定时间窗 / 空间范围内的节点。`time_axis` 决定候选时间区间(见 `_interval`)。"""
586
+ """落在给定时间窗 / 空间范围内的节点。`time_axis` 决定候选时间区间(见 `_interval`)。
587
+
588
+ issue #52(条件先行 + 截断可观测):时间窗/空间范围的条件命中集先于
589
+ `max_scan`(默认 5000,数值不变)确定——`count` 恒为条件命中总数,
590
+ 命中集超限才按时间倒序保留近期(无时间区间者先被截),并上报
591
+ `truncated`/`scanned`/`kept`/`hint`;旧实现按索引序切片在条件**之前**,
592
+ 命中项落在切片外即静默消失。
593
+ """
294
594
  q_t = None
295
595
  if isinstance(time_window, (list, tuple)) and len(time_window) == 2:
296
596
  q_t = (float(time_window[0]), float(time_window[1]))
@@ -300,8 +600,32 @@ def anchors(cg, time_window=None, bbox=None, layer=None, limit=50, max_scan=5000
300
600
  if q_t is None and q_b is None:
301
601
  return {"error": "need_time_window_or_bbox"}
302
602
 
603
+ # 第 3 层:时间维经 by_time 区间直取(层维同 timeline)。三条回退条件都在
604
+ # 索引面**如实上报**(禁止静默):
605
+ # · 非观察轴(effective 等):表按观察轴建,换轴即无法预筛 ⇒ 不索引该维;
606
+ # · 倒置查询窗(qs > qe):`equals` 命中可要求 a1 = qs > qe,「t ≤ qe」的
607
+ # 预筛会漏 ⇒ 不索引该维(宁慢不丢召回);
608
+ # · 非法轴:不索引 ⇒ 回退后由 `_interval` 在**与基线同一处**抛 ValueError。
609
+ time_range, blocked = None, None
610
+ if q_t is not None:
611
+ try:
612
+ _observed = trust.time_axis_of(time_axis) == "observed"
613
+ except ValueError:
614
+ _observed = False
615
+ if not _observed:
616
+ blocked = "time_axis_not_indexed"
617
+ elif q_t[0] <= q_t[1]:
618
+ time_range = (q_t[0], q_t[1])
619
+ else:
620
+ blocked = "inverted_query_window"
621
+ pairs, imeta = _index_pairs(
622
+ cg, layer=layer if layer else stgidx.MISSING,
623
+ time_range=time_range, blocked=blocked)
624
+
625
+ scanned = 0
303
626
  hits = []
304
- for n in _scan(cg, layer=layer, max_scan=max_scan):
627
+ for n in _scan(cg, layer=layer, nodes=pairs):
628
+ scanned += 1
305
629
  fm = n["frontmatter"]
306
630
  iv, bb = _interval(fm, time_axis), _bbox(fm)
307
631
  t_rel = time_relation(iv, q_t) if (q_t and iv) else None
@@ -312,13 +636,24 @@ def anchors(cg, time_window=None, bbox=None, layer=None, limit=50, max_scan=5000
312
636
  continue
313
637
  hits.append({"id": n["id"], "layer": n["layer"], "time": iv, "bbox": bb,
314
638
  "time_relation": t_rel, "space_relation": s_rel})
639
+ total = len(hits) # 条件命中总数(截断前,count 的口径)
640
+ hits, truncated = _cap_hits(hits, max_scan, _an_recent)
641
+ kept = len(hits)
315
642
  for h in hits[:limit]:
316
643
  h["preview"] = _preview(cg, h["id"])
317
- return {"count": len(hits), "query": {"time_window": q_t, "bbox": q_b},
318
- "items": hits[:limit]}
319
-
320
-
321
- # 生效条件:遍历 _scan(cg,layer=layer,max_scan=max_scan) 每条 frontmatter,bb 非 None 且不满足 bb[0]<=bb[2] and bb[1]<=bb[3] 记 invalid_bbox、iv(由 _interval(fm, time_axis) 取,time_axis 缺省 observed 与旧行为逐位一致、非法轴抛 ValueError)非 None 且 iv[0]>iv[1] 记 inverted_time_window、temporal 与 time_window 均经 trust.epoch_seconds 归一后可比且不满足 tw[0]<=t<=tw[1] 记 temporal_outside_window(该检查恒按观察轴内部口径、不随 time_axis 漂移;任一端不可转数值则忽略),返回 scanned 计数、issues 总数与 issues[:limit](limit 默认 50)。
644
+ _attach_qualification(
645
+ cg, hits[:limit],
646
+ {"stg": "anchors", "time_window": list(q_t) if q_t else None,
647
+ "bbox": list(q_b) if q_b else None, "layer": layer,
648
+ "time_axis": time_axis})
649
+ return _with_scan_reads(
650
+ {"count": total, "query": {"time_window": q_t, "bbox": q_b},
651
+ "items": hits[:limit]},
652
+ scanned=scanned, hits=total, kept=kept, truncated=truncated,
653
+ max_scan=max_scan, index_meta=imeta)
654
+
655
+
656
+ # 生效条件:以 cand=list(_scan(cg,layer=layer)) 为候选(scanned=len(cand) 为遍历读数,layer/可见性即其条件面),**先**按 (时间可判定否,start,end,id) 时间倒序截断到 max_scan(默认 5000 不变,截断时 kept=保留数、truncated=True 并附 hint)——随后逐条检查:bb 非 None 且不满足 bb[0]<=bb[2] and bb[1]<=bb[3] 记 invalid_bbox、iv(由 _interval(fm, time_axis) 取,time_axis 缺省 observed 与旧行为逐位一致、非法轴抛 ValueError)非 None 且 iv[0]>iv[1] 记 inverted_time_window、temporal 与 time_window 均经 trust.epoch_seconds 归一后可比且不满足 tw[0]<=t<=tw[1] 记 temporal_outside_window(该检查恒按观察轴内部口径、不随 time_axis 漂移;任一端不可转数值则忽略);返回 issues 总数与 issues[:limit](limit 默认 50)、scanned=遍历读数、kept=实际检查节点数、truncated=截断标记(截断时另有 hint)。
322
657
  def consistency(cg, layer=None, limit=50, max_scan=5000, time_axis="observed"):
323
658
  """时空字段自洽性检查:非法 bbox / 时间倒置 / 窗口与时刻冲突。
324
659
 
@@ -329,11 +664,21 @@ def consistency(cg, layer=None, limit=50, max_scan=5000, time_axis="observed"):
329
664
  两端比较前统一经 `trust.epoch_seconds` 归一:旧实现裸 `float` 比较,历史毫秒
330
665
  节点的 `1.7e12` 与秒级 `temporal` 永不落入区间 → 该检查在真实库中**静默失效**
331
666
  (issue #23 同根因)。返回体的 `temporal` / `time_window` 也随之为归一后的秒值。
667
+
668
+ issue #52(条件先行 + 截断可观测):候选/条件命中集(layer × 可见性)
669
+ **先于** `max_scan`(默认 5000,数值不变)确定;超限时按时间倒序保留近期
670
+ 再检查——`kept` 为实际检查数、`truncated` 显式上报(旧实现按索引序切片
671
+ 且无任何标记,「读不全」与「读不到」不可区分)。
332
672
  """
673
+ # 第 3 层:候选/条件面就是「层 × 可见性」,层维由 by_layer 直取(层缺省时
674
+ # 无维可索引 ⇒ 回退全量并上报 no_condition_dimension)。
675
+ pairs, imeta = _index_pairs(cg, layer=layer if layer else stgidx.MISSING)
676
+ cand = list(_scan(cg, layer=layer, nodes=pairs))
677
+ scanned = len(cand)
678
+ cand, truncated = _cap_hits(cand, max_scan, lambda n: _co_recent(n, time_axis))
679
+ kept = len(cand)
333
680
  issues = []
334
- scanned = 0
335
- for n in _scan(cg, layer=layer, max_scan=max_scan):
336
- scanned += 1
681
+ for n in cand:
337
682
  fm = n["frontmatter"]
338
683
  bb, iv = _bbox(fm), _interval(fm, time_axis)
339
684
  if bb and not (bb[0] <= bb[2] and bb[1] <= bb[3]):
@@ -348,5 +693,10 @@ def consistency(cg, layer=None, limit=50, max_scan=5000, time_axis="observed"):
348
693
  if lo is not None and hi is not None and not (lo <= t <= hi):
349
694
  issues.append({"id": n["id"], "issue": "temporal_outside_window",
350
695
  "temporal": t, "time_window": [lo, hi]})
351
- return {"scanned": scanned, "issues": len(issues), "limit": limit,
352
- "items": issues[:limit]}
696
+ _attach_qualification(
697
+ cg, issues[:limit],
698
+ {"stg": "consistency", "layer": layer, "time_axis": time_axis})
699
+ return _with_scan_reads(
700
+ {"issues": len(issues), "limit": limit, "items": issues[:limit]},
701
+ scanned=scanned, hits=scanned, kept=kept, truncated=truncated,
702
+ max_scan=max_scan, index_meta=imeta)