@furongjun1999/dsh-memory 0.4.8 → 0.4.10
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 +54 -26
- package/codebuddy/CODEBUDDY.md +196 -195
- package/codebuddy/README.md +13 -1
- package/codebuddy/mcp.json +9 -0
- package/docs/GBrain/345/217/257/345/200/237/351/211/264/347/202/271_/347/201/265/346/236/242/350/220/275/347/202/271/344/272/244/346/216/245_20260919.md +169 -0
- package/docs/README.md +1 -1
- package/docs/discipline/harnesses.yaml +18 -7
- package/docs/discipline/templates/full.md.tmpl +4 -3
- package/docs/experiments/linkref_backfill/candidates_20260917.json +726 -0
- package/docs/experiments/linkref_backfill/candidates_internal_20260917.json +602 -0
- package/docs/experiments/linkref_backfill/candidates_internal_v2.json +603 -0
- package/docs/experiments/linkref_backfill/candidates_secret_20260917.json +884 -0
- package/docs/experiments/linkref_backfill/candidates_secret_v2.json +789 -0
- package/docs/hive//345/244/232/347/253/257harness/351/200/232/344/277/241/345/245/221/347/272/246_v0.1.md +42 -0
- package/docs/hive//346/243/200/347/264/242/346/224/266/346/225/233/345/256/236/346/265/213/344/270/216S1b/350/256/276/350/256/241_v0.1.md +43 -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 +102 -0
- package/docs/hive//347/234/237/345/256/236/345/272/223/347/253/257/345/210/260/347/253/257/345/256/236/346/265/213_S1b/344/270/216/345/217/254/345/233/236/346/235/203/350/241/241_v0.1.md +57 -0
- package/docs/hive//347/234/237/345/256/236/345/272/223/347/253/257/345/210/260/347/253/257/345/256/236/346/265/213_S7/345/200/222/346/216/222/345/200/231/351/200/211/345/261/202_v0.1.md +126 -0
- package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +503 -0
- package/docs/mdcg/D_meta_/345/267/245/347/250/213/345/214/226/346/226/271/346/241/210_v0.2.md +216 -0
- package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.5.md +631 -625
- package/docs/mdcg//344/273/243/347/240/201/350/257/204/345/256/241/344/270/216/346/235/241/344/273/266/345/214/226/346/263/250/351/207/212_/345/245/221/347/272/246_v0.1.md +82 -0
- package/docs/mdcg//345/205/250/345/272/223/344/273/243/347/240/201/350/257/204/345/256/241/344/270/216/346/235/241/344/273/266/345/214/226/346/263/250/351/207/212_/350/256/241/345/210/222_v0.1.md +600 -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 +47 -45
- package/docs/mdcg//345/255/220/344/273/243/347/220/206/351/205/215/347/275/256/346/240/207/345/207/{206_v0.4.md → 206_v0.5.md} +92 -4
- 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 +172 -0
- package/docs/mdcg//347/216/257/344/272/214_/347/231/275/347/256/261/345/241/253/345/205/205/346/265/201/346/260/264/347/272/277_v0.1.md +31 -0
- package/docs/mdcg//350/267/250/347/253/257/351/252/214/350/257/201/344/270/216/345/220/214/346/255/245/345/215/217/350/256/256_v0.1.md +93 -0
- package/docs/theory//345/271/266/345/217/221/345/277/205/347/204/266/346/200/247/347/220/206/350/256/272_v0.2.md +2 -2
- package/docs/theory//347/220/206/350/256/272_/346/234/272/345/210/266_/344/273/243/347/240/201_/345/256/236/351/252/214_/347/274/272/345/217/243/347/237/251/351/230/265_v0.1.md +3 -3
- package/docs/theory//350/256/244/347/237/245/344/273/243/347/220/206/344/270/216/346/224/266/346/225/233/347/273/223/346/236/204_/346/235/241/344/273/266/350/256/272/351/207/215/346/236/204_v0.1.md +2 -2
- 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 +434 -433
- package/docs//347/201/265/346/236/242/350/207/252/346/210/221/346/224/271/350/277/233/345/267/245/344/275/234/350/256/241/345/210/222_/345/244/226/351/203/250/347/240/224/347/251/266/347/263/273/345/210/227/345/220/270/346/224/266_v1_20260919.md +286 -0
- package/dsh/README.md +33 -0
- package/dsh/cordis.yml.example +13 -7
- package/dsh/hive-mcp-probe.mjs +94 -0
- package/dsh/hive-mcp.example.yml +62 -0
- package/dsh/update-lingshu.bat +11 -0
- package/dsh/update-lingshu.ps1 +337 -0
- package/lib/hooks.d.ts +3 -0
- package/lib/hooks.js +17 -23
- package/lib/index.d.ts +4 -2
- package/lib/index.js +29 -6
- package/lib/lib/datapath.d.ts +76 -1
- package/lib/lib/datapath.js +199 -13
- package/lib/lib/mdcg_client.d.ts +40 -3
- package/lib/lib/mdcg_client.js +46 -22
- package/lib/lib/mutual.js +4 -4
- package/lib/lib/token_store.js +4 -5
- package/md_cg/audit.py +17 -2
- package/md_cg/autonomy.py +86 -15
- package/md_cg/backfill.py +36 -1
- package/md_cg/backfill_bigdomain.py +34 -0
- package/md_cg/bench6_arms.py +28 -1
- package/md_cg/bench6_common.py +10 -1
- package/md_cg/bench6_competitors.py +6 -1
- package/md_cg/bench_axis_domain.py +9 -1
- package/md_cg/bench_blind_comp.py +7 -1
- package/md_cg/bench_en_atoms_public.py +9 -0
- package/md_cg/bench_governance.py +348 -0
- package/md_cg/bench_lme_zh.py +16 -1
- package/md_cg/bench_locomo.py +2 -1
- package/md_cg/bench_locomo_zh.py +16 -1
- package/md_cg/bench_locomo_zh_public.py +4 -1
- package/md_cg/bench_longmem.py +2 -1
- package/md_cg/bench_membench.py +27 -1
- package/md_cg/bench_p0.py +4 -1
- package/md_cg/bench_progressive.py +13 -1
- package/md_cg/bench_role_views.py +238 -0
- package/md_cg/bench_task_ab.py +8 -1
- package/md_cg/bench_task_ab_llm.py +13 -1
- package/md_cg/bench_unified_en.py +6 -1
- package/md_cg/bench_zh_mad.py +20 -1
- package/md_cg/blindspot_tickets.py +123 -0
- package/md_cg/branches.py +12 -1
- package/md_cg/build_postings.py +73 -0
- package/md_cg/ccgc.py +67 -2
- package/md_cg/census.py +5 -1
- package/md_cg/chain.py +24 -3
- package/md_cg/codeindex.py +134 -17
- package/md_cg/coldverify.py +265 -0
- package/md_cg/comment_gate.py +338 -0
- package/md_cg/cond_compose.py +190 -0
- package/md_cg/cond_facts.py +155 -0
- package/md_cg/cond_template.json +107 -0
- package/md_cg/condition_anchor.py +143 -0
- package/md_cg/conformance.py +69 -4
- package/md_cg/consistency.py +24 -1
- package/md_cg/consolidate.py +53 -2
- package/md_cg/corpus.py +4 -0
- package/md_cg/crosscheck.py +42 -2
- package/md_cg/crypto.py +35 -1
- package/md_cg/d_meta.py +310 -0
- package/md_cg/datapath.py +201 -26
- package/md_cg/docindex.py +122 -1
- package/md_cg/eval_common.py +29 -1
- package/md_cg/evidence.py +27 -1
- package/md_cg/evolution.py +21 -1
- package/md_cg/export.py +11 -1
- package/md_cg/forgetting.py +23 -1
- package/md_cg/fsutil.py +18 -1
- package/md_cg/hotcache.py +214 -0
- package/md_cg/hyperedge.py +251 -0
- package/md_cg/identity.py +18 -1
- package/md_cg/insight.py +17 -1
- package/md_cg/lexicon/build_cedict_en_zh.py +9 -0
- package/md_cg/lexicon/build_standard_en.py +171 -168
- package/md_cg/lexicon/expand_en_zh.py +6 -0
- package/md_cg/lifecycle.py +12 -1
- package/md_cg/linkref.py +281 -0
- package/md_cg/links.py +29 -1
- package/md_cg/mcp_server.py +362 -43
- package/md_cg/md_whitebox.py +53 -1
- package/md_cg/mdcg.py +1003 -27
- package/md_cg/mdcos.py +558 -36
- package/md_cg/metacognition.py +37 -2
- package/md_cg/migrate.py +4 -0
- package/md_cg/migrate_aeis.py +221 -213
- package/md_cg/migrate_roleplay.py +8 -0
- package/md_cg/migrate_wisdom_graph.py +14 -1
- package/md_cg/mreview/__main__.py +3 -0
- package/md_cg/mreview/bundle.py +8 -0
- package/md_cg/mreview/candidates.py +9 -0
- package/md_cg/mreview/govern.py +21 -1
- package/md_cg/mreview/locate.py +34 -0
- package/md_cg/mreview/pipeline.py +29 -1
- package/md_cg/mreview/ruleset.py +16 -1
- package/md_cg/nodefile.py +233 -3
- package/md_cg/pooling.py +23 -1
- package/md_cg/postings.py +298 -0
- package/md_cg/predict.py +89 -9
- package/md_cg/progressive.py +3 -0
- package/md_cg/protect.py +14 -1
- package/md_cg/protocol.py +372 -0
- package/md_cg/provenance.py +262 -0
- package/md_cg/reach.py +453 -0
- package/md_cg/refindex.py +47 -2
- package/md_cg/refine.py +20 -1
- package/md_cg/roleviews.py +89 -0
- package/md_cg/routing.py +76 -0
- package/md_cg/scrub.py +63 -2
- package/md_cg/security.py +26 -1
- package/md_cg/self_state.py +64 -1
- package/md_cg/selfreport.py +151 -0
- package/md_cg/semantic/canonical.py +5 -0
- package/md_cg/semantic/en_normalizer.py +364 -355
- package/md_cg/semantic/zh_en_atoms.py +139 -136
- package/md_cg/signer.py +41 -1
- package/md_cg/sources.py +583 -547
- package/md_cg/statushdr.py +179 -0
- package/md_cg/stg.py +59 -18
- package/md_cg/subgraph.py +23 -0
- package/md_cg/sustain.py +56 -1
- package/md_cg/tasks.py +26 -2
- package/md_cg/test_autonomy.py +26 -0
- package/md_cg/test_bench_governance.py +102 -0
- package/md_cg/test_blindspot_tickets.py +166 -0
- package/md_cg/test_ccgc.py +10 -0
- package/md_cg/test_codeindex.py +338 -0
- package/md_cg/test_comment_gate.py +187 -0
- package/md_cg/test_cond_compose_anchors.py +76 -0
- package/md_cg/test_condition_anchor.py +82 -0
- package/md_cg/test_d_meta.py +412 -0
- package/md_cg/test_datapath_root.py +188 -0
- package/md_cg/test_gain_gate.py +47 -1
- package/md_cg/test_hot_cold.py +187 -0
- package/md_cg/test_hyperedge.py +245 -0
- package/md_cg/test_linkref.py +306 -0
- package/md_cg/test_md_access_parity.py +15 -3
- package/md_cg/test_mr_m1.py +108 -18
- package/md_cg/test_mr_m3.py +8 -1
- package/md_cg/test_p26_refindex.py +49 -20
- package/md_cg/test_p27_docindex.py +236 -2
- package/md_cg/test_p2_mcp.py +1 -1
- package/md_cg/test_p31_insight.py +24 -0
- package/md_cg/test_p44_md_whitebox.py +14 -1
- package/md_cg/test_protocol.py +243 -0
- package/md_cg/test_reach.py +378 -0
- package/md_cg/test_reach_keys.py +201 -0
- package/md_cg/test_reach_meta_exits.py +145 -0
- package/md_cg/test_read_clip.py +8 -4
- package/md_cg/test_retr_s1.py +340 -0
- package/md_cg/test_retr_s1b.py +209 -0
- package/md_cg/test_retr_s3.py +194 -0
- package/md_cg/test_retr_s4.py +163 -0
- package/md_cg/test_retr_s5.py +200 -0
- package/md_cg/test_retr_s6.py +157 -0
- package/md_cg/test_retr_s7.py +385 -0
- package/md_cg/test_retr_s8_time.py +316 -0
- package/md_cg/test_retr_s9_edges.py +286 -0
- package/md_cg/test_retr_s9_entity_ctx.py +175 -0
- package/md_cg/test_review_conformance.py +59 -2
- package/md_cg/test_role_views.py +354 -0
- package/md_cg/test_subproc_encoding.py +188 -0
- package/md_cg/test_trust.py +361 -0
- package/md_cg/test_units_poll.py +71 -0
- package/md_cg/test_v14_fixes.py +397 -0
- package/md_cg/test_validity_filter.py +280 -0
- package/md_cg/test_wisdom_md_store.py +7 -3
- package/md_cg/test_writepipe.py +5 -1
- package/md_cg/theory.py +16 -1
- package/md_cg/tokens.py +40 -8
- package/md_cg/tool_face.py +13 -2
- package/md_cg/trust.py +943 -0
- package/md_cg/twophase.py +12 -1
- package/md_cg/units.py +132 -10
- package/md_cg/vision_evidence.py +24 -1
- package/md_cg/weights.py +24 -1
- package/md_cg/whitebox.py +32 -1
- package/md_cg/whitebox_kb/data/verify_cache.json +21210 -365
- package/md_cg/whitebox_kb/data/verify_savings.jsonl +5078 -0
- package/md_cg/whitebox_kb/wisdom/audit_log/chain_heat.json +10 -10
- package/md_cg/whitebox_kb/wisdom/code_compose.py +113 -6
- package/md_cg/whitebox_kb/wisdom/code_solidified.json +1 -1
- package/md_cg/whitebox_kb/wisdom/verifier.py +340 -55
- package/md_cg/whitebox_kb/wisdom/wisdom-book-cloud.db +0 -0
- package/md_cg/writelimit.py +18 -4
- package/md_cg/writepipe.py +178 -7
- package/package.json +2 -2
- package/skills/skills/designer-perspective/scripts/__pycache__/designer.cpython-310.pyc +0 -0
- package/skills/skills/designer-perspective/scripts/designer.py +17 -1
- package/skills/skills/designer-perspective/tests/selftest.py +3 -1
- package/src/hooks.ts +17 -21
- package/src/index.ts +33 -6
- package/src/lib/datapath.ts +211 -13
- package/src/lib/mdcg_client.ts +64 -25
- package/src/lib/mutual.ts +411 -411
- package/src/lib/token_store.ts +4 -5
- package/zcode/AGENTS.md +196 -195
- package/zcode/README.md +4 -0
- package/md_cg/whitebox_kb/wisdom/wisdom-book-cloud.db-shm +0 -0
- package/md_cg/whitebox_kb/wisdom/wisdom-book-cloud.db-wal +0 -0
package/md_cg/trust.py
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · 可验证记忆单元(验证态状态机 + 依赖图 + 双时间轴)
|
|
3
|
+
|
|
4
|
+
把「可信度」从散文式描述,变成**显式、可查询、可传播、可输出**的结构化状态。
|
|
5
|
+
三件事:
|
|
6
|
+
|
|
7
|
+
1. **验证态持续化**——`verification_state` ∈ (unverified / verified /
|
|
8
|
+
expired / doubted / rechecking),随验证动作与依赖变动**流转**,不是一次性
|
|
9
|
+
布尔开关;状态落 `frontmatter.verification_state`(节点 md 是唯一真源),
|
|
10
|
+
索引快照同名透出(免读文件可查)。
|
|
11
|
+
2. **依赖声明与失效传播**——单元显式声明它依赖谁(`depends_on`);被依赖单元
|
|
12
|
+
被修改/证伪时,**直接下游立即同步标为 doubted**(一跳,低成本);**多跳**
|
|
13
|
+
交由 `propagate()` 后台巡检重算,不阻塞写入与查询。
|
|
14
|
+
3. **双时间轴**——既有「何时不再成立」(`valid_until`)之外补「何时开始成立」
|
|
15
|
+
(`valid_from`),使时效区间可判定:未生效 / 生效中 / 已过期。
|
|
16
|
+
|
|
17
|
+
与 `lifecycle.py` 刻意分层(同构实现、**正交语义**,禁跨套复用常量):
|
|
18
|
+
· `lifecycle.py` = 节点**生命周期**(active/converged/demoted/archived);
|
|
19
|
+
· 本模块 = 节点**验证态**(unverified/verified/expired/doubted/rechecking)。
|
|
20
|
+
两者都是「单点裁决 + 单点推进 + 硬拒入口」,但状态集不共享、`_RANK` 不共享。
|
|
21
|
+
|
|
22
|
+
与 `provenance.py` 刻意分层:
|
|
23
|
+
· `provenance.py` = 节点**派生血缘**(「它由谁派生」,`_link.jsonl`);
|
|
24
|
+
· 本模块 = 节点**依赖与证据强度**(「它靠谁成立、现在还成不成立」,
|
|
25
|
+
`_trust.jsonl`)。血缘是既成事实,依赖是可失效假设——不可混用。
|
|
26
|
+
|
|
27
|
+
三条纪律(对齐 G8 裁定与 lifecycle 口径):
|
|
28
|
+
1. **台账失败降级不阻断写**——`mark_dependents()` 永不抛,写不进只留痕告警;
|
|
29
|
+
2. **历史不回填**——存量缺 `verification_state` 视为 `unverified`,`backfill()`
|
|
30
|
+
只在显式调用时把「缺省」变「显式」;
|
|
31
|
+
3. **巡检只读**——`patrol()` 检出悬空依赖/过期节点但**不自动改状态、不删节点**。
|
|
32
|
+
|
|
33
|
+
命名避让(既有先例):字段**不叫 `state`**——该名已被裁决四态
|
|
34
|
+
(ACCEPT/REJECT/DEFER/BLINDSPOT)占用,`lifecycle_state` 的先例同此动机。
|
|
35
|
+
|
|
36
|
+
零第三方依赖(D-005)。
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import os
|
|
41
|
+
import time
|
|
42
|
+
|
|
43
|
+
from .fsutil import append_jsonl, read_jsonl
|
|
44
|
+
|
|
45
|
+
#: 验证态全集。语义:
|
|
46
|
+
#: unverified 未验证(默认;存量缺字段即视为它)
|
|
47
|
+
#: verified 已验证(有验证方式与证据,且依赖未动)
|
|
48
|
+
#: doubted 存疑(**依赖的地基动了**——异常的新定义)
|
|
49
|
+
#: rechecking 重新验证中(复核进行态)
|
|
50
|
+
#: expired 已过期(超出时效区间,或证据整体失效)
|
|
51
|
+
STATES = ("unverified", "verified", "doubted", "rechecking", "expired")
|
|
52
|
+
|
|
53
|
+
#: frontmatter 字段名(索引快照同名透出)。刻意避开 `state`——与 lifecycle 同动机。
|
|
54
|
+
STATE_FIELD = "verification_state"
|
|
55
|
+
#: 验证履历字段(frontmatter 侧历史,滚动保留最近 HISTORY_KEEP 条)
|
|
56
|
+
HISTORY_FIELD = "verification_history"
|
|
57
|
+
HISTORY_KEEP = 20
|
|
58
|
+
#: 依赖声明字段(CCG「子功能」槽的落字段)
|
|
59
|
+
DEPS_FIELD = "depends_on"
|
|
60
|
+
#: 双时间轴(**效力时间**)字段。2026-09-19 阶段一:规范名迁移到
|
|
61
|
+
#: `effective_from` / `effective_until`(**新写入落规范键**),历史名
|
|
62
|
+
#: `valid_from` / `valid_until` 保留为**读取侧回落别名**(存量不迁移、零破坏)。
|
|
63
|
+
FROM_FIELD = "valid_from"
|
|
64
|
+
UNTIL_FIELD = "valid_until"
|
|
65
|
+
#: 规范名(新写入落此;读取时优先于同名族旧键)
|
|
66
|
+
EFFECTIVE_FROM_FIELD = "effective_from"
|
|
67
|
+
EFFECTIVE_UNTIL_FIELD = "effective_until"
|
|
68
|
+
#: 信念时间(体系**何时确认此条**):取代/审核的锚。
|
|
69
|
+
#: **不是效力语义**——它既不是「已结束」也不是「尚未开始」,
|
|
70
|
+
#: 故绝不并入 `scrub._EXPIRY_KEYS`(已结束族)或 `scrub._NOT_YET_KEYS`(未生效族)。
|
|
71
|
+
BELIEVED_FIELD = "believed_at"
|
|
72
|
+
#: 过期时刻(写盘冗余:由 `effective_until` 派生落盘,供审计/对账直读)。
|
|
73
|
+
EXPIRED_FIELD = "expired_at"
|
|
74
|
+
|
|
75
|
+
#: 端点取值优先级(**规范键优先、别名回落**,2026-09-19 阶段一)。
|
|
76
|
+
#: 与 `scrub._NOT_YET_KEYS` / `scrub._EXPIRY_KEYS` 同键族——两处共用同一套别名,
|
|
77
|
+
#: 任一侧新增键须同步另一侧(交叉守卫测试 test_validity_filter 守住)。
|
|
78
|
+
FROM_ALIASES = (EFFECTIVE_FROM_FIELD, FROM_FIELD, "valid_since", "starts_at")
|
|
79
|
+
UNTIL_ALIASES = (EFFECTIVE_UNTIL_FIELD, UNTIL_FIELD,
|
|
80
|
+
"expires_at", "expire_at", "expiry", "deadline")
|
|
81
|
+
#: 验证态迁移审计(append-only,与 _lifecycle.jsonl / _maintain.jsonl 同风格)
|
|
82
|
+
AUDIT_FILE = "_trust.jsonl"
|
|
83
|
+
|
|
84
|
+
#: 降级序位(用于「受保护豁免降级」判定)。verified 最高,doubted/expired 更低。
|
|
85
|
+
_RANK = {"verified": 0, "rechecking": 1, "unverified": 1, "doubted": 2, "expired": 3}
|
|
86
|
+
|
|
87
|
+
#: 合法迁移表。刻意不收录的(即拒绝的):
|
|
88
|
+
#: verified → unverified(回落未验证:证据已存在,只应「存疑」而非「清零」)
|
|
89
|
+
#: doubted → expired(跳级:存疑未经复核不得直接判过期)
|
|
90
|
+
#: 注:`unverified → doubted` **收录**——两条路都真实到达这里:①上游变动波及一个
|
|
91
|
+
#: 从未验证过的下游(「地基动了」对未验证者同样成立);②未验证节点收到**负证据**
|
|
92
|
+
#: (证据削弱本身就是存疑的依据,比停在「未验证」更准确)。
|
|
93
|
+
TRANSITIONS = frozenset({
|
|
94
|
+
("unverified", "verified"),
|
|
95
|
+
("unverified", "doubted"),
|
|
96
|
+
("unverified", "rechecking"),
|
|
97
|
+
("unverified", "expired"),
|
|
98
|
+
("verified", "doubted"),
|
|
99
|
+
("verified", "rechecking"),
|
|
100
|
+
("verified", "expired"),
|
|
101
|
+
("doubted", "rechecking"),
|
|
102
|
+
("doubted", "verified"),
|
|
103
|
+
("rechecking", "verified"),
|
|
104
|
+
("rechecking", "doubted"),
|
|
105
|
+
("rechecking", "unverified"),
|
|
106
|
+
("rechecking", "expired"),
|
|
107
|
+
("expired", "rechecking"),
|
|
108
|
+
("expired", "verified"),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
#: 依赖边上限(单节点直接下游数);超出即拒收,防「依赖声明写成噪声」。
|
|
112
|
+
MAX_DEPS = 32
|
|
113
|
+
#: 多跳传播的节点上限(对齐 subgraph.MAX_NODES_DEFAULT 的量级)
|
|
114
|
+
MAX_NODES_DEFAULT = 500
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TrustError(Exception):
|
|
118
|
+
"""非法验证态迁移 / 依赖声明非法(写路径用 `require_transition` 直接抛)。"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, src, dst, code, reason):
|
|
121
|
+
super().__init__(f"非法验证态迁移 {src}→{dst}({code}):{reason}")
|
|
122
|
+
self.src, self.dst, self.code, self.reason = src, dst, code, reason
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------- 纯函数裁决
|
|
126
|
+
|
|
127
|
+
def state_of(fm) -> str:
|
|
128
|
+
"""frontmatter → 验证态;缺字段或未知值 → "unverified"(存量兼容,不猜测)。"""
|
|
129
|
+
if not isinstance(fm, dict):
|
|
130
|
+
return "unverified"
|
|
131
|
+
s = fm.get(STATE_FIELD)
|
|
132
|
+
return s if s in STATES else "unverified"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_downgrade(src: str, dst: str) -> bool:
|
|
136
|
+
"""是否向「更低可信度」迁移(verified < unverified/rechecking < doubted < expired)。"""
|
|
137
|
+
return _RANK.get(dst, 0) > _RANK.get(src, 0)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def can_transition(src, dst) -> bool:
|
|
141
|
+
"""迁移是否合法(含幂等;未知 src 按 unverified 处理)。"""
|
|
142
|
+
src = src if src in STATES else "unverified"
|
|
143
|
+
if dst not in STATES:
|
|
144
|
+
return False
|
|
145
|
+
if src == dst:
|
|
146
|
+
return True # 幂等:同状态是 no-op
|
|
147
|
+
return (src, dst) in TRANSITIONS
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check(src, dst, protected: bool = False, override: bool = False):
|
|
151
|
+
"""迁移合法性裁决 → `(ok, code, reason)`。**唯一裁决点**(纯函数,无 IO)。
|
|
152
|
+
|
|
153
|
+
code 取值:`ok` / `noop`(同状态,不写盘)/ `unknown_state` /
|
|
154
|
+
`illegal_transition` / `protected`。
|
|
155
|
+
"""
|
|
156
|
+
src = src if src in STATES else "unverified"
|
|
157
|
+
if dst not in STATES:
|
|
158
|
+
return False, "unknown_state", f"未知验证态 {dst!r}(允许:{STATES})"
|
|
159
|
+
if src == dst:
|
|
160
|
+
return True, "noop", "验证态未变(幂等,不写盘)"
|
|
161
|
+
if (src, dst) not in TRANSITIONS:
|
|
162
|
+
legal = "、".join(f"{a}→{b}" for a, b in sorted(TRANSITIONS))
|
|
163
|
+
return False, "illegal_transition", (
|
|
164
|
+
f"{src}→{dst} 不在合法迁移表内(允许:{legal})——"
|
|
165
|
+
"验证态只沿「验证/存疑/复核/过期」四条路径流转,跨级迁移一律拒绝")
|
|
166
|
+
if is_downgrade(src, dst) and protected and not override:
|
|
167
|
+
return False, "protected", (
|
|
168
|
+
f"受保护节点不接受降级迁移 {src}→{dst}(保护 = 不可轻弃);"
|
|
169
|
+
"确需降级请显式 override=True")
|
|
170
|
+
return True, "ok", f"{src}→{dst} 合法"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def require_transition(src, dst, protected: bool = False, override: bool = False):
|
|
174
|
+
"""合法则返回 code,非法则抛 `TrustError`(写路径的硬拒入口)。"""
|
|
175
|
+
ok, code, why = check(src, dst, protected=protected, override=override)
|
|
176
|
+
if not ok:
|
|
177
|
+
raise TrustError(src if src in STATES else "unverified", dst, code, why)
|
|
178
|
+
return code
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def stamp(fm: dict, dst: str, reason: str = None, actor: str = None,
|
|
182
|
+
evidence: str = None, method: str = None, trigger: str = None,
|
|
183
|
+
override: bool = False):
|
|
184
|
+
"""在给定 frontmatter 上**就地**推进验证态(无 IO)→ `(ok, code, why)`。
|
|
185
|
+
|
|
186
|
+
与 `set_state()` 共用同一裁决点(`check`),供**需与其他字段同批落盘**的
|
|
187
|
+
收口点使用(写入时一次写盘改依赖/时间轴/验证态)。
|
|
188
|
+
"""
|
|
189
|
+
src = state_of(fm)
|
|
190
|
+
prot = bool(fm.get("protected") or fm.get("immutable"))
|
|
191
|
+
ok, code, why = check(src, dst, protected=prot, override=override)
|
|
192
|
+
if not ok or code == "noop":
|
|
193
|
+
return ok, code, why
|
|
194
|
+
fm[STATE_FIELD] = dst
|
|
195
|
+
rec = {"at": time.time(), "from": src, "to": dst, "reason": reason,
|
|
196
|
+
"actor": actor, "trigger": trigger}
|
|
197
|
+
if method:
|
|
198
|
+
rec["method"] = str(method)[:120]
|
|
199
|
+
if evidence:
|
|
200
|
+
rec["evidence"] = str(evidence)[:300]
|
|
201
|
+
hist = list(fm.get(HISTORY_FIELD) or [])
|
|
202
|
+
hist.append(rec)
|
|
203
|
+
fm[HISTORY_FIELD] = hist[-HISTORY_KEEP:]
|
|
204
|
+
if method:
|
|
205
|
+
fm["verification_method"] = str(method)[:120]
|
|
206
|
+
if evidence:
|
|
207
|
+
fm["verification_evidence"] = str(evidence)[:300]
|
|
208
|
+
fm["verified_at"] = rec["at"]
|
|
209
|
+
return True, code, why
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------- 双时间轴
|
|
213
|
+
|
|
214
|
+
def parse_time(value):
|
|
215
|
+
"""时间值 → epoch 秒;不可解析返回 None(**不猜测**)。
|
|
216
|
+
|
|
217
|
+
接受:数字(epoch 秒)/ 日期或日期时间字符串(ISO 8601,宽松:空格分隔、
|
|
218
|
+
`Z` 后缀、日期精度都容忍)。纯标准库实现。
|
|
219
|
+
"""
|
|
220
|
+
if value is None or value == "":
|
|
221
|
+
return None
|
|
222
|
+
if isinstance(value, (int, float)):
|
|
223
|
+
return float(value)
|
|
224
|
+
s = str(value).strip()
|
|
225
|
+
if not s:
|
|
226
|
+
return None
|
|
227
|
+
try:
|
|
228
|
+
return float(s)
|
|
229
|
+
except ValueError:
|
|
230
|
+
pass
|
|
231
|
+
t = s.replace("/", "-").replace("T", " ")
|
|
232
|
+
if t.endswith("Z"):
|
|
233
|
+
t = t[:-1].strip()
|
|
234
|
+
fmts = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d")
|
|
235
|
+
for f in fmts:
|
|
236
|
+
try:
|
|
237
|
+
return time.mktime(time.strptime(t, f))
|
|
238
|
+
except ValueError:
|
|
239
|
+
continue
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def first_endpoint(fm, keys):
|
|
244
|
+
"""按 `keys` 优先级取首个**可解析**端点 → `(epoch, key)`;全链无值 → `(None, None)`。
|
|
245
|
+
|
|
246
|
+
单点定义「规范键优先、别名回落」的取值口径:某键**存在但不可解析**时继续回落
|
|
247
|
+
(而非按 None 定案)——「写坏了的时间值」不得遮蔽同族另一个合法键。
|
|
248
|
+
"""
|
|
249
|
+
if not isinstance(fm, dict):
|
|
250
|
+
return None, None
|
|
251
|
+
for k in keys:
|
|
252
|
+
if k in fm:
|
|
253
|
+
ts = parse_time(fm.get(k))
|
|
254
|
+
if ts is not None:
|
|
255
|
+
return ts, k
|
|
256
|
+
return None, None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def validity(fm, now: float = None):
|
|
260
|
+
"""双时间轴判定 → `(kind, start, end)`。
|
|
261
|
+
|
|
262
|
+
kind ∈ `unknown`(无时间轴约束)/ `not_yet`(未生效)/ `active`(生效中)/
|
|
263
|
+
`expired`(已过期)。任一端点不可解析 → 该端点按 None 处理(不猜测、不误判)。
|
|
264
|
+
|
|
265
|
+
端点取值(2026-09-19 阶段一):**规范键优先、别名回落**——
|
|
266
|
+
起点 `effective_from` > `valid_from` > `valid_since` > `starts_at`;
|
|
267
|
+
终点 `effective_until` > `valid_until` > `expires_at` > `expire_at` > `expiry` > `deadline`。
|
|
268
|
+
`believed_at`(信念时间)**不参与**本判定:它是「体系何时确认此条」(取代/审核的锚),
|
|
269
|
+
不是效力端点——并入任一方向都会把「已确认」误判成「已生效/已失效」。
|
|
270
|
+
"""
|
|
271
|
+
if not isinstance(fm, dict):
|
|
272
|
+
return "unknown", None, None
|
|
273
|
+
start, _sk = first_endpoint(fm, FROM_ALIASES)
|
|
274
|
+
end, _ek = first_endpoint(fm, UNTIL_ALIASES)
|
|
275
|
+
if start is None and end is None:
|
|
276
|
+
return "unknown", None, None
|
|
277
|
+
t = time.time() if now is None else float(now)
|
|
278
|
+
if start is not None and t < start:
|
|
279
|
+
return "not_yet", start, end
|
|
280
|
+
if end is not None and t > end:
|
|
281
|
+
return "expired", start, end
|
|
282
|
+
return "active", start, end
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def believed_at(fm) -> float:
|
|
286
|
+
"""信念时间(体系**何时确认此条**)→ epoch 秒;缺字段/不可解析 → None(不猜测)。
|
|
287
|
+
|
|
288
|
+
用途:取代(supersede)/ 审核的排序锚——「谁更晚被确认」是判定新旧的正路;
|
|
289
|
+
**不得**拿 `valid_from`/`valid_until` 代替(那是事实在任务世界里何时有效,
|
|
290
|
+
与体系何时知道它无关)。
|
|
291
|
+
"""
|
|
292
|
+
if not isinstance(fm, dict):
|
|
293
|
+
return None
|
|
294
|
+
return parse_time(fm.get(BELIEVED_FIELD))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def is_expired(fm, now: float = None) -> bool:
|
|
298
|
+
"""`validity` 的布尔快捷:是否「**已过期**」。
|
|
299
|
+
|
|
300
|
+
只判 `expired`——`not_yet`(尚未生效)**不算**:两者语义相反
|
|
301
|
+
(见 `scrub._NOT_YET_KEYS` 纪律「`valid_from` 绝不并入 `_EXPIRY_KEYS`」),
|
|
302
|
+
「尚未开始」不等于「已失效」。无时间轴 / 端点不可解析 → False(不猜测、不误杀)。
|
|
303
|
+
"""
|
|
304
|
+
return validity(fm, now=now)[0] == "expired"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def time_window_msg(fm, now: float = None) -> str:
|
|
308
|
+
"""时效判定的一句话(空串表示无时间轴约束)。**点名实际命中的键**(含别名)。"""
|
|
309
|
+
kind, start, end = validity(fm, now=now)
|
|
310
|
+
if kind == "unknown":
|
|
311
|
+
return ""
|
|
312
|
+
if kind == "not_yet":
|
|
313
|
+
_ts, k = first_endpoint(fm, FROM_ALIASES)
|
|
314
|
+
return f"未生效({k or FROM_FIELD} 未到)"
|
|
315
|
+
if kind == "expired":
|
|
316
|
+
_ts, k = first_endpoint(fm, UNTIL_ALIASES)
|
|
317
|
+
return f"已过期({k or UNTIL_FIELD} 已过)"
|
|
318
|
+
return "时效内"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------- 时间算子(阶段二 4.1)
|
|
322
|
+
|
|
323
|
+
#: 时间轴(**封闭枚举**)。两条轴物理隔离、语义不可互换(同 `believed_at` 的隔离纪律):
|
|
324
|
+
#: effective 效力轴——这条事实**何时开始/不再成立**(`effective_from/until` 及别名)
|
|
325
|
+
#: observed 观察轴——这条记忆**何时被观测/事件何时发生**(`temporal` / `time_window`)
|
|
326
|
+
#: 不设**隐式**默认轴:入口在「启用时间算子但未指定轴」时回落 `effective`
|
|
327
|
+
#: (与既有 `validity=` 语义连续),但轴本身永远由调用方显式决定。
|
|
328
|
+
TIME_AXES = ("effective", "observed")
|
|
329
|
+
|
|
330
|
+
#: 时间算子(**封闭枚举**,拒收未知名):候选轴端点 与 查询端点 的比较关系。
|
|
331
|
+
#: 缺省(不给 operator)= **区间重叠**语义,见 `window_match`。
|
|
332
|
+
TIME_OPERATORS = ("gt", "gte", "eq", "lte", "lt")
|
|
333
|
+
|
|
334
|
+
#: 观察轴字段名(与 `stg._interval` 同源口径,**不新增第二套解析**)。
|
|
335
|
+
OBSERVED_TIME_FIELD = "temporal"
|
|
336
|
+
OBSERVED_WINDOW_FIELD = "time_window"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def time_axis_of(axis) -> str:
|
|
340
|
+
"""轴名归一 → `"effective"` / `"observed"`;`None` → `"effective"`;
|
|
341
|
+
其余(含 `"believed"`)→ `ValueError`(**fail-closed,不静默降级**)。
|
|
342
|
+
|
|
343
|
+
与 `_stg_call`「不做签名推导——猜错会静默返回错误视图,比报错更贵」同风格:
|
|
344
|
+
轴写错时必须报错,因为静默按另一条轴过滤会产出**无法复算**的结果集。
|
|
345
|
+
"""
|
|
346
|
+
if axis is None:
|
|
347
|
+
return "effective"
|
|
348
|
+
a = str(axis).strip().lower()
|
|
349
|
+
if a in TIME_AXES:
|
|
350
|
+
return a
|
|
351
|
+
raise ValueError(f"未知 time_axis {axis!r}(允许:{TIME_AXES})")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def time_window_of(fm, axis: str = "effective"):
|
|
355
|
+
"""按轴取候选时间窗口 → `(start, end)`;不可判定 → `(None, None)`(不猜测)。
|
|
356
|
+
|
|
357
|
+
· `effective`:效力轴,走 `first_endpoint(FROM_ALIASES/UNTIL_ALIASES)`
|
|
358
|
+
(规范键优先、别名回落,与 `validity` **同源**);
|
|
359
|
+
缺字段/不可解析 → 该侧 `None`(= 无界,与 `validity` 的「不误判」口径一致)。
|
|
360
|
+
· `observed`:观察轴,与 `stg._interval` **同源口径**——`temporal`(事件时刻)优先,
|
|
361
|
+
缺失才回退 `condition_space.time_window`(观测窗);任一端不可解析 → `(None, None)`
|
|
362
|
+
(与 `_interval` 的「整体不可用」语义一致)。
|
|
363
|
+
|
|
364
|
+
`believed_at` **永不参与**(`BELIEVED_FIELD` 不出现于任何轴)。
|
|
365
|
+
"""
|
|
366
|
+
if not isinstance(fm, dict):
|
|
367
|
+
return None, None
|
|
368
|
+
if time_axis_of(axis) == "observed":
|
|
369
|
+
t = fm.get(OBSERVED_TIME_FIELD)
|
|
370
|
+
if t is not None:
|
|
371
|
+
ts = parse_time(t)
|
|
372
|
+
if ts is not None:
|
|
373
|
+
return ts, ts
|
|
374
|
+
# 兼容两种载体形态:**fm**(`condition_space.time_window`)与**索引扁平快照**
|
|
375
|
+
# (`time_window` 平铺在 entry 上,见 `mdcg._scan_nodes`/`_stage`)。
|
|
376
|
+
# 只读嵌套会让「按 entry 过滤」的路径永不命中(同类坑:S2 时间门控,
|
|
377
|
+
# mdcg.py L639 已记「直接读 cs 会让门控永不生效」)。
|
|
378
|
+
cs = fm.get("condition_space") or {}
|
|
379
|
+
tw = fm.get(OBSERVED_WINDOW_FIELD)
|
|
380
|
+
if tw is None and isinstance(cs, dict):
|
|
381
|
+
tw = cs.get(OBSERVED_WINDOW_FIELD)
|
|
382
|
+
if isinstance(tw, (list, tuple)) and len(tw) == 2:
|
|
383
|
+
s, e = parse_time(tw[0]), parse_time(tw[1])
|
|
384
|
+
if s is None or e is None:
|
|
385
|
+
return None, None
|
|
386
|
+
return s, e
|
|
387
|
+
return None, None
|
|
388
|
+
start, _sk = first_endpoint(fm, FROM_ALIASES)
|
|
389
|
+
end, _ek = first_endpoint(fm, UNTIL_ALIASES)
|
|
390
|
+
return start, end
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _op_ok(cand, q, op) -> bool:
|
|
394
|
+
"""单个端点比较;任一端不可解析 → False(缺字段的处置归**轴策略**,此处不猜测)。"""
|
|
395
|
+
if cand is None or q is None:
|
|
396
|
+
return False
|
|
397
|
+
if op == "gt":
|
|
398
|
+
return cand > q
|
|
399
|
+
if op == "gte":
|
|
400
|
+
return cand >= q
|
|
401
|
+
if op == "eq":
|
|
402
|
+
return cand == q
|
|
403
|
+
if op == "lte":
|
|
404
|
+
return cand <= q
|
|
405
|
+
if op == "lt":
|
|
406
|
+
return cand < q
|
|
407
|
+
return False # 未知算子:入口已 fail-closed,此处保守拒
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def window_match(cand_start, cand_end, q_start=None, q_end=None,
|
|
411
|
+
start_op: str = None, end_op: str = None) -> bool:
|
|
412
|
+
"""候选窗口与查询窗口是否匹配(§1.2 B1/B2 的**唯一实现点**,纯函数)。
|
|
413
|
+
|
|
414
|
+
两种模式由「是否给 operator」**显式分叉**(不允许隐式混用——混用会产出
|
|
415
|
+
「无法复算」的过滤,违反白箱):
|
|
416
|
+
|
|
417
|
+
· **重叠模式**(`start_op`/`end_op` 均为 `None`):候选窗口与查询窗口有交集
|
|
418
|
+
即命中(记忆窗口是**区间**不是点)。查询端点缺省 = 该侧**无界**(不隐含 now)。
|
|
419
|
+
· **端点模式**(至少给一个 operator):对**显式启用的侧**做 `op(cand端, q端)`;
|
|
420
|
+
一侧未给 operator 但**给了该侧查询端点**时,用 B1 缺省(起点 `gte` / 终点 `lte`);
|
|
421
|
+
该侧查询端点也没有 → **不约束该侧**。
|
|
422
|
+
|
|
423
|
+
「未给 operator 且未给查询端点 → 不约束」是刻意的:若一律回落到 B1 缺省再比较,
|
|
424
|
+
`_op_ok(cand, None, op)` 恒伪,于是 `start_operator="gte" + start_time=T`
|
|
425
|
+
(单端算子,合法调用)会静默返回**空集**——把「只筛起点」误答成「没有匹配」。
|
|
426
|
+
端点模式下候选的该侧端点不可解析 → `False`(该侧无法比较,不猜)。
|
|
427
|
+
|
|
428
|
+
候选窗口两端皆不可解析 → `False`(该节点「无时间轴可判」)——是否因此剔除
|
|
429
|
+
由**轴的策略**决定(效力轴 fail-open / 观察轴 fail-closed,见候选过滤处)。
|
|
430
|
+
"""
|
|
431
|
+
cs, ce = parse_time(cand_start), parse_time(cand_end)
|
|
432
|
+
if cs is None and ce is None:
|
|
433
|
+
return False
|
|
434
|
+
qs, qe = parse_time(q_start), parse_time(q_end)
|
|
435
|
+
if start_op is None and end_op is None:
|
|
436
|
+
if qs is not None and ce is not None and ce < qs:
|
|
437
|
+
return False
|
|
438
|
+
if qe is not None and cs is not None and cs > qe:
|
|
439
|
+
return False
|
|
440
|
+
return True
|
|
441
|
+
if start_op is not None or qs is not None:
|
|
442
|
+
if not _op_ok(cs, qs, start_op or "gte"):
|
|
443
|
+
return False
|
|
444
|
+
if end_op is not None or qe is not None:
|
|
445
|
+
if not _op_ok(ce, qe, end_op or "lte"):
|
|
446
|
+
return False
|
|
447
|
+
return True
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def check_time_args(start_time=None, end_time=None, start_operator=None,
|
|
451
|
+
end_operator=None, time_axis=None):
|
|
452
|
+
"""时间算子入参 fail-closed 校验 → `(enabled, axis, why)`。
|
|
453
|
+
|
|
454
|
+
**入口(`mdcg.search` / `entity_contexts`)与库层共用的唯一校验点**
|
|
455
|
+
(避免两处各写一套、口径漂移)。三则误用一律拒(`why` 非空即应抛
|
|
456
|
+
`ValueError`,不静默忽略——与 `_stg_call` 同风格):
|
|
457
|
+
|
|
458
|
+
1. 只给 operator 而不给对应的 `start_time`/`end_time`;
|
|
459
|
+
2. `time_axis` 非 `{"effective","observed"}`(含 `"believed"`);
|
|
460
|
+
3. `start_time > end_time`。
|
|
461
|
+
|
|
462
|
+
未启用(五参全 `None`)→ `(False, None, "")`(默认路径零变更)。
|
|
463
|
+
"""
|
|
464
|
+
if time_axis is not None and str(time_axis).strip().lower() not in TIME_AXES:
|
|
465
|
+
return False, None, (f"未知 time_axis {time_axis!r}(允许:{TIME_AXES})——"
|
|
466
|
+
"轴必须显式且合法,静默按另一条轴过滤会产出无法复算的结果")
|
|
467
|
+
for op, val, nm in ((start_operator, start_time, "start_operator/start_time"),
|
|
468
|
+
(end_operator, end_time, "end_operator/end_time")):
|
|
469
|
+
if op is None:
|
|
470
|
+
continue
|
|
471
|
+
if str(op).strip().lower() not in TIME_OPERATORS:
|
|
472
|
+
return False, None, (f"未知算子 {op!r}(允许:{TIME_OPERATORS})")
|
|
473
|
+
if val is None:
|
|
474
|
+
return False, None, f"给了 {nm.split('/')[0]} 但缺 {nm.split('/')[1]}(不猜默认值)"
|
|
475
|
+
s, e = parse_time(start_time), parse_time(end_time)
|
|
476
|
+
if start_time is not None and s is None:
|
|
477
|
+
return False, None, f"start_time 不可解析:{start_time!r}"
|
|
478
|
+
if end_time is not None and e is None:
|
|
479
|
+
return False, None, f"end_time 不可解析:{end_time!r}"
|
|
480
|
+
if s is not None and e is not None and s > e:
|
|
481
|
+
return False, None, f"start_time({s}) > end_time({e}):空窗口,拒绝"
|
|
482
|
+
enabled = any(x is not None for x in (start_time, end_time,
|
|
483
|
+
start_operator, end_operator))
|
|
484
|
+
if not enabled:
|
|
485
|
+
return False, None, ""
|
|
486
|
+
return True, time_axis_of(time_axis), ""
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def window_matches_node(fm, axis, q_start, q_end, start_op=None, end_op=None):
|
|
490
|
+
"""候选节点按轴取窗后做 `window_match` → `(matched, missing)`。
|
|
491
|
+
|
|
492
|
+
`missing=True` 表示该节点在**该轴上不可判定**(窗口两端皆 `None`)——
|
|
493
|
+
调用方据此执行轴策略:效力轴 fail-open(保留)、观察轴 fail-closed
|
|
494
|
+
(剔除并计入 `axis_missing`)。策略按**保证强度**定,不按「一致好看」定:
|
|
495
|
+
效力轴字段是可选声明,观察轴字段由写入侧保证存在。
|
|
496
|
+
"""
|
|
497
|
+
cs, ce = time_window_of(fm, axis)
|
|
498
|
+
if cs is None and ce is None:
|
|
499
|
+
return False, True
|
|
500
|
+
return window_match(cs, ce, q_start, q_end, start_op, end_op), False
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def time_filter_meta(axis=None, mode="overlap", start=None, end=None,
|
|
504
|
+
start_operator=None, end_operator=None, dropped=0,
|
|
505
|
+
axis_missing=0, applied=False) -> dict:
|
|
506
|
+
"""`meta["time_filter"]` 审计块(**五键齐备**,可复算):
|
|
507
|
+
|
|
508
|
+
`dropped + 存活数 == 候选数` 由调用方保证;`axis_missing` 单独记账,
|
|
509
|
+
使「观察轴缺字段被剔除」的条数可查(静默放行会把数据异常藏起来)。
|
|
510
|
+
"""
|
|
511
|
+
return {"axis": axis, "mode": mode, "start": start, "end": end,
|
|
512
|
+
"start_operator": start_operator, "end_operator": end_operator,
|
|
513
|
+
"dropped": int(dropped), "axis_missing": int(axis_missing),
|
|
514
|
+
"applied": bool(applied)}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def filter_by_time(entries, axis, q_start, q_end, start_op=None, end_op=None):
|
|
518
|
+
"""按轴过滤候选 → `(kept, dropped, axis_missing)`。**候选层唯一过滤点**。
|
|
519
|
+
|
|
520
|
+
轴策略按**字段保证强度**定(不按「行为一致好看」定):
|
|
521
|
+
· `effective` 效力轴:`effective_from/until` 是**可选声明**(多数节点没有)
|
|
522
|
+
→ 不可判定一律 fail-open(保留)——否则「没写时效 = 被过滤掉」,
|
|
523
|
+
会把沉默当否认;
|
|
524
|
+
· `observed` 观察轴:`temporal` / `time_window` 由**写入侧保证**存在
|
|
525
|
+
(add 缺省填观测窗)→ 不可判定一律 fail-closed(剔除并计入
|
|
526
|
+
`axis_missing`,使数据异常可查而非静默放行)。
|
|
527
|
+
"""
|
|
528
|
+
kept, dropped, missing = [], 0, 0
|
|
529
|
+
for e in entries:
|
|
530
|
+
m, miss = window_matches_node(e, axis, q_start, q_end, start_op, end_op)
|
|
531
|
+
if miss:
|
|
532
|
+
if axis == "observed":
|
|
533
|
+
dropped += 1
|
|
534
|
+
missing += 1
|
|
535
|
+
else:
|
|
536
|
+
kept.append(e)
|
|
537
|
+
continue
|
|
538
|
+
if m:
|
|
539
|
+
kept.append(e)
|
|
540
|
+
else:
|
|
541
|
+
dropped += 1
|
|
542
|
+
return kept, dropped, missing
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ---------------------------------------------------------------- 依赖声明
|
|
546
|
+
|
|
547
|
+
def as_deps(value) -> list:
|
|
548
|
+
"""把单值 / 序列 / 逗号串统一成**去重、排序**的节点 id 列表(上限 MAX_DEPS)。
|
|
549
|
+
|
|
550
|
+
容错口径(写路径的入参形态不由本模块决定,收窄会误伤):
|
|
551
|
+
· `None` / 空串 / 纯空白项 → 丢弃(不是目标,也不是「声明了依赖」);
|
|
552
|
+
· 半角/全角逗号串 → 切分(`"a, b"` 与 `["a", "b"]` 等价);
|
|
553
|
+
· 排序的动机:依赖是**集合语义**(声明顺序不承载信息),排序让 fm 与
|
|
554
|
+
索引快照对同一组依赖恒等——比较/去重/审计不再依赖声明顺序。
|
|
555
|
+
"""
|
|
556
|
+
if value is None:
|
|
557
|
+
return []
|
|
558
|
+
items = list(value) if isinstance(value, (list, tuple, set)) else [value]
|
|
559
|
+
out = []
|
|
560
|
+
for x in items:
|
|
561
|
+
if x is None:
|
|
562
|
+
continue
|
|
563
|
+
for s in str(x).replace(",", ",").split(","):
|
|
564
|
+
s = s.strip()
|
|
565
|
+
if s and s not in out:
|
|
566
|
+
out.append(s)
|
|
567
|
+
out.sort()
|
|
568
|
+
return out[:MAX_DEPS]
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def deps_of(cg, nid: str) -> list:
|
|
572
|
+
"""读某节点声明的依赖(**零读文件**:索引快照缺失才回读节点)。"""
|
|
573
|
+
e = ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(nid) or {}
|
|
574
|
+
deps = as_deps(e.get(DEPS_FIELD))
|
|
575
|
+
if deps:
|
|
576
|
+
return deps
|
|
577
|
+
node = cg.get(nid)
|
|
578
|
+
if not node:
|
|
579
|
+
return []
|
|
580
|
+
return as_deps((node.get("frontmatter") or {}).get(DEPS_FIELD))
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def invalidate_cache(cg) -> None:
|
|
584
|
+
"""作废反查缓存(写/删节点后调用;下次读时增量重建)。"""
|
|
585
|
+
try:
|
|
586
|
+
cg.__dict__.pop("_trust_dependents", None)
|
|
587
|
+
except Exception: # noqa: BLE001
|
|
588
|
+
pass
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def dependents_index(cg, refresh: bool = False) -> dict:
|
|
592
|
+
"""反查索引:`{被依赖节点: [依赖它的节点…]}`(「谁依赖我」)。
|
|
593
|
+
|
|
594
|
+
缓存挂在 cg 实例上(与 `subgraph` 的缓存化做法同构);`refresh=True` 强制重建。
|
|
595
|
+
"""
|
|
596
|
+
if not refresh:
|
|
597
|
+
cached = getattr(cg, "_trust_dependents", None)
|
|
598
|
+
if isinstance(cached, dict):
|
|
599
|
+
return cached
|
|
600
|
+
nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
|
|
601
|
+
idx = {}
|
|
602
|
+
for nid, e in nodes.items():
|
|
603
|
+
for p in as_deps((e or {}).get(DEPS_FIELD)):
|
|
604
|
+
idx.setdefault(p, []).append(nid)
|
|
605
|
+
for k in idx:
|
|
606
|
+
idx[k].sort()
|
|
607
|
+
try:
|
|
608
|
+
cg._trust_dependents = idx
|
|
609
|
+
except Exception: # noqa: BLE001
|
|
610
|
+
pass
|
|
611
|
+
return idx
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _record(cg, rec: dict) -> None:
|
|
615
|
+
"""台账追加(best-effort:失败静默,**绝不阻断写路径**)。"""
|
|
616
|
+
try:
|
|
617
|
+
append_jsonl(os.path.join(cg.root, AUDIT_FILE), rec)
|
|
618
|
+
except Exception: # noqa: BLE001
|
|
619
|
+
pass
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---------------------------------------------------------------- 推进
|
|
623
|
+
|
|
624
|
+
def _sync_index(cg, node_id, fm) -> None:
|
|
625
|
+
"""把验证态同步进索引快照(免读文件可查);无索引实现时静默跳过。"""
|
|
626
|
+
idx = getattr(cg, "index", None)
|
|
627
|
+
if not isinstance(idx, dict):
|
|
628
|
+
return
|
|
629
|
+
e = (idx.get("nodes") or {}).get(node_id)
|
|
630
|
+
if e is None:
|
|
631
|
+
return
|
|
632
|
+
e[STATE_FIELD] = fm.get(STATE_FIELD)
|
|
633
|
+
dirty = getattr(cg, "_dirty", None)
|
|
634
|
+
if isinstance(dirty, dict):
|
|
635
|
+
dirty[node_id] = e
|
|
636
|
+
flush = getattr(cg, "flush", None)
|
|
637
|
+
if callable(flush):
|
|
638
|
+
flush()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def set_state(cg, node_id: str, dst: str, reason: str = None, actor: str = None,
|
|
642
|
+
evidence: str = None, method: str = None, trigger: str = None,
|
|
643
|
+
override: bool = False) -> dict:
|
|
644
|
+
"""推进一个节点的验证态(**唯一推进入口**)。
|
|
645
|
+
|
|
646
|
+
非法迁移不走异常而是返回 `{"ok": False, "error": <code>, ...}`(负路由)。
|
|
647
|
+
幂等迁移返回 `changed=False` 且不写盘。
|
|
648
|
+
"""
|
|
649
|
+
node = cg.get(node_id)
|
|
650
|
+
if not node:
|
|
651
|
+
return {"ok": False, "error": "node_not_found", "node_id": node_id}
|
|
652
|
+
fm = dict(node.get("frontmatter") or {})
|
|
653
|
+
src = state_of(fm)
|
|
654
|
+
ok, code, why = stamp(fm, dst, reason=reason, actor=actor, evidence=evidence,
|
|
655
|
+
method=method, trigger=trigger, override=override)
|
|
656
|
+
base = {"node_id": node_id, "from": src, "to": dst, "code": code}
|
|
657
|
+
if not ok:
|
|
658
|
+
return {**base, "ok": False, "error": code, "reason": why}
|
|
659
|
+
if code == "noop":
|
|
660
|
+
return {**base, "ok": True, "changed": False, "reason": why}
|
|
661
|
+
at = fm[HISTORY_FIELD][-1]["at"]
|
|
662
|
+
cg._write_node(node_id, os.path.join(cg.root, node["path"]), fm,
|
|
663
|
+
node.get("content") or "")
|
|
664
|
+
_sync_index(cg, node_id, fm)
|
|
665
|
+
_record(cg, {"t": at, "action": "set_state", "node_id": node_id,
|
|
666
|
+
"from": src, "to": dst, "reason": reason, "actor": actor,
|
|
667
|
+
"method": method, "trigger": trigger, "override": bool(override)})
|
|
668
|
+
if fm.get(DEPS_FIELD):
|
|
669
|
+
invalidate_cache(cg)
|
|
670
|
+
return {**base, "ok": True, "changed": True, "reason": why, "at": at}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ---------------------------------------------------------------- 失效传播
|
|
674
|
+
|
|
675
|
+
def mark_dependents(cg, node_id: str, reason: str = None, *, apply: bool = True,
|
|
676
|
+
actor: str = "system", trigger: str = None,
|
|
677
|
+
depth: int = 1, override: bool = False) -> dict:
|
|
678
|
+
"""**一跳同步传播**:把直接下游标为 `doubted`(异常的新定义)。
|
|
679
|
+
|
|
680
|
+
根因口径:异常不再仅是「内容错」,而是「**它所依赖的地基动了**」——被依赖
|
|
681
|
+
单元被修改/证伪时,直接下游立刻产生存疑标记,无需等全库巡检。
|
|
682
|
+
|
|
683
|
+
**永不抛**(对齐 provenance G8 纪律:传播失败不得阻断写入)。`apply=False`
|
|
684
|
+
只预演;同状态下游是 no-op(幂等,不写盘、不留痕)。
|
|
685
|
+
"""
|
|
686
|
+
try:
|
|
687
|
+
dep_map = dependents_index(cg)
|
|
688
|
+
kids = list(dep_map.get(node_id, []))
|
|
689
|
+
kids.sort()
|
|
690
|
+
if not kids:
|
|
691
|
+
return {"ok": True, "changed": 0, "dependents": [], "apply": bool(apply),
|
|
692
|
+
"reason": "no_dependents"}
|
|
693
|
+
reason = reason or f"上游 {node_id} 变动"
|
|
694
|
+
planned = [k for k in kids
|
|
695
|
+
if state_of((cg.index or {}).get("nodes", {}).get(k) or {})
|
|
696
|
+
!= "doubted"]
|
|
697
|
+
if not apply:
|
|
698
|
+
return {"ok": True, "dry_run": True, "changed": 0,
|
|
699
|
+
"dependents": kids, "planned": len(planned)}
|
|
700
|
+
done, skipped = [], []
|
|
701
|
+
for k in kids:
|
|
702
|
+
r = set_state(cg, k, "doubted", reason=reason, actor=actor,
|
|
703
|
+
trigger=trigger or f"dep_changed:{node_id}",
|
|
704
|
+
override=override)
|
|
705
|
+
if r.get("ok") and r.get("changed"):
|
|
706
|
+
done.append(k)
|
|
707
|
+
elif r.get("ok"):
|
|
708
|
+
skipped.append(k) # 已是 doubted:幂等
|
|
709
|
+
else:
|
|
710
|
+
skipped.append(f"{k}({r.get('error')})")
|
|
711
|
+
return {"ok": True, "changed": len(done), "dependents": kids,
|
|
712
|
+
"updated": done, "skipped": skipped, "apply": True,
|
|
713
|
+
"reason": reason}
|
|
714
|
+
except Exception as exc: # noqa: BLE001
|
|
715
|
+
_record(cg, {"t": time.time(), "action": "mark_dependents_degraded",
|
|
716
|
+
"node_id": node_id, "error": f"{type(exc).__name__}: {exc}",
|
|
717
|
+
"actor": actor})
|
|
718
|
+
return {"ok": False, "degraded": True, "changed": 0,
|
|
719
|
+
"error": f"{type(exc).__name__}: {exc}"}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def propagate(cg, *, apply: bool = False, max_nodes: int = MAX_NODES_DEFAULT,
|
|
723
|
+
actor: str = "patrol") -> dict:
|
|
724
|
+
"""**多跳异步重算**(巡检面,不阻塞写入/查询)。
|
|
725
|
+
|
|
726
|
+
BFS 从所有 `expired` / `rechecking` / 显式根源节点出发,把可达下游标
|
|
727
|
+
`doubted`(**只降不升**:复核通过须由人显式 `set_state(verified)`)。
|
|
728
|
+
默认 `apply=False` 只出报表;`max_nodes` 封顶防大库爆炸。
|
|
729
|
+
|
|
730
|
+
`doubted` 中间节点的语义(2026-09-20 v14 缺陷 B 修复):**穿过,但不重复
|
|
731
|
+
标记**。已 doubted 说明它已被标记,无需再标(幂等);但它**不是传播终点**
|
|
732
|
+
——「地基动了」的波及范围必须继续往其下游走。旧实现把 doubted 直接
|
|
733
|
+
`continue`(当作终点),于是「先 `mark_dependents` 预演直接下游、再
|
|
734
|
+
`propagate` 正式传播」这一最常见序列会使多跳传播**整体失效**
|
|
735
|
+
(`reachable`/`updated` 全 0)且**无任何报错**。穿过的节点在
|
|
736
|
+
`passed_doubted` 中如实透出(可审计)。
|
|
737
|
+
"""
|
|
738
|
+
nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
|
|
739
|
+
dep_map = dependents_index(cg)
|
|
740
|
+
roots = []
|
|
741
|
+
for nid, e in nodes.items():
|
|
742
|
+
st = (e or {}).get(STATE_FIELD)
|
|
743
|
+
if st in ("expired", "rechecking"):
|
|
744
|
+
roots.append(nid)
|
|
745
|
+
roots.sort()
|
|
746
|
+
seen, waves, passed = set(roots), [], set()
|
|
747
|
+
frontier, depth = list(roots), 0
|
|
748
|
+
while frontier and len(seen) < int(max_nodes):
|
|
749
|
+
depth += 1
|
|
750
|
+
nxt = []
|
|
751
|
+
for nid in frontier:
|
|
752
|
+
for k in dep_map.get(nid, []):
|
|
753
|
+
if k in seen:
|
|
754
|
+
continue
|
|
755
|
+
seen.add(k)
|
|
756
|
+
if state_of(nodes.get(k) or {}) == "doubted":
|
|
757
|
+
passed.add(k) # 穿过但不重复标记(不是终点)
|
|
758
|
+
nxt.append(k)
|
|
759
|
+
if len(seen) >= int(max_nodes):
|
|
760
|
+
break
|
|
761
|
+
for k in nxt:
|
|
762
|
+
if k in passed:
|
|
763
|
+
continue # 已 doubted:无需再标(幂等)
|
|
764
|
+
waves.append({"node_id": k, "depth": depth,
|
|
765
|
+
"via": sorted(n for n in dep_map if k in dep_map[n]
|
|
766
|
+
and n in seen)})
|
|
767
|
+
frontier = nxt
|
|
768
|
+
if not apply:
|
|
769
|
+
return {"ok": True, "dry_run": True, "roots": roots[:50],
|
|
770
|
+
"roots_count": len(roots), "reachable": len(waves),
|
|
771
|
+
"planned": waves[:50], "passed_doubted": sorted(passed)[:20],
|
|
772
|
+
"max_nodes": int(max_nodes),
|
|
773
|
+
"note": "预演:未改盘;只降不升(复核须显式)"}
|
|
774
|
+
done, skipped = [], []
|
|
775
|
+
for w in waves:
|
|
776
|
+
r = set_state(cg, w["node_id"], "doubted",
|
|
777
|
+
reason="多跳失效传播(上游时间轴/证据失效)",
|
|
778
|
+
actor=actor, trigger=f"propagate:d{w['depth']}")
|
|
779
|
+
if r.get("ok") and r.get("changed"):
|
|
780
|
+
done.append(w["node_id"])
|
|
781
|
+
elif not r.get("ok"):
|
|
782
|
+
skipped.append(f"{w['node_id']}({r.get('error')})")
|
|
783
|
+
return {"ok": True, "dry_run": False, "roots": roots[:50],
|
|
784
|
+
"roots_count": len(roots), "updated": done, "updated_count": len(done),
|
|
785
|
+
"passed_doubted": sorted(passed)[:20],
|
|
786
|
+
"skipped": skipped[:20], "max_nodes": int(max_nodes)}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
# ---------------------------------------------------------------- 巡检 / 回填
|
|
790
|
+
|
|
791
|
+
# 生效条件:cg 传入即只读遍历 index 快照并返回悬空/未生效/已过期/存疑四类计数与样本,不写盘、不改状态、不抛(index 缺失按空库处理);
|
|
792
|
+
def patrol(cg, *, limit: int = 20, now: float = None) -> dict:
|
|
793
|
+
"""只读巡检:悬空依赖 / 时间轴失效 / 存疑积压。**不改状态、不删节点**。
|
|
794
|
+
|
|
795
|
+
命名纪律(负记忆,2026-09-19 实测):本函数**不得叫 `check`**——裁决层已有
|
|
796
|
+
`check(src, dst, ...)`(唯一裁决点)。同模块内同名二次定义会**静默遮蔽**前者,
|
|
797
|
+
`stamp()` 随即调到巡检版并抛 `TypeError`(现场:`set_verification` 整条链断)。
|
|
798
|
+
两者语义分属不同层次(纯函数裁决 vs 全库只读巡检),名字必须分开。
|
|
799
|
+
"""
|
|
800
|
+
nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
|
|
801
|
+
known = set(nodes)
|
|
802
|
+
dangling, expired, not_yet, doubted = [], [], [], []
|
|
803
|
+
for nid, e in sorted(nodes.items()):
|
|
804
|
+
for p in as_deps((e or {}).get(DEPS_FIELD)):
|
|
805
|
+
if p not in known:
|
|
806
|
+
dangling.append({"node_id": nid, "missing": p})
|
|
807
|
+
st = (e or {}).get(STATE_FIELD)
|
|
808
|
+
if st == "doubted":
|
|
809
|
+
doubted.append(nid)
|
|
810
|
+
# 时间轴按索引可判定字段粗判(精确判定需 fm,巡检读面只给候选)
|
|
811
|
+
kind = _index_validity(e, now=now)
|
|
812
|
+
if kind == "expired":
|
|
813
|
+
expired.append(nid)
|
|
814
|
+
elif kind == "not_yet":
|
|
815
|
+
not_yet.append(nid)
|
|
816
|
+
return {
|
|
817
|
+
"ok": not dangling,
|
|
818
|
+
"checked": True, "root": cg.root, "nodes": len(known),
|
|
819
|
+
"dangling": dangling[:int(limit)], "dangling_count": len(dangling),
|
|
820
|
+
"expired": expired[:int(limit)], "expired_count": len(expired),
|
|
821
|
+
"not_yet": not_yet[:int(limit)], "not_yet_count": len(not_yet),
|
|
822
|
+
"doubted": doubted[:int(limit)], "doubted_count": len(doubted),
|
|
823
|
+
"readonly": True,
|
|
824
|
+
"note": "只读巡检:悬空依赖/时效仅检出并报告,不自动改状态(处置由人/巡检动作发起)"}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _index_validity(e, now: float = None) -> str:
|
|
828
|
+
"""索引快照口径的时效粗判(索引缺时间字段时返回 unknown,零读文件)。"""
|
|
829
|
+
if not isinstance(e, dict):
|
|
830
|
+
return "unknown"
|
|
831
|
+
return validity(e, now=now)[0]
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def backfill(cg, apply: bool = False, limit: int = 5000) -> dict:
|
|
835
|
+
"""存量回填:把缺 `verification_state` 的节点显式补成 `unverified`(幂等)。
|
|
836
|
+
|
|
837
|
+
缺字段本就按 unverified 解释(`state_of`),故回填**不是让功能工作的前提**,
|
|
838
|
+
而是把「缺省」变成「显式」——索引/审计里从此可直接读到验证态。默认只盘点。
|
|
839
|
+
"""
|
|
840
|
+
nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
|
|
841
|
+
missing = []
|
|
842
|
+
for nid, e in list(nodes.items()):
|
|
843
|
+
if len(missing) >= limit:
|
|
844
|
+
break
|
|
845
|
+
if (e or {}).get(STATE_FIELD) in STATES:
|
|
846
|
+
continue
|
|
847
|
+
missing.append(nid)
|
|
848
|
+
missing.sort()
|
|
849
|
+
if not apply:
|
|
850
|
+
return {"ok": True, "dry_run": True, "scanned": len(nodes),
|
|
851
|
+
"missing": len(missing), "planned": missing[:50]}
|
|
852
|
+
done, failed = [], []
|
|
853
|
+
for nid in missing:
|
|
854
|
+
node = cg.get(nid)
|
|
855
|
+
if not node:
|
|
856
|
+
failed.append(nid)
|
|
857
|
+
continue
|
|
858
|
+
fm = dict(node.get("frontmatter") or {})
|
|
859
|
+
fm[STATE_FIELD] = state_of(fm)
|
|
860
|
+
cg._write_node(nid, os.path.join(cg.root, node["path"]), fm,
|
|
861
|
+
node.get("content") or "")
|
|
862
|
+
_sync_index(cg, nid, fm)
|
|
863
|
+
done.append(nid)
|
|
864
|
+
return {"ok": True, "dry_run": False, "scanned": len(nodes),
|
|
865
|
+
"missing": len(missing), "backfilled": len(done),
|
|
866
|
+
"planned": done[:50], "failed": failed[:20]}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def describe(cg, node_id: str, now: float = None) -> dict:
|
|
870
|
+
"""单节点验证态全貌(供 op=status / 状态头渲染)。只读、失败不抛。"""
|
|
871
|
+
try:
|
|
872
|
+
node = cg.get(node_id)
|
|
873
|
+
except Exception: # noqa: BLE001
|
|
874
|
+
node = None
|
|
875
|
+
e = ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id)
|
|
876
|
+
if not node and not e:
|
|
877
|
+
return {"ok": False, "error": "node_not_found", "node_id": node_id}
|
|
878
|
+
fm = (node or {}).get("frontmatter") or {}
|
|
879
|
+
deps = as_deps(fm.get(DEPS_FIELD)) or as_deps((e or {}).get(DEPS_FIELD))
|
|
880
|
+
kind, start, end = validity(fm if fm else (e or {}), now=now)
|
|
881
|
+
dep_map = dependents_index(cg)
|
|
882
|
+
hist = list(fm.get(HISTORY_FIELD) or [])[-5:]
|
|
883
|
+
return {"ok": True, "node_id": node_id,
|
|
884
|
+
"verification_state": state_of(fm if fm else (e or {})),
|
|
885
|
+
"verification_basis": fm.get("verification_basis"),
|
|
886
|
+
"verification_method": fm.get("verification_method"),
|
|
887
|
+
"verified_at": fm.get("verified_at"),
|
|
888
|
+
"depends_on": deps,
|
|
889
|
+
"depended_by": dep_map.get(node_id, []),
|
|
890
|
+
"validity": {"kind": kind, "valid_from": fm.get(FROM_FIELD),
|
|
891
|
+
"valid_until": fm.get(UNTIL_FIELD),
|
|
892
|
+
"start": start, "end": end},
|
|
893
|
+
"history": hist,
|
|
894
|
+
"lifecycle_state": fm.get("lifecycle_state"),
|
|
895
|
+
"protected": bool(fm.get("protected") or fm.get("immutable"))}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def summary(cg) -> dict:
|
|
899
|
+
"""轻量摘要(只读;失败不抛,避免拖垮 health / 常驻循环)。"""
|
|
900
|
+
try:
|
|
901
|
+
rep = patrol(cg, limit=3)
|
|
902
|
+
return {"dangling": rep["dangling_count"], "expired": rep["expired_count"],
|
|
903
|
+
"not_yet": rep["not_yet_count"], "doubted": rep["doubted_count"],
|
|
904
|
+
"nodes": rep["nodes"], "sample": [
|
|
905
|
+
f"{r['node_id']}->?{r['missing']}" for r in rep["dangling"]]}
|
|
906
|
+
except Exception: # noqa: BLE001
|
|
907
|
+
return {}
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def load_ledger(cg, *, node_id: str = None, limit: int = None) -> list:
|
|
911
|
+
"""读验证态台账(跳过坏行;可按节点过滤)。"""
|
|
912
|
+
path = os.path.join(cg.root, AUDIT_FILE)
|
|
913
|
+
out = []
|
|
914
|
+
for r in read_jsonl(path):
|
|
915
|
+
if not isinstance(r, dict):
|
|
916
|
+
continue
|
|
917
|
+
if node_id and r.get("node_id") != node_id:
|
|
918
|
+
continue
|
|
919
|
+
out.append(r)
|
|
920
|
+
return out[:int(limit)] if limit else out
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def catalog(root: str = None) -> dict:
|
|
924
|
+
"""自描述(供 MCP catalog / 人工核对)。"""
|
|
925
|
+
return {
|
|
926
|
+
"layer": "可验证记忆单元",
|
|
927
|
+
"question": "它还成不成立(验证态 + 依赖 + 时效)",
|
|
928
|
+
"ledger": os.path.join(root, AUDIT_FILE) if root else AUDIT_FILE,
|
|
929
|
+
"states": list(STATES),
|
|
930
|
+
"state_field": STATE_FIELD,
|
|
931
|
+
"history_field": HISTORY_FIELD,
|
|
932
|
+
"deps_field": DEPS_FIELD,
|
|
933
|
+
"time_fields": [FROM_FIELD, UNTIL_FIELD],
|
|
934
|
+
"transitions": sorted(f"{a}→{b}" for a, b in TRANSITIONS),
|
|
935
|
+
"max_deps": MAX_DEPS,
|
|
936
|
+
"max_nodes_default": MAX_NODES_DEFAULT,
|
|
937
|
+
"discipline": {"never_block_write": True, "no_backfill": True,
|
|
938
|
+
"patrol_readonly": True, "one_hop_sync": True,
|
|
939
|
+
"multi_hop_async": True, "only_downgrade": True},
|
|
940
|
+
"distinct_from": ("lifecycle.py = 节点生命周期(lifecycle_state);"
|
|
941
|
+
"provenance.py = 派生血缘(_link.jsonl);"
|
|
942
|
+
"本层 = 验证态与依赖(verification_state/_trust.jsonl)"),
|
|
943
|
+
}
|