@furongjun1999/dsh-memory 0.6.0 → 0.6.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 +588 -572
- package/codebuddy/CODEBUDDY.md +10 -10
- package/data/policy.json +27 -0
- package/docs/discipline/harnesses.yaml +31 -0
- package/docs/discipline/templates/full.md.tmpl +1 -1
- package/docs/discipline/templates/rules.mdc.tmpl +1 -1
- package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
- package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
- package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
- package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
- package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
- package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
- package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
- package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
- package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
- package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v20.md +283 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v21.md +224 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v22.md +223 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v23.md +293 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v24.md +303 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v25.md +230 -0
- 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 +20 -0
- package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
- package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -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 +21 -9
- package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
- package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +39 -3
- package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
- package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
- package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
- package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +460 -460
- package/lib/bridge.js +24 -2
- package/lib/hooks.d.ts +31 -2
- package/lib/hooks.js +219 -16
- package/lib/init.js +16 -1
- package/lib/lib/prompt_safety.d.ts +52 -1
- package/lib/lib/prompt_safety.js +76 -1
- package/md_cg/audit.py +652 -379
- package/md_cg/bench6_arms.py +1 -1
- package/md_cg/bench_p0.py +1 -1
- package/md_cg/branches.py +2 -2
- package/md_cg/ccgc.py +1071 -1006
- package/md_cg/chain.py +1 -1
- package/md_cg/consistency.py +97 -9
- package/md_cg/consolidate.py +34 -9
- package/md_cg/crypto.py +59 -26
- package/md_cg/docindex.py +16 -1
- package/md_cg/evolution.py +17 -1
- package/md_cg/export.py +1 -1
- package/md_cg/forgetting.py +309 -13
- package/md_cg/fsutil.py +454 -8
- package/md_cg/identity.py +3 -3
- package/md_cg/insight.py +24 -3
- package/md_cg/linkref.py +1 -1
- package/md_cg/logref.py +327 -0
- package/md_cg/mcp_server.py +4152 -3818
- package/md_cg/mdcg.py +4208 -3547
- package/md_cg/mdcos.py +4491 -4159
- package/md_cg/mreview/pipeline.py +15 -1
- package/md_cg/nodefile.py +639 -575
- package/md_cg/protect.py +158 -11
- package/md_cg/protocol.py +41 -2
- package/md_cg/provenance.py +22 -4
- package/md_cg/reach.py +4 -4
- package/md_cg/readcache.py +76 -15
- package/md_cg/reconcile.py +1 -1
- package/md_cg/refindex.py +246 -37
- package/md_cg/refine.py +1 -1
- package/md_cg/routing.py +32 -5
- package/md_cg/run_tests.py +17 -0
- package/md_cg/scrub.py +30 -3
- package/md_cg/security.py +47 -1
- package/md_cg/self_state.py +5 -5
- package/md_cg/sources.py +23 -6
- package/md_cg/srcindex.py +352 -0
- package/md_cg/stg.py +47 -3
- package/md_cg/subgraph.py +2 -2
- package/md_cg/sustain.py +1299 -1168
- package/md_cg/tasks.py +469 -470
- package/md_cg/test_b1_auto_id_multiproc.py +277 -0
- package/md_cg/test_b1b2_write_face.py +417 -0
- package/md_cg/test_b2_sensitivity_landing.py +223 -0
- package/md_cg/test_b3_merge_keeps_content.py +576 -0
- package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
- package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
- package/md_cg/test_c3_transient_read_negative.py +793 -0
- package/md_cg/test_c8_search_rrf_gates.py +502 -0
- package/md_cg/test_ccg_form_parity.py +188 -0
- package/md_cg/test_ccgc.py +28 -3
- package/md_cg/test_govern_directread.py +17 -4
- package/md_cg/test_h2_session_view_norm.py +233 -0
- package/md_cg/test_h4_sustain_snapshot.py +1377 -0
- package/md_cg/test_hive_ingest.py +285 -285
- package/md_cg/test_issue39_utf8_stdio.py +307 -16
- package/md_cg/test_issue43_default_policy.py +865 -0
- package/md_cg/test_legacy_p3_node_id_type.py +375 -0
- package/md_cg/test_linkref.py +10 -3
- package/md_cg/test_lock.py +2 -2
- package/md_cg/test_logref.py +1109 -0
- package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
- package/md_cg/test_m3_h9_semantic_guard.py +525 -0
- package/md_cg/test_mr_m2.py +15 -3
- package/md_cg/test_n130_verify_falsified_protect.py +1 -1
- package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
- package/md_cg/test_n176_link_trust.py +221 -0
- package/md_cg/test_n178_units_jobid_gate.py +212 -0
- package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
- package/md_cg/test_n195_writepath_reload.py +283 -0
- package/md_cg/test_n196_stale_gate_skip.py +169 -0
- package/md_cg/test_n197_n208_write_face_gates.py +471 -0
- package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
- package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
- package/md_cg/test_n201_proposal_visibility.py +382 -0
- package/md_cg/test_n202_session_notes_visibility.py +461 -0
- package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
- package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
- package/md_cg/test_n209_verify_write_face_gates.py +461 -0
- package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
- package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
- package/md_cg/test_n225_nonobject_load.py +1110 -0
- package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
- package/md_cg/test_neg_condition_hits.py +333 -0
- package/md_cg/test_neg_tail_honesty.py +663 -0
- package/md_cg/test_none_id_write_guard.py +4 -3
- package/md_cg/test_opt_batch1_md_cg.py +451 -0
- package/md_cg/test_p1.py +5 -5
- package/md_cg/test_p11_consistency.py +161 -12
- package/md_cg/test_p26_refindex.py +2 -2
- package/md_cg/test_p27_docindex.py +777 -774
- package/md_cg/test_p28_refcheck.py +740 -13
- package/md_cg/test_p29_session_ingest_export.py +2 -2
- package/md_cg/test_p2_mcp.py +12 -1
- package/md_cg/test_p30_maintain.py +33 -2
- package/md_cg/test_p31_insight.py +1 -0
- package/md_cg/test_p9c_dedup_hints.py +276 -0
- package/md_cg/test_policy_required_ccg.py +426 -0
- package/md_cg/test_protocol.py +22 -0
- package/md_cg/test_rank_parity_score_mode.py +516 -0
- package/md_cg/test_read_face_input_gates.py +363 -0
- package/md_cg/test_read_face_semantics.py +489 -0
- package/md_cg/test_recall_face_guards.py +812 -0
- package/md_cg/test_rejected_credential_forms.py +188 -0
- package/md_cg/test_rejected_redact.py +146 -0
- package/md_cg/test_retr_s1b.py +2 -2
- package/md_cg/test_retr_s5.py +20 -7
- package/md_cg/test_review_conformance.py +21 -3
- package/md_cg/test_security_audit_v21.py +2 -2
- package/md_cg/test_server_version.py +67 -0
- package/md_cg/test_srcindex.py +171 -0
- package/md_cg/test_tenant_env_override_warn.py +63 -50
- package/md_cg/test_token_lowercase_form.py +315 -0
- package/md_cg/test_v21r1_package_version.py +310 -0
- package/md_cg/test_writepipe.py +10 -4
- package/md_cg/tokens.py +225 -95
- package/md_cg/tool_face.py +4 -4
- package/md_cg/trust.py +49 -5
- package/md_cg/units.py +204 -6
- package/md_cg/weights.py +4 -4
- package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
- package/md_cg/writelimit.py +42 -8
- package/md_cg/writepipe.py +651 -554
- package/package.json +3 -2
- package/skills/plugin.json +1 -1
- package/src/bridge.ts +25 -2
- package/src/hooks.ts +229 -16
- package/src/init.ts +15 -1
- package/src/lib/prompt_safety.ts +88 -1
- package/zcode/AGENTS.md +10 -10
package/md_cg/refindex.py
CHANGED
|
@@ -25,6 +25,18 @@
|
|
|
25
25
|
「回读说没漂、巡检说有漂」。与 `region_hash` 的教训同源:区间哈希只允许一份实现,
|
|
26
26
|
这里连「怎么判定 ok / stale / dangling」也只允许一份。
|
|
27
27
|
|
|
28
|
+
**分工:节点判漂移,水位只判跳过**
|
|
29
|
+
漂移 / 悬空**只由节点自身 frontmatter ref 判**(`probe_ref` / `read_ref` 的唯一实现);
|
|
30
|
+
水位(`_refindex.json`)**不进 probe**——它只判「这个节点能不能跳过、不读源」。
|
|
31
|
+
跳过的充分条件是三条同时成立:①节点 ref 与水位条目
|
|
32
|
+
`(hash, lineno, end, path, root)` **五项全等**;②源文件 size/mtime 未变;
|
|
33
|
+
③该 node_id 本轮尚未判过(跨大域撞 id 时同一 id 会被多条水位条目列出,只判一次)。
|
|
34
|
+
五项里含 `path` / `root` 是必要的:不含时跨域撞 id 在稳态不可见(节点 ref 指向另一域
|
|
35
|
+
而水位条目取自本域),且不一致会重复报红(同一 node_id 两条 stale)。
|
|
36
|
+
不一致本身**不改判定**(不进 `ok`、不产生新红行),只记 `ledger_mismatch` 归因行,
|
|
37
|
+
用两侧 root 分辨两类成因:roots 相同=`watermark_behind`(写序断裂 / 旧水位)、
|
|
38
|
+
roots 不同=`collision_multi_root`(id 不含 root 的跨域撞 id 归属)。
|
|
39
|
+
|
|
28
40
|
零第三方依赖。
|
|
29
41
|
"""
|
|
30
42
|
from __future__ import annotations
|
|
@@ -240,14 +252,21 @@ class Ledger:
|
|
|
240
252
|
files.pop(k, None)
|
|
241
253
|
return len(dead)
|
|
242
254
|
|
|
243
|
-
# 生效条件:当 kind、root、files、indexed、truncated 传入时,self.load()["last_index"] 被设为含 ts=_now()、kind、root、files、indexed、truncated=bool(truncated)、truncated_reason=reason or "" 的字典;reason 为假值(默认 ""/None)时 truncated_reason 回落 "";
|
|
255
|
+
# 生效条件:当 kind、root、files、indexed、truncated 传入时,self.load()["last_index"] 被设为含 ts=_now()、kind、root、files、indexed、truncated=bool(truncated)、truncated_reason=reason or ""、empty_scan=bool(empty_scan) 的字典;reason 为假值(默认 ""/None)时 truncated_reason 回落 "";
|
|
244
256
|
def note_index(self, *, kind: str, root: str, files: int, indexed: int,
|
|
245
|
-
truncated: bool, reason: str = ""
|
|
246
|
-
|
|
257
|
+
truncated: bool, reason: str = "",
|
|
258
|
+
empty_scan: bool = False) -> None:
|
|
259
|
+
"""记「最近一次索引」结果——截断与空扫都在这里留痕,供 diagnose 看见。
|
|
260
|
+
|
|
261
|
+
`empty_scan=True` ⇒ 这一次**一个文件都没走过**(目录空/全被排除/全读不回):
|
|
262
|
+
`files` 如实记 0,且这一次不做对账(见 `index_dir`),两个面因此对齐——
|
|
263
|
+
水位条目不被剪空,而 `last_index.files=0` 也不假装扫过。
|
|
264
|
+
"""
|
|
247
265
|
self.load()["last_index"] = {
|
|
248
266
|
"ts": _now(), "kind": kind, "root": root, "files": files,
|
|
249
267
|
"indexed": indexed, "truncated": bool(truncated),
|
|
250
268
|
"truncated_reason": reason or "",
|
|
269
|
+
"empty_scan": bool(empty_scan),
|
|
251
270
|
}
|
|
252
271
|
|
|
253
272
|
# 生效条件:无参数调用即生效,取 self.load() 结果把 updated_at 置为 _now(),再以 atomic_write 把 json.dumps(..., ensure_ascii=False, indent=1, sort_keys=True) 写入 self.path,无返回值。
|
|
@@ -281,15 +300,26 @@ class Ledger:
|
|
|
281
300
|
# 统一 index_dir:调度 + 水位 + 落盘(供 op=index_code / op=index_doc / heal 共用)
|
|
282
301
|
# --------------------------------------------------------------------------
|
|
283
302
|
|
|
284
|
-
# 生效条件:root 为源大域根、kind 为 'code_ref'/'doc_ref' 时经 _mod(kind) 调度底层 index_dir 并返回 (items, errors, stats);ledger 非空时逐文件 record,incremental 为真时跳过 ledger.is_fresh
|
|
303
|
+
# 生效条件:root 为源大域根、kind 为 'code_ref'/'doc_ref' 时经 _mod(kind) 调度底层 index_dir 并返回 (items, errors, stats);ledger 非空时逐文件 record,incremental 为真时跳过 ledger.is_fresh 为真的文件,stats 未截断且 seen 非空时执行 reconcile,commit 为假时不 save(由调用方在节点写成功后 save)。
|
|
285
304
|
def index_dir(root: str, *, kind: str, patterns=None, max_files: int = 500,
|
|
286
305
|
max_items: int = 2000, incremental: bool = False,
|
|
287
|
-
ledger: "Ledger" = None, skip_dirs=None):
|
|
306
|
+
ledger: "Ledger" = None, skip_dirs=None, commit: bool = True):
|
|
288
307
|
"""按 kind 调度 codeindex / docindex 的全量(或增量)索引。
|
|
289
308
|
|
|
290
309
|
incremental=True 且给了 ledger 时:未变文件跳过(`skipped_unchanged`)。
|
|
291
310
|
返回 (items, errors, stats),与底层 index_dir 的返回一致(多一个
|
|
292
|
-
`skipped_unchanged`)。
|
|
311
|
+
`skipped_unchanged`;给了 ledger 时另加 `empty_scan`)。
|
|
312
|
+
|
|
313
|
+
**零信息纪律(对账)**:`seen` 为空=这次一个文件都没走过(目录空 / 全被排除 /
|
|
314
|
+
全读不回),此时「没见到」不等于「源已消失」——**不对账**。否则一次空扫就能把
|
|
315
|
+
该域的水位整片剪空(实测:目录仍在而零文件,prune 删 0、reconcile 删 2)。
|
|
316
|
+
`prune` 照旧执行:它的判据是「root 是否还是目录」,与本次扫到几个文件无关
|
|
317
|
+
(实测「目录仍在零文件」时 prune 删 0)。
|
|
318
|
+
|
|
319
|
+
**写序(`commit`)**:`commit=False` 时照做 prune / reconcile / note_index,
|
|
320
|
+
但不落盘——由调用方在**节点写成功之后**再 `ledger.save()`。这样进程在两步之间
|
|
321
|
+
被杀,残留方向是「节点新 + 水位旧」(下次增量重切该文件),而不是「水位新 +
|
|
322
|
+
节点旧」(节点被判成已索引、漂移静默)。
|
|
293
323
|
|
|
294
324
|
`skip_dirs` 透传给底层:**追加**排除、只增不减(内置 `.git`/`.venv`/
|
|
295
325
|
`node_modules` 等不可被关闭),见 `codeindex.skip_matcher`。实际排掉了哪些目录
|
|
@@ -321,14 +351,21 @@ def index_dir(root: str, *, kind: str, patterns=None, max_files: int = 500,
|
|
|
321
351
|
fresh=fresh, on_file=on_file, skip_dirs=skip_dirs,
|
|
322
352
|
)
|
|
323
353
|
if ledger is not None:
|
|
354
|
+
# 空扫透出(stats 与 last_index 两面同值):这不是「源全没了」,而是
|
|
355
|
+
# 「这一轮什么都没走到」——两个面都如实记,别互相打架。
|
|
356
|
+
stats["empty_scan"] = not seen
|
|
324
357
|
ledger.prune()
|
|
325
|
-
if not stats.get("truncated"):
|
|
326
|
-
#
|
|
358
|
+
if seen and not stats.get("truncated"):
|
|
359
|
+
# 对账的两个前提:①**这次真的走过文件**(空扫 ⇒ 「没见到」不代表
|
|
360
|
+
# 「源已消失」,否则一次空扫就把水位剪空);②没被截断(没扫完 ≠
|
|
361
|
+
# 剩下的都消失了)。
|
|
327
362
|
ledger.reconcile(root, kind, seen)
|
|
328
363
|
ledger.note_index(kind=kind, root=root, files=stats.get("files", 0),
|
|
329
364
|
indexed=len(items), truncated=bool(stats.get("truncated")),
|
|
330
|
-
reason=stats.get("truncated_reason") or ""
|
|
331
|
-
|
|
365
|
+
reason=stats.get("truncated_reason") or "",
|
|
366
|
+
empty_scan=stats["empty_scan"])
|
|
367
|
+
if commit:
|
|
368
|
+
ledger.save()
|
|
332
369
|
return items, errors, stats
|
|
333
370
|
|
|
334
371
|
|
|
@@ -348,13 +385,19 @@ def _domain_of(it: dict) -> str:
|
|
|
348
385
|
return path.split("/")[0] or "orphan"
|
|
349
386
|
|
|
350
387
|
|
|
351
|
-
# 生效条件:kind == 'code_ref' 时按 codeindex.node_id/render 写入 cg(tags 含 'code'、code_ref=_code_ref(it, root)),kind == 'doc_ref' 时按 docindex 写入(tags 含 'doc'、doc_ref=_doc_ref(it, root)、密级取自 docindex.sensitivity_for(it['path'], sensitivity)),其他 kind 抛 ValueError,返回 (ids, sens)。
|
|
388
|
+
# 生效条件:kind == 'code_ref' 时按 codeindex.node_id/render 写入 cg(tags 含 'code'、code_ref=_code_ref(it, root)),kind == 'doc_ref' 时按 docindex 写入(tags 含 'doc'、doc_ref=_doc_ref(it, root, src_of(it))、密级取自 docindex.sensitivity_for(it['path'], sensitivity)),其他 kind 抛 ValueError,返回 (ids, sens)。
|
|
352
389
|
def add_items(cg, items, *, kind: str, root: str, layer=None, sensitivity=None,
|
|
353
|
-
layer_of=None):
|
|
390
|
+
layer_of=None, src_of=None, extra_of=None):
|
|
354
391
|
"""把索引条目写进认知图(code / doc 的落盘细节收在这里,唯一实现)。
|
|
355
392
|
|
|
356
393
|
- `layer=None` → 默认 `knowledge`(与代码节点同层,保证进默认召回)。
|
|
357
394
|
- `layer_of(nid)` 可逐节点覆盖 layer(heal 重建时保留原层)。
|
|
395
|
+
- `src_of(it)` 为真时把返回值写进 `doc_ref["src"]`(真源身份层,见
|
|
396
|
+
`_doc_ref`)——不传时产物**逐字节不变**。
|
|
397
|
+
- `extra_of(it)` 返回 `{"tags": [...], "attrs": {...}}`:追加标签与
|
|
398
|
+
frontmatter 键(日志索引的 `dsh-log/logsrc/regenerable/ws:` 与
|
|
399
|
+
`session/workspace/dsh_session_uuid` 走这里,免得在 `logref` 里
|
|
400
|
+
复制一遍 doc 分支而与本函数漂移)。与既有 `layer_of` 同款逐条钩子。
|
|
358
401
|
- doc 节点:密级走 `docindex.sensitivity_for`(只可能更严);返回密级分布。
|
|
359
402
|
- `condition_space` 走 `codeindex/docindex.condition_space`,与正文的
|
|
360
403
|
`# 生效条件:` 行**同源**——改造前此处只写 `observation_position` 单槽,
|
|
@@ -380,15 +423,17 @@ def add_items(cg, items, *, kind: str, root: str, layer=None, sensitivity=None,
|
|
|
380
423
|
level = it.get("level")
|
|
381
424
|
s, _basis = docindex.sensitivity_for(it.get("path") or "", sensitivity)
|
|
382
425
|
sens[s] = sens.get(s, 0) + 1
|
|
426
|
+
extra = (extra_of(it) or {}) if extra_of else {}
|
|
383
427
|
cg.add(
|
|
384
428
|
nid, docindex.render(it),
|
|
385
429
|
layer=(layer_of(nid) if layer_of else None) or layer or "knowledge",
|
|
386
430
|
tags=["doc", "doc:md", f"level:{level}",
|
|
387
|
-
"domain:" + _domain_of(it)],
|
|
431
|
+
"domain:" + _domain_of(it)] + list(extra.get("tags") or []),
|
|
388
432
|
condition_space=docindex.condition_space(it),
|
|
389
433
|
verification_basis="data",
|
|
390
434
|
sensitivity=s,
|
|
391
|
-
doc_ref=_doc_ref(it, root),
|
|
435
|
+
doc_ref=_doc_ref(it, root, src_of(it) if src_of else None),
|
|
436
|
+
**(extra.get("attrs") or {}),
|
|
392
437
|
)
|
|
393
438
|
else:
|
|
394
439
|
raise ValueError(f"未知 ref kind:{kind!r}")
|
|
@@ -410,15 +455,20 @@ def _code_ref(it: dict, root: str, render_version=None) -> dict:
|
|
|
410
455
|
}
|
|
411
456
|
|
|
412
457
|
|
|
413
|
-
# 生效条件:把入参 root 原样写入返回 dict 的 'root',path/heading/heading_path/level/lineno/end/anchor/hash/lang 按 it.get 取值(缺省 None),precise 取 bool(it.get('precise', True))
|
|
414
|
-
def _doc_ref(it: dict, root: str) -> dict:
|
|
415
|
-
|
|
458
|
+
# 生效条件:把入参 root 原样写入返回 dict 的 'root',path/heading/heading_path/level/lineno/end/anchor/hash/lang 按 it.get 取值(缺省 None),precise 取 bool(it.get('precise', True));src 为真值时并入 'src' 键,假值(含 None/{}/"")时该键**不出现**(默认路径产物逐字不变——read_ref/probe_ref/check_refs/Ledger 都不认这个键)。
|
|
459
|
+
def _doc_ref(it: dict, root: str, src: dict = None) -> dict:
|
|
460
|
+
ref = {
|
|
416
461
|
"path": it.get("path"), "heading": it.get("heading"),
|
|
417
462
|
"heading_path": it.get("heading_path"), "level": it.get("level"),
|
|
418
463
|
"lineno": it.get("lineno"), "end": it.get("end"),
|
|
419
464
|
"anchor": it.get("anchor"), "hash": it.get("hash"), "lang": it.get("lang"),
|
|
420
465
|
"precise": bool(it.get("precise", True)), "root": root,
|
|
421
466
|
}
|
|
467
|
+
if src:
|
|
468
|
+
# 真源身份层(日志一路):区间落在转写上,身份落在 zstd 日志上。
|
|
469
|
+
# 只增不删:既有 11 键语义不动,回读侧(refindex.read_ref)对它无感。
|
|
470
|
+
ref["src"] = src
|
|
471
|
+
return ref
|
|
422
472
|
|
|
423
473
|
|
|
424
474
|
# --------------------------------------------------------------------------
|
|
@@ -486,14 +536,68 @@ def read_ref(ref: dict, *, root: str = None, ref_kind: str = "ref") -> dict:
|
|
|
486
536
|
}
|
|
487
537
|
|
|
488
538
|
|
|
489
|
-
# 生效条件:
|
|
539
|
+
# 生效条件:n 为水位条目内的节点记录(id/lineno/end/hash)、e 为文件条目、rel/src_root 为源相对路径与源大域、unchanged 为源 size/mtime 未变时,按 cg.get(nid) 的 frontmatter ref 判该 node_id 的快路径结论并返回 {'node_id','kind','covered','probe','mismatch'}——取不回节点或节点无 ref 时 covered=False/probe=None(落回退路径、不记 mismatch);五项全等且 unchanged 时 probe=None(跳过不读源);否则 probe 为待探测 ref;不一致时 mismatch 带两侧 root/hash 与 kind_of_gap。
|
|
540
|
+
def _fast_path_verdict(cg, n: dict, e: dict, rel: str, src_root: str,
|
|
541
|
+
unchanged: bool) -> dict:
|
|
542
|
+
"""单条水位记录 → 该 node_id 的快路径结论(**唯一实现**,也是定点变异锚点)。
|
|
543
|
+
|
|
544
|
+
探测数据**只**取节点自身 frontmatter ref(`ref_of(cg.get(nid))`)——水位条目
|
|
545
|
+
一律不进 probe,它只判「能不能跳过」。跳过的充分条件=五项全等 + 源 size/mtime
|
|
546
|
+
未变 + 本轮未被判过(去重由调用方在 `covered` 上做,见 `check_refs`)。
|
|
547
|
+
|
|
548
|
+
`cg.get` 抛错 / 取空 / 节点无 ref 时**不认领也不记 mismatch**:那类节点落回
|
|
549
|
+
回退路径(同一份 `cg.get` + `ref_of` 口径),不新增行类。代价如实记:它们仍算
|
|
550
|
+
未覆盖、留在 todo 里,而 `truncated` 只看 todo 长度——大量读不回的节点可能把
|
|
551
|
+
`truncated` 推成 True,那是覆盖缺口被说出来了,不是被判成漂移。
|
|
552
|
+
"""
|
|
553
|
+
nid = n.get("id")
|
|
554
|
+
out = {"node_id": nid, "kind": "", "covered": False, "probe": None,
|
|
555
|
+
"mismatch": None}
|
|
556
|
+
try:
|
|
557
|
+
node = cg.get(nid)
|
|
558
|
+
except Exception: # 读不回 ≠ 漂移:交回退路径同口径处理
|
|
559
|
+
return out
|
|
560
|
+
kind, ref = ref_of(node)
|
|
561
|
+
if not ref:
|
|
562
|
+
return out
|
|
563
|
+
out["kind"] = kind
|
|
564
|
+
out["covered"] = True
|
|
565
|
+
agree = (_norm_rel(ref.get("path")) == _norm_rel(rel)
|
|
566
|
+
and ref.get("lineno") == n.get("lineno")
|
|
567
|
+
and ref.get("end") == n.get("end")
|
|
568
|
+
and ref.get("hash") == n.get("hash")
|
|
569
|
+
and _same_root(ref.get("root"), src_root))
|
|
570
|
+
if not agree:
|
|
571
|
+
out["mismatch"] = {
|
|
572
|
+
"node_id": nid, "path": rel, "kind": kind,
|
|
573
|
+
"watermark_root": e.get("root") or src_root,
|
|
574
|
+
"node_root": ref.get("root"),
|
|
575
|
+
"watermark_hash": n.get("hash"), "node_hash": ref.get("hash"),
|
|
576
|
+
"kind_of_gap": ("watermark_behind"
|
|
577
|
+
if _same_root(ref.get("root"), src_root)
|
|
578
|
+
else "collision_multi_root"),
|
|
579
|
+
}
|
|
580
|
+
if agree and unchanged:
|
|
581
|
+
return out # 跳过:不读源
|
|
582
|
+
out["probe"] = {**ref, "path": ref.get("path") or rel,
|
|
583
|
+
"root": ref.get("root") or src_root}
|
|
584
|
+
return out
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# 生效条件:cg 的 index['nodes'] 非空时汇总 stale/dangling/unresolved/errors/ledger_mismatch 并返回 ok =(无 stale 且无 dangling);only_tagged 为真时只探测 tags 含 'code'/'doc' 的节点,ledger 非空时先经 _fast_path_verdict 走 (size, mtime) 快路径。
|
|
490
588
|
def check_refs(cg, *, ledger: "Ledger" = None, max_nodes: int = MAX_CHECK,
|
|
491
589
|
only_tagged: bool = True) -> dict:
|
|
492
590
|
"""漂移 / 悬空巡检(只读、不抛)。
|
|
493
591
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
592
|
+
**分工:节点判漂移,水位只判跳过。** 探测数据只取**节点自身 frontmatter ref**
|
|
593
|
+
(`ref_of(cg.get(nid))`)——ledger 条目一律不进 probe;它只用来判「这个节点能不能
|
|
594
|
+
跳过、不读源」。跳过的充分条件(三条同时成立):①节点 ref 与水位条目
|
|
595
|
+
`(hash, lineno, end, path, root)` **五项全等**;②源文件 size/mtime 未变;
|
|
596
|
+
③该 node_id 本轮尚未判过(去重)。五项含 `path` / `root` 是必要的:不含时跨域
|
|
597
|
+
撞 id 在稳态不可见,且 node/水位不一致会重复报红。
|
|
598
|
+
不一致本身不改判定(不进 `ok`、不产生新红行),只记 `ledger_mismatch` 归因行
|
|
599
|
+
(带两侧 root:roots 相同=写序断裂 / 旧水位,不同=跨域撞 id)。
|
|
600
|
+
水位覆盖不到的节点(早期索引 / 未开增量 / `cg.get` 读不回)再回退逐节点探测。
|
|
497
601
|
|
|
498
602
|
`only_tagged=True`(默认)只探测带 `code` / `doc` 标签的节点——ref 只由
|
|
499
603
|
`index_code` / `index_doc` 产生,两者都会打这两个标签;这样巡检不必为每条
|
|
@@ -501,6 +605,7 @@ def check_refs(cg, *, ledger: "Ledger" = None, max_nodes: int = MAX_CHECK,
|
|
|
501
605
|
"""
|
|
502
606
|
nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
|
|
503
607
|
stale, dangling, unresolved, errors = [], [], [], []
|
|
608
|
+
mismatch = []
|
|
504
609
|
covered = set()
|
|
505
610
|
|
|
506
611
|
# 生效条件:当 nid、ref、kind、rel 传入时,p = probe_ref(ref) 后按 p["status"] 分派:为 "dangling" 时把含 node_id/ref_kind/path/lineno/end/error 的 row 加入 dangling,为 "stale" 时补 hash_expected/hash 加入 stale,为 "unresolved" 时加入 unresolved,为 "error" 时加入 errors;其他状态不加入;
|
|
@@ -520,14 +625,14 @@ def check_refs(cg, *, ledger: "Ledger" = None, max_nodes: int = MAX_CHECK,
|
|
|
520
625
|
elif p["status"] == "error":
|
|
521
626
|
errors.append(row)
|
|
522
627
|
|
|
523
|
-
# 快路径:ledger 记录的文件(键 =
|
|
628
|
+
# 快路径:ledger 记录的文件(键 = 源文件绝对路径,天然跨大域不撞名)。
|
|
629
|
+
# 判据与探测数据收在 `_fast_path_verdict`(唯一实现,也是红基线的定点变异锚点)。
|
|
524
630
|
if ledger is not None:
|
|
525
631
|
for key, e in sorted((ledger.load().get("files") or {}).items()):
|
|
526
632
|
rec_nodes = [n for n in (e.get("nodes") or [])
|
|
527
633
|
if n.get("id") in nodes]
|
|
528
634
|
if not rec_nodes:
|
|
529
635
|
continue
|
|
530
|
-
covered.update(n.get("id") for n in rec_nodes)
|
|
531
636
|
rel = e.get("path") or ""
|
|
532
637
|
# 必须用**每个源文件自己的 root**(索引时的源大域),不能用 ledger.root:
|
|
533
638
|
# 后者是认知图根,拿它拼路径会指向不存在的位置、把一切都误判成 dangling。
|
|
@@ -538,11 +643,23 @@ def check_refs(cg, *, ledger: "Ledger" = None, max_nodes: int = MAX_CHECK,
|
|
|
538
643
|
and abs(float(e.get("mtime") or 0.0) - st.st_mtime) < 1e-6)
|
|
539
644
|
except OSError:
|
|
540
645
|
unchanged = False
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
646
|
+
for n in rec_nodes:
|
|
647
|
+
nid = n.get("id")
|
|
648
|
+
if nid in covered:
|
|
649
|
+
# ①同一 node_id 只判一次:跨大域撞 id 时两条水位条目列同一 id,
|
|
650
|
+
# 不去重就会把同一处漂移按「水位条目数」重复报(实测两域源都改:
|
|
651
|
+
# 不去重 4 行 / dup=2,去重后 2 行 / dup=0——夹具每文件 2 节点)。
|
|
652
|
+
continue
|
|
653
|
+
v = _fast_path_verdict(cg, n, e, rel, src_root, unchanged)
|
|
654
|
+
if v["mismatch"]:
|
|
655
|
+
mismatch.append(v["mismatch"])
|
|
656
|
+
if not v["covered"]:
|
|
657
|
+
# ②读不回节点 / 节点无 ref:不认领、不记 mismatch,落回退路径
|
|
658
|
+
continue
|
|
659
|
+
covered.add(nid)
|
|
660
|
+
if v["probe"] is not None:
|
|
661
|
+
_probe_one(nid, v["probe"],
|
|
662
|
+
v["kind"] or e.get("kind") or kind_of_path(rel), rel)
|
|
546
663
|
|
|
547
664
|
# 回退:ledger 未覆盖的索引节点
|
|
548
665
|
# 生效条件:当 nid 传入时,若 only_tagged 为假值立即返回 True;否则取 (nodes.get(nid) or {}).get("tags") or [],仅当其中存在 "code" 或 "doc" 返回 True,否则返回 False;
|
|
@@ -552,7 +669,7 @@ def check_refs(cg, *, ledger: "Ledger" = None, max_nodes: int = MAX_CHECK,
|
|
|
552
669
|
tags = (nodes.get(nid) or {}).get("tags") or []
|
|
553
670
|
return any(t in ("code", "doc") for t in tags)
|
|
554
671
|
|
|
555
|
-
todo = [nid for nid in nodes if nid not in covered and _candidate(nid)]
|
|
672
|
+
todo = [nid for nid in list(nodes) if nid not in covered and _candidate(nid)]
|
|
556
673
|
truncated = len(todo) > max_nodes
|
|
557
674
|
checked = 0
|
|
558
675
|
for nid in todo[:max_nodes]:
|
|
@@ -578,6 +695,10 @@ def check_refs(cg, *, ledger: "Ledger" = None, max_nodes: int = MAX_CHECK,
|
|
|
578
695
|
"ledger_files": len((ledger.load().get("files") or {})) if ledger else 0,
|
|
579
696
|
"stale": stale, "dangling": dangling,
|
|
580
697
|
"unresolved": unresolved, "errors": errors,
|
|
698
|
+
# 水位与节点不一致的**归因行**(带两侧 root):不进 ok、不产生新红行,
|
|
699
|
+
# 但也不是静默——写序断裂 / 跨域撞 id 在这里可见(只增键,语义不动)。
|
|
700
|
+
"ledger_mismatch": mismatch[:20],
|
|
701
|
+
"ledger_mismatch_count": len(mismatch),
|
|
581
702
|
"truncated": truncated, "max_nodes": max_nodes,
|
|
582
703
|
}
|
|
583
704
|
|
|
@@ -691,7 +812,7 @@ def prune_orphans(cg, *, kind: str, root: str, items, dry_run: bool = False,
|
|
|
691
812
|
# 免得为全库每条记忆都读一次盘。
|
|
692
813
|
tag = "doc" if kind == "doc_ref" else "code"
|
|
693
814
|
plan = []
|
|
694
|
-
for nid, e in nodes.items():
|
|
815
|
+
for nid, e in list(nodes.items()):
|
|
695
816
|
if tag not in ((e or {}).get("tags") or []):
|
|
696
817
|
continue
|
|
697
818
|
try:
|
|
@@ -727,6 +848,13 @@ def prune_dangling(cg, *, only_roots=None, dry_run: bool = False,
|
|
|
727
848
|
出口——已删脚本、被搬走的文档留下的残留节点一次清掉,而不是逐条手工
|
|
728
849
|
`forget`。判定与巡检共用 `probe_ref` 的唯一实现,口径不会打架。
|
|
729
850
|
|
|
851
|
+
**可再生节点不列入**(计数 `regenerable_skipped` 透出):判据两条取并——
|
|
852
|
+
标签含 `regenerable`,**或** ref 带真源身份层(`doc_ref.src.file_hash`)。
|
|
853
|
+
日志索引的转写落在系统临时目录、被清理后回读必然 dangling,而这类节点
|
|
854
|
+
**可再生**(真源仍在,重跑落库即恢复)——把它们软删会让再生无从下手。
|
|
855
|
+
第二条判据是必要的:标签会随 heal/rebuild 的重放面缺席(只重放 layer 与
|
|
856
|
+
src)而消失,只认标签等于让这条保护在重建后静默失效。
|
|
857
|
+
|
|
730
858
|
另清**幽灵条目**(ghosts):索引有条目、节点文件却不存在。它们是历史
|
|
731
859
|
「删除只摘内存索引、不落盘」的遗留——`cg.get` 取不回 → 悬空清退够不着它,
|
|
732
860
|
而 `check_refs` 走 ledger 会一直报 → dangling 永不归零。判据只用唯一真源
|
|
@@ -746,10 +874,17 @@ def prune_dangling(cg, *, only_roots=None, dry_run: bool = False,
|
|
|
746
874
|
# 同 prune_orphans:ref 只在节点 frontmatter 里,索引条目里没有,
|
|
747
875
|
# 必须 cg.get 取回节点再 ref_of(否则恒空、静默不删)。
|
|
748
876
|
todo = []
|
|
749
|
-
|
|
877
|
+
regenerable_skipped = []
|
|
878
|
+
for nid, e in list(nodes.items()):
|
|
750
879
|
tags = (e or {}).get("tags") or []
|
|
751
880
|
if not any(t in ("code", "doc") for t in tags):
|
|
752
881
|
continue
|
|
882
|
+
if "regenerable" in tags:
|
|
883
|
+
# 可再生节点(转写落 %TEMP%、被系统清理后回读必然 dangling)**不进清退
|
|
884
|
+
# 计划**:软删它们与「可再生」的前提直接冲突——P2 的再生路径正是靠
|
|
885
|
+
# 这些节点找回真源。计数透出,不静默。
|
|
886
|
+
regenerable_skipped.append(nid)
|
|
887
|
+
continue
|
|
753
888
|
try:
|
|
754
889
|
node = cg.get(nid)
|
|
755
890
|
except Exception:
|
|
@@ -759,6 +894,13 @@ def prune_dangling(cg, *, only_roots=None, dry_run: bool = False,
|
|
|
759
894
|
_k, ref = ref_of(node)
|
|
760
895
|
if not ref or not ref.get("root"):
|
|
761
896
|
continue
|
|
897
|
+
if isinstance(ref.get("src"), dict) and ref["src"].get("file_hash"):
|
|
898
|
+
# 第二判据(**不依赖标签存活**):ref 带真源身份层(`doc_ref.src`)即
|
|
899
|
+
# 「真源仍在 + 有文件哈希」⇒ 转写可再生。标签可能被 heal/rebuild 的
|
|
900
|
+
# 重放面丢掉(它只重放 layer 与 src,见 rebuild docstring),只认标签
|
|
901
|
+
# 会让「可再生」这个安全属性在重建后静默消失。
|
|
902
|
+
regenerable_skipped.append(nid)
|
|
903
|
+
continue
|
|
762
904
|
if only_roots and not any(_same_root(ref.get("root"), r) for r in only_roots):
|
|
763
905
|
continue
|
|
764
906
|
todo.append((nid, ref))
|
|
@@ -777,7 +919,9 @@ def prune_dangling(cg, *, only_roots=None, dry_run: bool = False,
|
|
|
777
919
|
base = {"scanned": len(nodes), "candidates": len(plan),
|
|
778
920
|
"ghosts": len(ghost_plan),
|
|
779
921
|
"dry_run": bool(dry_run), "truncated": truncated,
|
|
780
|
-
"max_nodes": max_nodes
|
|
922
|
+
"max_nodes": max_nodes,
|
|
923
|
+
# 被排除的可再生节点数(原本会因转写被清理而列入 dangling)——不静默
|
|
924
|
+
"regenerable_skipped": len(regenerable_skipped)}
|
|
781
925
|
if dry_run:
|
|
782
926
|
return {**base, "ok": True, "count": len(plan), "pruned": sorted(plan)[:50],
|
|
783
927
|
"ghost_pruned": ghost_plan[:50],
|
|
@@ -786,20 +930,40 @@ def prune_dangling(cg, *, only_roots=None, dry_run: bool = False,
|
|
|
786
930
|
dropped = _drop_ghosts(cg, ghost_plan)
|
|
787
931
|
return {**base, "ok": True, "count": len(done), "pruned": done[:50],
|
|
788
932
|
"ghost_pruned": dropped[:50],
|
|
789
|
-
"skipped_protected": blocked[:20],
|
|
933
|
+
"skipped_protected": blocked[:20],
|
|
934
|
+
"regenerable_skipped_ids": sorted(regenerable_skipped)[:20],
|
|
935
|
+
"reason": why}
|
|
790
936
|
|
|
791
937
|
|
|
792
|
-
# 生效条件:cg 节点按 ref['root'] 与 kind 分组后逐组以 index_dir(incremental=False, ledger=ledger) 重切、再以 add_items(layer_of=原 layer) 重建,返回 {'ok','roots','groups','indexed','errors','truncated'};only_roots 非 None 时只处理其中列出的 root。
|
|
938
|
+
# 生效条件:cg 节点按 ref['root'] 与 kind 分组后逐组以 index_dir(incremental=False, ledger=ledger) 重切、再以 add_items(layer_of=原 layer) 重建,返回 {'ok','roots','groups','indexed','errors','truncated','roots_missing','rebuilt'};root 已不是目录的组不调 index_dir(记 roots_missing 并置 ok=False);本次 0 文件(stats.empty_scan)或 files 为 0 的组同样置 ok=False 并记错误;only_roots 非 None 时只处理其中列出的 root。
|
|
793
939
|
def rebuild(cg, *, ledger: "Ledger" = None, only_roots=None, max_files: int = 500,
|
|
794
940
|
max_items: int = 2000) -> dict:
|
|
795
941
|
"""按 ref 记录的 root 重建索引(sustain.heal 的修复动作)。
|
|
796
942
|
|
|
797
943
|
只重跑出了问题的 root(`only_roots`),逐节点**保留原 layer**;
|
|
798
944
|
doc 密级用默认策略重算(默认只可能更严,不会放松)。
|
|
945
|
+
`src_of` 逐条从**既有节点的 doc_ref** 取回 `src`(与 `layer_of` 同构):
|
|
946
|
+
否则重切转写 root 时 `doc_ref.src` 会静默消失=真源身份层丢失。
|
|
947
|
+
|
|
948
|
+
**悬空 root 不重建(H1b)**:root 已不是目录的组**不调 `index_dir`**——重切只会
|
|
949
|
+
扫到 0 个文件,而 `index_dir` 的 prune / reconcile 会把该域水位剪空(实测:
|
|
950
|
+
见 `index_dir` 的零信息纪律;这是「水位被写成空」的唯一入口,op 面在
|
|
951
|
+
`mcp_server` 侧已先按 isdir 拒)。该组记入 `roots_missing` 并置 `ok=False`
|
|
952
|
+
——没修好就说没修好,悬空仍需人工处置(`op=ref action=prune`)。
|
|
953
|
+
|
|
954
|
+
**逐组如实回报(H2)**:`rebuilt` 逐组记 (root/kind/files/indexed/empty_scan/
|
|
955
|
+
truncated);空扫组(`stats.empty_scan` 或 0 文件)置 `ok=False` 并 append 一条
|
|
956
|
+
errors。截断仍逐组传播到 `out["truncated"]`,异常路径仍置 `ok=False`。
|
|
957
|
+
|
|
958
|
+
**如实边界**:本函数只重放 `layer` 与 `src`,**不重放 `extra_of` 派生面**
|
|
959
|
+
(附加 tags / 附加 frontmatter 键)。日志索引节点(`dsh-log/logsrc/
|
|
960
|
+
regenerable` + `session/workspace/dsh_session_uuid`)因此不能靠 heal 重建,
|
|
961
|
+
要走专用入口 `scripts/_mdcg_reindex_dshlogs.py --repair 1`——该边界写在
|
|
962
|
+
返回值的 `note` 里,不静默。
|
|
799
963
|
"""
|
|
800
964
|
nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
|
|
801
965
|
groups = {}
|
|
802
|
-
for nid in nodes:
|
|
966
|
+
for nid in list(nodes):
|
|
803
967
|
try:
|
|
804
968
|
node = cg.get(nid)
|
|
805
969
|
except Exception:
|
|
@@ -814,16 +978,61 @@ def rebuild(cg, *, ledger: "Ledger" = None, only_roots=None, max_files: int = 50
|
|
|
814
978
|
groups[(r, kind)] += 1
|
|
815
979
|
|
|
816
980
|
out = {"ok": True, "roots": sorted({r for r, _ in groups}),
|
|
817
|
-
"groups": len(groups), "indexed": 0, "errors": [], "truncated": False
|
|
981
|
+
"groups": len(groups), "indexed": 0, "errors": [], "truncated": False,
|
|
982
|
+
"roots_missing": [], "rebuilt": []}
|
|
818
983
|
for (root, kind) in sorted(groups):
|
|
984
|
+
if not os.path.isdir(root):
|
|
985
|
+
# H1b:悬空域(源大域已搬走/删除)**不进 index_dir**——它对不上任何文件,
|
|
986
|
+
# 而 index_dir 会把该域水位剪空([C2] 实测)。这里如实记、置 ok=False。
|
|
987
|
+
out["roots_missing"].append(root)
|
|
988
|
+
out["ok"] = False
|
|
989
|
+
out["errors"].append(
|
|
990
|
+
f"{root} [{kind}]:root 已不是目录,未重建(悬空需人工处置)")
|
|
991
|
+
continue
|
|
819
992
|
try:
|
|
820
993
|
items, errors, stats = index_dir(
|
|
821
994
|
root, kind=kind, max_files=max_files, max_items=max_items,
|
|
822
995
|
incremental=False, ledger=ledger)
|
|
996
|
+
empty = stats.get("empty_scan")
|
|
997
|
+
if empty is None: # 无 ledger 时退一步:0 文件即空扫
|
|
998
|
+
empty = not stats.get("files")
|
|
999
|
+
out["rebuilt"].append({
|
|
1000
|
+
"root": root, "kind": kind, "files": stats.get("files"),
|
|
1001
|
+
"indexed": len(items), "empty_scan": bool(empty),
|
|
1002
|
+
"truncated": bool(stats.get("truncated"))})
|
|
1003
|
+
if empty:
|
|
1004
|
+
# H2:这一组什么都没扫到 ⇒ 没修好,不许报成修好。
|
|
1005
|
+
out["ok"] = False
|
|
1006
|
+
out["errors"].append(
|
|
1007
|
+
f"{root} [{kind}]:本次 0 个文件被扫到(empty_scan),"
|
|
1008
|
+
f"该域水位未对账、索引未重建")
|
|
1009
|
+
kept_src = [] # 本次真的取回 src 的节点
|
|
1010
|
+
|
|
1011
|
+
def _src_of(it, _cg=cg, _kind=kind, _hit=kept_src): # noqa: E306
|
|
1012
|
+
nid = node_id_of(it, _kind)
|
|
1013
|
+
try:
|
|
1014
|
+
node = _cg.get(nid)
|
|
1015
|
+
except Exception:
|
|
1016
|
+
return None
|
|
1017
|
+
if not node:
|
|
1018
|
+
return None
|
|
1019
|
+
_k, ref = ref_of(node)
|
|
1020
|
+
s = (ref or {}).get("src") or None
|
|
1021
|
+
if s:
|
|
1022
|
+
_hit.append(nid)
|
|
1023
|
+
return s
|
|
1024
|
+
|
|
823
1025
|
ids, _sens = add_items(
|
|
824
1026
|
cg, items, kind=kind, root=root,
|
|
825
|
-
layer_of=lambda nid: (nodes.get(nid) or {}).get("layer")
|
|
1027
|
+
layer_of=lambda nid: (nodes.get(nid) or {}).get("layer"),
|
|
1028
|
+
src_of=_src_of)
|
|
826
1029
|
out["indexed"] += len(ids)
|
|
1030
|
+
if kept_src:
|
|
1031
|
+
# extra_of 派生面不重放(见 docstring):命中 src 承载节点即如实提示
|
|
1032
|
+
out["note"] = ("只重放 layer 与 doc_ref.src(%d 个)——extra_of "
|
|
1033
|
+
"派生面(附加 tags/frontmatter 键)不由 heal 重建;"
|
|
1034
|
+
"日志索引节点请走 scripts/_mdcg_reindex_dshlogs.py"
|
|
1035
|
+
" --repair 1" % len(kept_src))
|
|
827
1036
|
out["errors"].extend(errors)
|
|
828
1037
|
out["truncated"] = out["truncated"] or bool(stats.get("truncated"))
|
|
829
1038
|
except Exception as exc: # 自愈不抛
|
package/md_cg/refine.py
CHANGED
|
@@ -136,7 +136,7 @@ def _pool(cg, prefix) -> tuple:
|
|
|
136
136
|
"""抽检池:id 以 prefix 开头、非受保护节点(与 induce 的池口径一致)。"""
|
|
137
137
|
nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
|
|
138
138
|
ids, protected = [], 0
|
|
139
|
-
for nid, e in nodes.items():
|
|
139
|
+
for nid, e in list(nodes.items()):
|
|
140
140
|
if not str(nid).startswith(prefix):
|
|
141
141
|
continue
|
|
142
142
|
if (e or {}).get("protected"):
|
package/md_cg/routing.py
CHANGED
|
@@ -269,27 +269,54 @@ def domain_similarity(a: str, b: str) -> float:
|
|
|
269
269
|
return len(ga & gb) / len(ga | gb)
|
|
270
270
|
|
|
271
271
|
|
|
272
|
-
# 生效条件:counts 各值之和为 0(含空 dict 与全 0
|
|
273
|
-
def bucket_health(counts: dict) -> dict:
|
|
272
|
+
# 生效条件:counts 各值之和为 0(含空 dict 与全 0 计数)时——total_nodes 为假值(缺省 None 或 <=0)返回 {'ok': True, 'reason': 'empty', 'buckets': 0}(与加 total_nodes 参数之前逐位一致),total_nodes > 0 返回 {'ok': False, 'reason': 'empty_buckets', 'buckets': 0, 'nodes': total_nodes, 'problems': [...]};否则在「桶数 >1 且最大桶占比 >30%」、「桶数 >1 且单例桶占比 >50%」、「桶数 ==1(单桶,条件路由不可用)」、桶数 >1 时「期望扫描 >30%」中命中的项写入 problems,返回含 ok(=problems 为空)、buckets、nodes、max_bucket_share、singleton_ratio、expected_scan、problems 的 dict。
|
|
273
|
+
def bucket_health(counts: dict, total_nodes: int = None) -> dict:
|
|
274
274
|
"""分区健康度自检。分桶键一旦退化(巨桶或碎片化),条件路由就是纸面收益,
|
|
275
275
|
必须在写入侧就能发现,而不是等召回变差才回头查。
|
|
276
276
|
|
|
277
277
|
counts: {bucket_dir: node_count}
|
|
278
|
+
total_nodes: 调用方(`MdCG.health`)持有的**库内节点总数**,唯一用途是把
|
|
279
|
+
「真空库(无节点、无桶)」与「有节点但分桶表为空(分区信息缺失)」分开。
|
|
280
|
+
**缺省 None ⇒ 不做该比对**:空 counts 的返回与加本参数之前逐位一致,
|
|
281
|
+
故既有调用方(test_p0 / bench_axis_domain / bench6_arms,只传 counts)
|
|
282
|
+
行为一字不变——M3③ 的向后兼容面。
|
|
283
|
+
|
|
284
|
+
单桶(nb == 1)是「从未分区」而不是「分区退化」:巨桶判据(top/n == 1.0)与
|
|
285
|
+
期望扫描判据(同样恒 1.0)在 nb==1 时必触发且互为同一事实,此前把这类库报成
|
|
286
|
+
「巨桶 / 分区失效」是假话(M3①②)。现只给一条如实读数——条件路由本就不可用;
|
|
287
|
+
ok 仍为 False(单桶库的一次查询确实是全量扫描,判据未放宽)。
|
|
278
288
|
"""
|
|
279
289
|
n = sum(counts.values())
|
|
290
|
+
nb = len(counts)
|
|
280
291
|
if not n:
|
|
292
|
+
# 库里有节点却零个桶 ⇒ 分桶表与节点面不一致(分区信息缺失),
|
|
293
|
+
# 不能报 ok=True 冒充健康(M3③)。total_nodes 缺省时不作此判断。
|
|
294
|
+
if total_nodes and total_nodes > 0:
|
|
295
|
+
return {
|
|
296
|
+
"ok": False,
|
|
297
|
+
"reason": "empty_buckets",
|
|
298
|
+
"buckets": 0,
|
|
299
|
+
"nodes": int(total_nodes),
|
|
300
|
+
"problems": [f"分桶表为空但库内有 {int(total_nodes)} 个节点:"
|
|
301
|
+
"分区信息缺失,条件路由不可用(非真空库)"],
|
|
302
|
+
}
|
|
281
303
|
return {"ok": True, "reason": "empty", "buckets": 0}
|
|
282
|
-
nb = len(counts)
|
|
283
304
|
top = max(counts.values())
|
|
284
305
|
singles = sum(1 for v in counts.values() if v == 1)
|
|
285
306
|
# 期望扫描占比:随机取一节点的情境去路由,命中桶的期望大小占全库比例
|
|
286
307
|
expected_scan = sum(v * v for v in counts.values()) / n / n
|
|
287
308
|
problems = []
|
|
288
|
-
if top / n > 0.30:
|
|
309
|
+
if nb > 1 and top / n > 0.30:
|
|
289
310
|
problems.append(f"巨桶:最大桶占 {top / n:.1%}(>30% 视为分区失效)")
|
|
290
311
|
if nb > 1 and singles / nb > 0.50:
|
|
291
312
|
problems.append(f"碎片化:单例桶占 {singles / nb:.1%}(>50% 说明键含实例级字段)")
|
|
292
|
-
if
|
|
313
|
+
if nb == 1:
|
|
314
|
+
# 巨桶/期望扫描两条在 nb==1 时只是同一事实的两种写法,合并为一条如实读数。
|
|
315
|
+
# expected_scan 的**数值**仍在返回 dict 里(下游读的是数:test_p0 :92、
|
|
316
|
+
# bench_axis_domain :159),只有文案不再把「未分区」说成「巨桶退化」。
|
|
317
|
+
problems.append(f"单桶:全库仅 1 个分桶({n} 个节点同桶),"
|
|
318
|
+
"条件路由本就不可用(从未分区,非分区退化)")
|
|
319
|
+
elif expected_scan > 0.30:
|
|
293
320
|
problems.append(f"路由无效:期望扫描 {expected_scan:.1%}(接近全量)")
|
|
294
321
|
return {
|
|
295
322
|
"ok": not problems,
|
package/md_cg/run_tests.py
CHANGED
|
@@ -38,6 +38,23 @@ import sys
|
|
|
38
38
|
import tempfile
|
|
39
39
|
import time
|
|
40
40
|
|
|
41
|
+
# ---------------------------------------------------------------- 入口自保证 UTF-8
|
|
42
|
+
# 约束(工作纪律第 15 条):本调用必须在**任何文件/库 I/O 之前**——utf8_boot.ensure_utf8
|
|
43
|
+
# 在解释器未开 UTF-8 模式时以相同 argv 重启自身(-X utf8),早于它的任何 open/stdio
|
|
44
|
+
# 读写都走 locale 编码(Windows 中文机 = cp936:裸 open 抛 UnicodeDecodeError、中文写
|
|
45
|
+
# 落 GBK 字节)。本文件是包内全量入口(安装态下 README 的门禁命令),必须最先保证。
|
|
46
|
+
# 仓库根入 sys.path 的形态照 hive/exec.py::_md_cg_import 的最小写法(助手在仓根,
|
|
47
|
+
# 不是 md_cg 包目录)。
|
|
48
|
+
# 被 import(本模块非 __main__)时助手只置子进程继承面、绝不重启/退出——F6:静默重启
|
|
49
|
+
# 会吞掉调用方输入。
|
|
50
|
+
_UTF8_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
51
|
+
if _UTF8_ROOT not in sys.path:
|
|
52
|
+
sys.path.insert(0, _UTF8_ROOT)
|
|
53
|
+
from utf8_boot import ensure_utf8 # noqa: E402
|
|
54
|
+
|
|
55
|
+
ensure_utf8(__file__)
|
|
56
|
+
|
|
57
|
+
|
|
41
58
|
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
42
59
|
#: 逐用例日志目录(重定向目标;默认临时目录,避免污染仓)
|
|
43
60
|
_LOG_DIR = os.environ.get("MDCG_TESTLOG_DIR") or os.path.join(
|