@furongjun1999/dsh-memory 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -18
- package/docs/README.md +1 -0
- package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +223 -0
- package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.1.md +340 -0
- package/docs/eval/issue50_/345/205/203/346/225/260/346/215/256/351/200/217/344/274/240/344/270/216/345/205/234/345/272/225_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +437 -0
- package/docs/eval/issue50_/345/215/212/351/207/215/345/244/215/345/276/205/345/256/232/345/244/215/346/240/270_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +488 -0
- package/docs/eval/issue50_/345/276/205/345/256/232/345/244/215/346/240/270/345/205/245/351/230/237_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +428 -0
- package/docs/eval/issue50_/350/257/273/351/235/242/344/277/235/346/212/244/345/217/252/350/256/244/346/230/276/345/274/217/346/235/245/346/272/220_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +271 -0
- package/docs/eval/issue50_/351/207/215/350/246/201/345/272/246/345/220/214/346/272/220/344/270/216/344/277/235/346/212/244/350/257/255/344/271/211_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +306 -0
- package/docs/eval/issue51_/344/270/200/351/224/256/345/256/211/350/243/205/345/244/261/350/264/245_/345/275/222/345/261/236/345/210/244/345/256/232_v1.0.md +58 -0
- package/docs/eval/issue52_/346/235/241/344/273/266/345/205/210/350/241/214/344/270/216/346/210/252/346/226/255/345/217/257/350/247/202/346/265/213_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +360 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/241/346/241/243/344/275/215/345/215/225/344/270/200/345/205/245/345/217/243_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +651 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/242/345/217/230/346/233/264/345/215/225/344/270/216/345/233/236/346/273/232/345/216/237/350/257/255_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +439 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/243/345/207/206/345/205/245/350/257/273/346/225/260_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1337 -0
- package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/244/346/224/266/345/256/230/344/270/216/345/205/250/351/223/276/351/252/214/346/224/266_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1817 -0
- package/docs/eval//345/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +460 -0
- package/docs/eval//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +97 -0
- package/docs/eval//345/217/221/345/270/20309_/346/243/200/347/264/242/351/235/242/344/270/211/346/211/271/346/224/266/345/217/243_v1.0.md +66 -0
- package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +458 -0
- package/docs/eval//347/254/2543/345/261/202stg/347/273/223/346/236/204/347/264/242/345/274/225_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +250 -0
- package/docs/hive//346/243/200/347/264/242/347/256/227/346/263/225/345/217/243/345/276/204/345/257/271/347/205/247_v0.1.md +136 -11
- package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +32 -2
- package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +88 -0
- package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
- package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +47 -11
- package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +183 -0
- package/docs/plans/stg/346/235/241/344/273/266/345/214/226/344/270/216/347/273/223/346/236/204/347/264/242/345/274/225_/350/256/276/350/256/241_v0.1.md +124 -0
- package/docs/plans//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +547 -0
- package/docs/plans//350/256/260/345/277/206/350/207/252/345/244/204/347/220/206/344/270/211/346/241/243/350/207/252/346/262/273_/350/256/276/350/256/241_v0.2.md +207 -0
- package/md_cg/admission.py +718 -0
- package/md_cg/autonomy_modes.py +642 -0
- package/md_cg/bench_e2e_locomo_qa.py +11 -4
- package/md_cg/chain.py +47 -0
- package/md_cg/consistency.py +133 -8
- package/md_cg/forgetting.py +38 -6
- package/md_cg/freshness.py +527 -0
- package/md_cg/generation.py +409 -0
- package/md_cg/hotcache.py +4 -1
- package/md_cg/lifecycle.py +30 -2
- package/md_cg/mcp_server.py +191 -26
- package/md_cg/mdcg.py +594 -39
- package/md_cg/mdcos.py +869 -38
- package/md_cg/nodefile.py +74 -1
- package/md_cg/protect.py +79 -4
- package/md_cg/provenance.py +1 -1
- package/md_cg/review_cli.py +31 -2
- package/md_cg/rollback.py +411 -0
- package/md_cg/rollback_cli.py +104 -0
- package/md_cg/semantic/canonical.py +22 -0
- package/md_cg/semantic/unify.py +73 -22
- package/md_cg/semantic/unify_fixture.json +25 -0
- package/md_cg/sleep.py +1297 -0
- package/md_cg/stg.py +400 -50
- package/md_cg/stgidx.py +281 -0
- package/md_cg/sustain.py +146 -9
- package/md_cg/test_auto_defaults.py +424 -0
- package/md_cg/test_autonomy_admission.py +1098 -0
- package/md_cg/test_autonomy_modes.py +1972 -0
- package/md_cg/test_b1_auto_id_multiproc.py +7 -0
- package/md_cg/test_b1b2_write_face.py +7 -0
- package/md_cg/test_b3_merge_keeps_content.py +7 -0
- package/md_cg/test_boundary_hit.py +410 -0
- package/md_cg/test_cons200_scan_selection.py +791 -0
- package/md_cg/test_en_pipeline.py +22 -13
- package/md_cg/test_generation_guard.py +352 -0
- package/md_cg/test_h4_sustain_snapshot.py +14 -4
- package/md_cg/test_i50a_half_dup_defer.py +408 -0
- package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
- package/md_cg/test_i50c_meta_passthrough.py +608 -0
- package/md_cg/test_i50d_importance_source.py +531 -0
- package/md_cg/test_i50e_readside_protection.py +656 -0
- package/md_cg/test_issue39_utf8_stdio.py +9 -1
- package/md_cg/test_issue52_scan_condition_first.py +677 -0
- package/md_cg/test_linkref.py +7 -0
- package/md_cg/test_mode_parity.py +1393 -0
- package/md_cg/test_mutation_rollback.py +805 -0
- package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
- package/md_cg/test_n212_n213_n224_generation_gates.py +14 -8
- package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
- package/md_cg/test_n230_dirty_replay.py +375 -0
- package/md_cg/test_p2_mcp.py +8 -0
- package/md_cg/test_p2_six_elements.py +463 -0
- package/md_cg/test_p3_legacy_closure.py +433 -0
- package/md_cg/test_p4_freshness.py +680 -0
- package/md_cg/test_p8_subgraph_chain.py +14 -1
- package/md_cg/test_p9_forget_protect.py +7 -0
- package/md_cg/test_p9c_dedup_hints.py +7 -0
- package/md_cg/test_policy_required_ccg.py +5 -1
- package/md_cg/test_protocol.py +7 -0
- package/md_cg/test_rank_parity_score_mode.py +6 -0
- package/md_cg/test_semantic_canonical.py +5 -3
- package/md_cg/test_sleep.py +611 -0
- package/md_cg/test_sleep_p1.py +784 -0
- package/md_cg/test_stgidx_index_parity.py +1020 -0
- package/md_cg/test_time_core_lint.py +968 -0
- package/md_cg/test_unify_default_off.py +701 -0
- package/md_cg/test_unify_scope.py +182 -0
- package/md_cg/test_writelimit.py +36 -9
- package/md_cg/test_writepipe.py +5 -2
- package/md_cg/weights.py +15 -1
- package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
- package/md_cg/writepipe.py +101 -2
- package/package.json +3 -2
- package/skills/plugin.json +1 -1
- package/utf8_boot.py +237 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""三档自治 · 准入读数 R1–R4 与准入闸(设计 v0.2 §六/§七 · 批次④)。
|
|
3
|
+
|
|
4
|
+
设计真源(`docs/plans/记忆自处理三档自治_设计_v0.2.md`,已签收冻结):
|
|
5
|
+
· §七「放权阶梯与准入读数」——四条读数 R1–R4、门槛、窗口(裁定③:
|
|
6
|
+
长期 30 天 / 短期 3 天);「读数即断言……常驻断言(`conformance`/`census`
|
|
7
|
+
家族,只告警不改数据),随时可答『它今天够格吗』」;
|
|
8
|
+
· §六「完全访问·准入」——不满足时该档**不可选中**(fail-closed 并报
|
|
9
|
+
「缺哪一条读数」,不静默降级);
|
|
10
|
+
· §十一「验收判据」——R1–R4 各有独立断言与一组「门槛刚过/刚不过」的
|
|
11
|
+
边界用例(含窗口边界 3 天 / 30 天)。
|
|
12
|
+
|
|
13
|
+
三条纪律(与 `conformance` 家族同口径):
|
|
14
|
+
1. **只读**:本模块不写任何文件——读数与准入判定都不改数据(设计 §七
|
|
15
|
+
「只告警不改数据」)。
|
|
16
|
+
2. **如实**:窗口样本不足 ⇒ 该读数 `ok=None`(「不可判」),绝不冒充
|
|
17
|
+
PASS;缺数据面 ⇒ 在读数体里指名缺哪个文件;有未纳入的归档面 ⇒
|
|
18
|
+
在 `detail` 里如实点名(见 R3)。
|
|
19
|
+
3. **不静默**:`ok` 不满足时读数体给出 `missing`/`detail`,由调用方
|
|
20
|
+
(`autonomy_modes.settle`)原样报出——报「缺哪一条读数」。
|
|
21
|
+
|
|
22
|
+
四条读数(判据逐字照设计 §七 表;窗口 = 裁定③):
|
|
23
|
+
|
|
24
|
+
**R1 判断非退化**(长期 30 天):`_forgetting.jsonl` 窗口内 DROP/MERGE/DEFER
|
|
25
|
+
各自出现过,且单一 verdict 占比 < 90%。数据面 `_forgetting.jsonl`;另附
|
|
26
|
+
全量分布 `summary_all`(与 `forgetting.summary` 同源同口径——两处都流式
|
|
27
|
+
聚合同一个文件;本模块为保持热路径 import 面轻量不引 `forgetting`,
|
|
28
|
+
守卫有常量一致性断言钉死同值)。
|
|
29
|
+
|
|
30
|
+
**R2 会说「不」**(短期 3 天 + 长期 30 天总量):`rejected/` 与 `unresolved/`
|
|
31
|
+
两层**近期新增**——3 天内各 ≥1,且 30 天总量各 ≥1。判窗用节点
|
|
32
|
+
`frontmatter.created_at`(实测两层的 add 均落该键);无 `created_at` 的
|
|
33
|
+
节点计入 `undated` 不计窗(如实,不猜文件 mtime)。
|
|
34
|
+
|
|
35
|
+
**R3 破坏可逆且「回过」**(长期 30 天):30 天内 ≥1 次**真实回滚**。
|
|
36
|
+
**探针实测(步骤③报告 §6.1 第 3 条)**:回滚无独立留痕——`rollback_mutation`
|
|
37
|
+
不写台账;C/B 回滚前后 `decisions.jsonl`/`_audit.jsonl` 零新增;D 回滚经
|
|
38
|
+
`restore` 写 `_audit.jsonl` 但行内不含 pid。故本读数用**状态推断**两条
|
|
39
|
+
证据面(`method="state_inference"`,逐条如实标注):
|
|
40
|
+
· **D 面**(`audit_restore`):活动分片 `_audit.jsonl` 的 `op="restore"`
|
|
41
|
+
行(时刻在 30 天窗内)且同 id 有**更早**的 `op="forget"` 行
|
|
42
|
+
⇒「删过又回过」;
|
|
43
|
+
· **C/B 面**(`preimage_pair`):`decisions.jsonl` 的 accept 执行记录
|
|
44
|
+
(30 天窗内、`rec["mutation"]` 为 dict)+ 执行时点前像在位
|
|
45
|
+
(`_protected_history/` 文件 + `_protected_audit.jsonl` 的
|
|
46
|
+
`action="preimage"` 行)+ **现盘面与该前像逐字节相等** ⇒
|
|
47
|
+
「曾变更且现回到前像」。
|
|
48
|
+
成本边界(如实):`_audit.jsonl` 只读**活动分片**;库存在归档分片
|
|
49
|
+
(`_audit_archive/`,在役实测 8 片 ≈ 536MB)时**不**读(准入检查须限
|
|
50
|
+
成本),读数体在 `archived_shards` / `detail` 里如实点名——落在归档里的
|
|
51
|
+
`restore` 会被漏计(留池:需要时离线全扫)。
|
|
52
|
+
|
|
53
|
+
**R4 判断收敛**(长期 30 天内 3 个 10 天子窗):驳回率(reject+edit ÷
|
|
54
|
+
全部裁定)**逐窗严格下降**且最近子窗 < 20%。任一子窗无裁定样本 ⇒
|
|
55
|
+
`ok=None`「不可判」(窗口不足 3 个子窗,如实上报,不假装)。
|
|
56
|
+
|
|
57
|
+
触发频度与缓存(契约「关键约束」的落点):`gate()` 是读数唯一入口,
|
|
58
|
+
带**进程级 TTL 缓存**(缺省 60s;键 = root 绝对路径)——热路径
|
|
59
|
+
(`autonomy_modes.mode()`/`decide()`)**零 IO**:只读 `settle()` 冻结的
|
|
60
|
+
结算态;批量 `settle()` 在 TTL 内复用读数(不重扫)。稳态下一次完整
|
|
61
|
+
读数扫描频率 ≤ 1 次 / TTL。在役库实测:一次完整扫描 ≈ 0.3s(主成本 =
|
|
62
|
+
`_audit.jsonl` 10.5 万行流式 0.26s;`_forgetting.jsonl`/`decisions.jsonl`
|
|
63
|
+
均 < 0.05s)——TTL=60s 即最坏每分钟一次。
|
|
64
|
+
|
|
65
|
+
CLI(「全入口可答『它今天够格吗』」):
|
|
66
|
+
python -X utf8 -m md_cg.admission [--root R] [--json out.json] [--ttl N]
|
|
67
|
+
退出码:0 = 四条全满足;1 = 有未满足或不可判;2 = root 不可读。
|
|
68
|
+
|
|
69
|
+
**补强批次(v1.1,2026-10-02)**:数值容错收口——`_ts`/`_fm_ts` 对不可表示的
|
|
70
|
+
时间戳值(超出 float 范围的整数型数值、±inf、NaN 等)一律**视同缺时间戳**
|
|
71
|
+
(返回 None ⇒ 读数的 `undated` 桶),**不抛**:口径与 `_stream_jsonl`「坏行
|
|
72
|
+
跳过不抛」、`_ts` docstring「非正数返回 None」一致(前述纪律②「如实」)。
|
|
73
|
+
四条读数与 `check()` 对含此类坏值的数据面不再崩溃(补强前:
|
|
74
|
+
`{"t": <400 位整数>}` 使 `float(v)` 抛 `OverflowError`、级联 R1–R4/`check`/
|
|
75
|
+
`settle` 全崩——复核 uncovered,见步骤④报告 §5.3/§六.12);合法数值行为
|
|
76
|
+
逐位不变。数值闸单点见 `_as_ts`。
|
|
77
|
+
|
|
78
|
+
不适用条件:不做常驻巡检的自动接线(本批落读数与判定单点;挂 sustain/
|
|
79
|
+
启动面的接线由调用方按需调用 `gate`/`settle`,见 `autonomy_modes.settle`)。
|
|
80
|
+
"""
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
import argparse
|
|
84
|
+
import json
|
|
85
|
+
import os
|
|
86
|
+
import sys
|
|
87
|
+
import time
|
|
88
|
+
|
|
89
|
+
from . import nodefile
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"DAY", "WINDOW_SHORT_D", "WINDOW_LONG_D",
|
|
93
|
+
"R1_MAX_SHARE", "R1_VERDICTS", "R2_MIN_RECENT", "R2_MIN_TOTAL",
|
|
94
|
+
"R3_MIN_ROLLBACKS", "R4_MAX_RATE", "R4_SUBWINDOWS", "R4_SUBWINDOW_D",
|
|
95
|
+
"TTL_DEFAULT", "FORGETTING_LOG", "DEVICE_AUDIT", "PROTECT_AUDIT",
|
|
96
|
+
"DECISIONS_LOG", "LAYERS_SAY_NO",
|
|
97
|
+
"r1_non_degenerate", "r2_says_no", "r3_reversible", "r4_converging",
|
|
98
|
+
"check", "gate", "cache_stats", "reset_cache", "default_root", "render",
|
|
99
|
+
"main",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
DAY = 86400.0
|
|
103
|
+
#: 裁定③:短期 = 3 天(R2 活性读数);长期 = 30 天(R1/R3/R4 能力读数)。
|
|
104
|
+
WINDOW_SHORT_D = 3
|
|
105
|
+
WINDOW_LONG_D = 30
|
|
106
|
+
|
|
107
|
+
#: R1:单一 verdict 占比上限(严格小于——「恰 90%」不满足)。
|
|
108
|
+
R1_MAX_SHARE = 0.90
|
|
109
|
+
#: R1:必须各自出现过的 verdict 三态(设计 §七 表逐字)。
|
|
110
|
+
R1_VERDICTS = ("DROP", "MERGE", "DEFER")
|
|
111
|
+
#: R1 三态「各自出现过」的计数下限(>0)。
|
|
112
|
+
R1_MIN_ONE = 1
|
|
113
|
+
|
|
114
|
+
#: R2:短期 3 天内每层新增下限;长期 30 天总量每层下限。
|
|
115
|
+
R2_MIN_RECENT = 1
|
|
116
|
+
R2_MIN_TOTAL = 1
|
|
117
|
+
#: R2 计数面两层(设计 §九「它今天拒绝过吗 / 它不确定过吗」)。
|
|
118
|
+
LAYERS_SAY_NO = ("rejected", "unresolved")
|
|
119
|
+
|
|
120
|
+
#: R3:30 天内真实回滚次数下限。
|
|
121
|
+
R3_MIN_ROLLBACKS = 1
|
|
122
|
+
|
|
123
|
+
#: R4:3 个 10 天子窗;最近子窗驳回率上限(严格小于——「恰 20%」不满足);
|
|
124
|
+
#: 「逐窗下降」取**严格递减**读法(平坦不算下降)。
|
|
125
|
+
R4_MAX_RATE = 0.20
|
|
126
|
+
R4_SUBWINDOWS = 3
|
|
127
|
+
R4_SUBWINDOW_D = 10.0
|
|
128
|
+
|
|
129
|
+
#: 读数缓存 TTL(秒)——探针定:在役库一次完整扫描 ≈ 0.3s(主成本 =
|
|
130
|
+
#: `_audit.jsonl` 10.5 万行流式 0.26s),TTL=60s ⇒ 最坏每分钟一次,热路径零 IO。
|
|
131
|
+
TTL_DEFAULT = 60.0
|
|
132
|
+
|
|
133
|
+
# ---- 数据面文件名(字面量出处;守卫有常量一致性断言钉死与真源同值)----------
|
|
134
|
+
#: = `forgetting.LOG_FILE`(按值复制:引 forgetting 会把 crypto/mdcg 重依赖
|
|
135
|
+
#: 拉进本模块的 import 面;一致性由守卫断言 `== forgetting.LOG_FILE` 保证)。
|
|
136
|
+
FORGETTING_LOG = "_forgetting.jsonl"
|
|
137
|
+
#: = `mdcos` 的设备审计活动分片(root 下 `_audit.jsonl`;归档在 `_audit_archive/`)。
|
|
138
|
+
DEVICE_AUDIT = "_audit.jsonl"
|
|
139
|
+
#: = `protect.AUDIT_FILE`(前像/快照审计)。
|
|
140
|
+
PROTECT_AUDIT = "_protected_audit.jsonl"
|
|
141
|
+
#: = `mdcos` 的裁决记录(`hippocampus/decisions.jsonl`)。
|
|
142
|
+
DECISIONS_LOG = os.path.join("hippocampus", "decisions.jsonl")
|
|
143
|
+
#: = `mdcos.AUDIT_ARCHIVE`(归档分片目录;R3 不读、只计数如实点名)。
|
|
144
|
+
AUDIT_ARCHIVE = "_audit_archive"
|
|
145
|
+
#: = `protect.HISTORY_DIR`(前像/快照目录)。
|
|
146
|
+
HISTORY_DIR = "_protected_history"
|
|
147
|
+
#: R3 盘面定位要跳过的目录(元数据/归档面;`_` 前缀整支另按前缀跳过)。
|
|
148
|
+
_SKIP_DIRS = ("trash", "hippocampus", "rejected", "unresolved", "goals",
|
|
149
|
+
"structural", "data", "anchor")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------- 小工具
|
|
153
|
+
|
|
154
|
+
# 生效条件:now 为假值(None)时返回 time.time(),否则返回 float(now)——所有读数函数共用的「现在」单点(可注入以便守卫控制时间窗);
|
|
155
|
+
def _now(now=None) -> float:
|
|
156
|
+
return time.time() if now is None else float(now)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# 生效条件:v 能表示为**有限正浮点**(int/float,含 bool——与旧行为一致)时返回该 float;不可表示(超出 float 范围的整数型数值)/±inf/NaN/非正数/非数值型一律返回 None——坏值不抛;本函数是 _ts/_fm_ts 共用的数值闸单点(补强批次);
|
|
160
|
+
def _as_ts(v):
|
|
161
|
+
"""时间戳候选值 → 有限正 float;不可表示值一律 None(不抛,坏行口径)。
|
|
162
|
+
|
|
163
|
+
为什么单点:`float(v)` 对超大整数型数值抛 `OverflowError`(如 400 位十进制
|
|
164
|
+
整数),±inf 转出非有限值——两者都不得让读数/结算崩溃(补强批次;口径见
|
|
165
|
+
模块 docstring)。判定顺序:类型 → 可转换 → 有限且正。
|
|
166
|
+
"""
|
|
167
|
+
if not isinstance(v, (int, float)):
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
f = float(v)
|
|
171
|
+
except (OverflowError, ValueError, TypeError):
|
|
172
|
+
return None
|
|
173
|
+
if not (0.0 < f < float("inf")):
|
|
174
|
+
return None
|
|
175
|
+
return f
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# 生效条件:rec 为 dict 且含正的数值时间键 t(缺时回退 created_at/time)时返回该 float;全缺、非正数或不可表示(见 _as_ts)时返回 None;
|
|
179
|
+
def _ts(rec) -> float:
|
|
180
|
+
"""JSONL 行时间戳(本仓惯例键序:t > created_at > time)。
|
|
181
|
+
|
|
182
|
+
坏值容错(补强批次):数值超出 float 表示范围(如 400 位十进制整数)、
|
|
183
|
+
±inf、NaN 一律视同**缺时间戳**(None ⇒ 读数的 undated 桶),不抛——与
|
|
184
|
+
`_stream_jsonl`「坏行跳过不抛」同口径(模块 docstring 纪律②)。
|
|
185
|
+
"""
|
|
186
|
+
if not isinstance(rec, dict):
|
|
187
|
+
return None
|
|
188
|
+
for k in ("t", "created_at", "time"):
|
|
189
|
+
f = _as_ts(rec.get(k))
|
|
190
|
+
if f is not None:
|
|
191
|
+
return f
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# 生效条件:fm 为 dict 且含正的数值 created_at(缺时回退 t)时返回该 float;否则 None(不可表示值同样归 None,见 _as_ts);
|
|
196
|
+
def _fm_ts(fm) -> float:
|
|
197
|
+
"""节点 frontmatter 的创建时间(实测 rejected/unresolved 的 add 均落 created_at)。
|
|
198
|
+
|
|
199
|
+
坏值容错(补强批次):同 `_ts`——不可表示/±inf/NaN 视同缺时间戳(None),
|
|
200
|
+
由 R2 计入 `undated`(如实),不抛。
|
|
201
|
+
"""
|
|
202
|
+
if not isinstance(fm, dict):
|
|
203
|
+
return None
|
|
204
|
+
for k in ("created_at", "t"):
|
|
205
|
+
f = _as_ts(fm.get(k))
|
|
206
|
+
if f is not None:
|
|
207
|
+
return f
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# 生效条件:path 为可读文本文件时逐行 yield 解析成功的 dict(空行/坏行跳过,不抛);文件不存在或不可读时零 yield;
|
|
212
|
+
def _stream_jsonl(path):
|
|
213
|
+
"""流式 JSONL 迭代(不把大文件读进内存;坏行跳过不抛)。"""
|
|
214
|
+
try:
|
|
215
|
+
f = open(path, "r", encoding="utf-8", errors="replace")
|
|
216
|
+
except OSError:
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
for line in f:
|
|
220
|
+
line = line.strip()
|
|
221
|
+
if not line:
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
rec = json.loads(line)
|
|
225
|
+
except Exception: # noqa: BLE001
|
|
226
|
+
continue
|
|
227
|
+
if isinstance(rec, dict):
|
|
228
|
+
yield rec
|
|
229
|
+
finally:
|
|
230
|
+
f.close()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# 生效条件:root 为目录时返回 {rel_path: (size, mtime_ns)}(只读,不跟随目录符号链接);root 不存在返回空 dict;
|
|
234
|
+
def _fingerprint(root):
|
|
235
|
+
"""目录指纹(相对路径 → (size, mtime_ns))——守卫用来证明读数零写入。"""
|
|
236
|
+
out = {}
|
|
237
|
+
if not os.path.isdir(root):
|
|
238
|
+
return out
|
|
239
|
+
for dirpath, dirs, files in os.walk(root):
|
|
240
|
+
dirs.sort()
|
|
241
|
+
for fn in sorted(files):
|
|
242
|
+
p = os.path.join(dirpath, fn)
|
|
243
|
+
rel = os.path.relpath(p, root).replace("\\", "/")
|
|
244
|
+
try:
|
|
245
|
+
st = os.stat(p)
|
|
246
|
+
except OSError:
|
|
247
|
+
out[rel] = None
|
|
248
|
+
continue
|
|
249
|
+
out[rel] = (st.st_size, st.st_mtime_ns)
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------- R1
|
|
254
|
+
|
|
255
|
+
# 生效条件:root 下 `_forgetting.jsonl` 可读时流式统计窗口(30 天,按行 t)内与全量 verdict 分布,返回读数体 dict(ok=窗口内 DROP/MERGE/DEFER 各自 ≥1 且最大占比 < R1_MAX_SHARE);文件缺失时按 total_win=0 且 ok=False 返回(detail 指名缺文件);
|
|
256
|
+
def r1_non_degenerate(root, now=None) -> dict:
|
|
257
|
+
"""R1 判断非退化(窗口 30 天):verdict 分布非常量,单一 verdict 占比 < 90%。"""
|
|
258
|
+
now = _now(now)
|
|
259
|
+
path = os.path.join(root, FORGETTING_LOG)
|
|
260
|
+
by_all, by_win = {}, {}
|
|
261
|
+
total_all = total_win = undated_all = 0
|
|
262
|
+
if os.path.exists(path):
|
|
263
|
+
for rec in _stream_jsonl(path):
|
|
264
|
+
v = str(rec.get("verdict") or "?")
|
|
265
|
+
by_all[v] = by_all.get(v, 0) + 1
|
|
266
|
+
total_all += 1
|
|
267
|
+
t = _ts(rec)
|
|
268
|
+
if t is None:
|
|
269
|
+
undated_all += 1
|
|
270
|
+
continue
|
|
271
|
+
if 0 <= (now - t) <= WINDOW_LONG_D * DAY:
|
|
272
|
+
by_win[v] = by_win.get(v, 0) + 1
|
|
273
|
+
total_win += 1
|
|
274
|
+
seen = {v: by_win.get(v, 0) for v in R1_VERDICTS}
|
|
275
|
+
missing_verdicts = [v for v, n in seen.items() if n < R1_MIN_ONE]
|
|
276
|
+
top = max(by_win, key=by_win.get) if by_win else None
|
|
277
|
+
top_share = (by_win[top] / total_win) if total_win else None
|
|
278
|
+
ok = bool(total_win > 0 and not missing_verdicts
|
|
279
|
+
and top_share is not None and top_share < R1_MAX_SHARE)
|
|
280
|
+
if not os.path.exists(path):
|
|
281
|
+
detail = "缺数据面:%s 不存在(库从未留痕?)——不可判。" % FORGETTING_LOG
|
|
282
|
+
elif total_win == 0:
|
|
283
|
+
detail = ("%s 在 %d 天窗内 0 条留痕(全量 %d 条)——样本不足,不可判。"
|
|
284
|
+
% (FORGETTING_LOG, WINDOW_LONG_D, total_all))
|
|
285
|
+
elif missing_verdicts:
|
|
286
|
+
detail = ("%d 天窗内 verdict 三态不全:缺 %s(窗口内 %s)——分布退化。"
|
|
287
|
+
% (WINDOW_LONG_D, "、".join(missing_verdicts), by_win))
|
|
288
|
+
elif top_share is not None and top_share >= R1_MAX_SHARE:
|
|
289
|
+
detail = ("%d 天窗内单一 verdict 占比 %.1f%% ≥ %.0f%%(%s)——分布退化。"
|
|
290
|
+
% (WINDOW_LONG_D, top_share * 100, R1_MAX_SHARE * 100, top))
|
|
291
|
+
else:
|
|
292
|
+
detail = ("%d 天窗内 %d 条:%s;最大占比 %.1f%% < %.0f%%——判断非退化。"
|
|
293
|
+
% (WINDOW_LONG_D, total_win, by_win,
|
|
294
|
+
(top_share or 0) * 100, R1_MAX_SHARE * 100))
|
|
295
|
+
return {"id": "R1", "ok": ok, "window_days": WINDOW_LONG_D,
|
|
296
|
+
"total": total_win, "by_verdict": by_win,
|
|
297
|
+
"seen": seen, "missing_verdicts": missing_verdicts,
|
|
298
|
+
"top": top, "top_share": top_share,
|
|
299
|
+
"summary_all": {"total": total_all, "by_verdict": by_all},
|
|
300
|
+
"undated": undated_all,
|
|
301
|
+
"thresholds": {"max_share": R1_MAX_SHARE,
|
|
302
|
+
"required": list(R1_VERDICTS)},
|
|
303
|
+
"detail": detail}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------- R2
|
|
307
|
+
|
|
308
|
+
# 生效条件:root 下 rejected/ 与 unresolved/ 目录可遍历时逐 .md 解析 frontmatter.created_at,返回两层计数与最近时间(recent=3 天内、total_30d=30 天内、undated=无 created_at 数);ok=短期两层各 ≥R2_MIN_RECENT 且长期两层各 ≥R2_MIN_TOTAL;目录缺失按 0 计并计入 detail;
|
|
309
|
+
def r2_says_no(root, now=None) -> dict:
|
|
310
|
+
"""R2 会说「不」(短期 3 天各 ≥1 + 长期 30 天总量各 ≥1)。"""
|
|
311
|
+
now = _now(now)
|
|
312
|
+
layers = {}
|
|
313
|
+
for layer in LAYERS_SAY_NO:
|
|
314
|
+
d = os.path.join(root, layer)
|
|
315
|
+
n = recent = total_30 = undated = 0
|
|
316
|
+
latest = None
|
|
317
|
+
exists = os.path.isdir(d)
|
|
318
|
+
if exists:
|
|
319
|
+
for dirpath, dirs, files in os.walk(d):
|
|
320
|
+
for fn in files:
|
|
321
|
+
if not fn.endswith(".md"):
|
|
322
|
+
continue
|
|
323
|
+
n += 1
|
|
324
|
+
try:
|
|
325
|
+
with open(os.path.join(dirpath, fn),
|
|
326
|
+
encoding="utf-8", errors="replace") as f:
|
|
327
|
+
fm, _content = nodefile.loads(f.read())
|
|
328
|
+
except OSError:
|
|
329
|
+
undated += 1
|
|
330
|
+
continue
|
|
331
|
+
ca = _fm_ts(fm)
|
|
332
|
+
if ca is None:
|
|
333
|
+
undated += 1
|
|
334
|
+
continue
|
|
335
|
+
latest = ca if latest is None else max(latest, ca)
|
|
336
|
+
if 0 <= (now - ca) <= WINDOW_SHORT_D * DAY:
|
|
337
|
+
recent += 1
|
|
338
|
+
if 0 <= (now - ca) <= WINDOW_LONG_D * DAY:
|
|
339
|
+
total_30 += 1
|
|
340
|
+
layers[layer] = {"exists": exists, "total": n, "recent": recent,
|
|
341
|
+
"total_30d": total_30, "latest_created_at": latest,
|
|
342
|
+
"undated": undated}
|
|
343
|
+
short_ok = all(layers[l]["recent"] >= R2_MIN_RECENT for l in LAYERS_SAY_NO)
|
|
344
|
+
long_ok = all(layers[l]["total_30d"] >= R2_MIN_TOTAL for l in LAYERS_SAY_NO)
|
|
345
|
+
ok = bool(short_ok and long_ok)
|
|
346
|
+
short_missing = [l for l in LAYERS_SAY_NO
|
|
347
|
+
if layers[l]["recent"] < R2_MIN_RECENT]
|
|
348
|
+
long_missing = [l for l in LAYERS_SAY_NO
|
|
349
|
+
if layers[l]["total_30d"] < R2_MIN_TOTAL]
|
|
350
|
+
if ok:
|
|
351
|
+
detail = ("%d 天窗内两层各有新增(%s);%d 天总量各 ≥%d——会说「不」。"
|
|
352
|
+
% (WINDOW_SHORT_D,
|
|
353
|
+
"、".join("%s=%d" % (l, layers[l]["recent"])
|
|
354
|
+
for l in LAYERS_SAY_NO),
|
|
355
|
+
WINDOW_LONG_D, R2_MIN_TOTAL))
|
|
356
|
+
else:
|
|
357
|
+
parts = []
|
|
358
|
+
if short_missing:
|
|
359
|
+
parts.append("%d 天窗内新增不足:%s" % (
|
|
360
|
+
WINDOW_SHORT_D,
|
|
361
|
+
"、".join("%s=%d" % (l, layers[l]["recent"]) for l in short_missing)))
|
|
362
|
+
if long_missing:
|
|
363
|
+
parts.append("%d 天总量不足:%s" % (
|
|
364
|
+
WINDOW_LONG_D,
|
|
365
|
+
"、".join("%s=%d" % (l, layers[l]["total_30d"])
|
|
366
|
+
for l in long_missing)))
|
|
367
|
+
if not parts:
|
|
368
|
+
parts.append("读数异常(无缺口但 ok=False)——如实上报")
|
|
369
|
+
undated = {l: layers[l]["undated"] for l in LAYERS_SAY_NO
|
|
370
|
+
if layers[l]["undated"]}
|
|
371
|
+
if undated:
|
|
372
|
+
parts.append("无 created_at 未计窗:%s" % undated)
|
|
373
|
+
detail = ";".join(parts) + "。"
|
|
374
|
+
return {"id": "R2", "ok": ok,
|
|
375
|
+
"window_days_short": WINDOW_SHORT_D, "window_days_long": WINDOW_LONG_D,
|
|
376
|
+
"layers": layers, "short_missing": short_missing,
|
|
377
|
+
"long_missing": long_missing,
|
|
378
|
+
"thresholds": {"recent_min": R2_MIN_RECENT,
|
|
379
|
+
"total_min": R2_MIN_TOTAL},
|
|
380
|
+
"detail": detail}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---------------------------------------------------------------- R3
|
|
384
|
+
|
|
385
|
+
# 生效条件:root 下数据面可读时收集两类「回过」证据——① 活动分片 _audit.jsonl 的 op=restore 行(t 在 30 天窗内)且同 id 有更早 op=forget 行;② decisions.jsonl 的 accept 执行记录(t 在 30 天窗内、rec["mutation"] 为 dict)中动作类 C/B 且执行时点前像在位(_protected_history 文件 + _protected_audit.jsonl 的 action=preimage 行)且现盘面与该前像逐字节相等(现盘面经 _disk_path_of 两段式定位:文件名优先、_index.json 兜底;索引仅在文件名未命中时加载);返回读数体 dict(ok=证据数 ≥ R3_MIN_ROLLBACKS;archived_shards 为未纳入的 _audit_archive 分片数,如实点名);
|
|
386
|
+
def r3_reversible(root, now=None) -> dict:
|
|
387
|
+
"""R3 破坏可逆且「回过」(30 天内 ≥1 次真实回滚;状态推断,见模块 docstring)。"""
|
|
388
|
+
now = _now(now)
|
|
389
|
+
lo = now - WINDOW_LONG_D * DAY
|
|
390
|
+
evidence = []
|
|
391
|
+
|
|
392
|
+
# ---- ① D 面:活动分片的 forget → restore 对 ----
|
|
393
|
+
forget_at = {}
|
|
394
|
+
audit_p = os.path.join(root, DEVICE_AUDIT)
|
|
395
|
+
if os.path.isfile(audit_p):
|
|
396
|
+
for rec in _stream_jsonl(audit_p):
|
|
397
|
+
op = rec.get("op")
|
|
398
|
+
nid = rec.get("id")
|
|
399
|
+
t = _ts(rec)
|
|
400
|
+
if not nid or t is None:
|
|
401
|
+
continue
|
|
402
|
+
if op == "forget":
|
|
403
|
+
if nid not in forget_at or t < forget_at[nid]:
|
|
404
|
+
forget_at[nid] = t
|
|
405
|
+
elif op == "restore":
|
|
406
|
+
ft = forget_at.get(nid)
|
|
407
|
+
if ft is not None and ft < t and lo <= t <= now:
|
|
408
|
+
evidence.append({"source": "audit_restore", "node_id": nid,
|
|
409
|
+
"t": t, "forget_t": ft,
|
|
410
|
+
"detail": "删(%.0f)→ 回(%.0f)" % (ft, t)})
|
|
411
|
+
|
|
412
|
+
# ---- ② C/B 面:accept 执行记录 + 前像在位 + 现盘面 == 前像 ----
|
|
413
|
+
cand = []
|
|
414
|
+
dec_p = os.path.join(root, DECISIONS_LOG)
|
|
415
|
+
if os.path.isfile(dec_p):
|
|
416
|
+
for rec in _stream_jsonl(dec_p):
|
|
417
|
+
if rec.get("decision") != "accept":
|
|
418
|
+
continue
|
|
419
|
+
mut = rec.get("mutation")
|
|
420
|
+
if not isinstance(mut, dict):
|
|
421
|
+
continue
|
|
422
|
+
t = _ts(rec)
|
|
423
|
+
if t is None or not (lo <= t <= now):
|
|
424
|
+
continue
|
|
425
|
+
act = str(mut.get("action") or "").strip().upper()
|
|
426
|
+
if act in ("B", "C") and mut.get("before") and mut.get("target"):
|
|
427
|
+
cand.append({"t": t, "action": act, "target": mut["target"],
|
|
428
|
+
"before": mut["before"], "pid": rec.get("pid")})
|
|
429
|
+
pre_audit = set()
|
|
430
|
+
prot_p = os.path.join(root, PROTECT_AUDIT)
|
|
431
|
+
if os.path.isfile(prot_p):
|
|
432
|
+
for rec in _stream_jsonl(prot_p):
|
|
433
|
+
if rec.get("action") == "preimage" and rec.get("snapshot"):
|
|
434
|
+
pre_audit.add(str(rec["snapshot"]).replace("\\", "/"))
|
|
435
|
+
for c in cand:
|
|
436
|
+
rel = str(c["before"]).replace("\\", "/")
|
|
437
|
+
pre_p = os.path.join(root, rel.replace("/", os.sep))
|
|
438
|
+
if not os.path.isfile(pre_p):
|
|
439
|
+
continue # 前像缺失——回滚句柄不存在,不计
|
|
440
|
+
if rel not in pre_audit:
|
|
441
|
+
continue # 审计链缺 preimage 行——不计(如实)
|
|
442
|
+
disk_p = _disk_path_of(root, c["target"])
|
|
443
|
+
if not disk_p:
|
|
444
|
+
continue
|
|
445
|
+
try:
|
|
446
|
+
with open(disk_p, "rb") as f:
|
|
447
|
+
a = f.read()
|
|
448
|
+
with open(pre_p, "rb") as f:
|
|
449
|
+
b = f.read()
|
|
450
|
+
except OSError:
|
|
451
|
+
continue
|
|
452
|
+
if a != b:
|
|
453
|
+
continue # 盘面尚未回到前像——破坏仍在外
|
|
454
|
+
evidence.append({"source": "preimage_pair", "node_id": c["target"],
|
|
455
|
+
"t": c["t"], "action": c["action"], "pid": c["pid"],
|
|
456
|
+
"preimage": rel,
|
|
457
|
+
"detail": "执行后盘面已回到执行时点前像(逐字节)"})
|
|
458
|
+
|
|
459
|
+
# 归档分片计数(不读内容,如实点名)
|
|
460
|
+
arc_p = os.path.join(root, AUDIT_ARCHIVE)
|
|
461
|
+
archived = 0
|
|
462
|
+
if os.path.isdir(arc_p):
|
|
463
|
+
try:
|
|
464
|
+
archived = len([n for n in os.listdir(arc_p)
|
|
465
|
+
if n.startswith("_audit.") and n.endswith(".jsonl")])
|
|
466
|
+
except OSError:
|
|
467
|
+
archived = 0
|
|
468
|
+
|
|
469
|
+
count = len(evidence)
|
|
470
|
+
ok = bool(count >= R3_MIN_ROLLBACKS)
|
|
471
|
+
d_restores = len([e for e in evidence if e["source"] == "audit_restore"])
|
|
472
|
+
cb_pairs = len([e for e in evidence if e["source"] == "preimage_pair"])
|
|
473
|
+
if ok:
|
|
474
|
+
detail = ("%d 天窗内 %d 次「回过」证据(D 面 %d + C/B 面 %d);"
|
|
475
|
+
"方法=状态推断(回滚无独立留痕,见模块 docstring)%s。"
|
|
476
|
+
% (WINDOW_LONG_D, count, d_restores, cb_pairs,
|
|
477
|
+
(";未纳入归档分片 %d 个" % archived) if archived else ""))
|
|
478
|
+
else:
|
|
479
|
+
detail = ("%d 天窗内 0 次「回过」证据(D 面 0 + C/B 面 0;"
|
|
480
|
+
"候选执行记录 %d 条)%s——破坏可逆性未实测。"
|
|
481
|
+
% (WINDOW_LONG_D, len(cand),
|
|
482
|
+
(";注意:未纳入归档分片 %d 个,落在归档里的 restore 会漏计"
|
|
483
|
+
% archived) if archived else ""))
|
|
484
|
+
return {"id": "R3", "ok": ok, "window_days": WINDOW_LONG_D,
|
|
485
|
+
"count": count, "min_count": R3_MIN_ROLLBACKS,
|
|
486
|
+
"d_restores": d_restores, "cb_pairs": cb_pairs,
|
|
487
|
+
"candidates": len(cand), "archived_shards": archived,
|
|
488
|
+
"evidence": evidence[:20],
|
|
489
|
+
"method": "state_inference(回滚无独立留痕:步骤③报告 §6.1 第 3 条)",
|
|
490
|
+
"thresholds": {"min_count": R3_MIN_ROLLBACKS},
|
|
491
|
+
"detail": detail}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# 生效条件:node_id 非空时按「① 层目录文件名 {id}.md(跳过 _ 前缀与 _SKIP_DIRS;不解析内容,纯文件名匹配)→ ② _index.json 的 nodes[id].path(兜底,库未 compact 时可能缺)」两段式定位盘面文件,返回绝对路径;两段都未命中或文件不存在时返回 None(不猜——调用方按「无法比对」计);
|
|
495
|
+
def _disk_path_of(root, node_id):
|
|
496
|
+
"""盘面定位(只读;R3 的 C/B 面用)。
|
|
497
|
+
|
|
498
|
+
为什么文件名优先:`_index.json` 只在 compact/close 时落盘(探针实测:
|
|
499
|
+
add+flush 后仅 `_index_log/` 分片有记录),常驻库的索引快照可能滞后;
|
|
500
|
+
文件名 `{id}.md` 是写侧惯例(`census.load` 同源:`nid = fm.get("id")
|
|
501
|
+
or fn[:-3]`),walk 文件名不解析内容、成本 ≪ 加载 21MB 索引。
|
|
502
|
+
"""
|
|
503
|
+
if not node_id:
|
|
504
|
+
return None
|
|
505
|
+
fn = node_id + ".md"
|
|
506
|
+
for dirpath, dirs, files in os.walk(root):
|
|
507
|
+
dirs[:] = [d for d in dirs
|
|
508
|
+
if not d.startswith("_") and d not in _SKIP_DIRS]
|
|
509
|
+
if fn in files:
|
|
510
|
+
p = os.path.join(dirpath, fn)
|
|
511
|
+
if os.path.isfile(p):
|
|
512
|
+
return p
|
|
513
|
+
idx = _load_index_nodes(root)
|
|
514
|
+
e = idx.get(node_id) or {}
|
|
515
|
+
if e.get("path"):
|
|
516
|
+
p = os.path.join(root, str(e["path"]).replace("/", os.sep))
|
|
517
|
+
if os.path.isfile(p):
|
|
518
|
+
return p
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# 生效条件:root 下 _index.json 可读且含 dict 型 nodes 时返回该 nodes;不可读/解析失败/nodes 非 dict 时返回 {}(不抛——R3 的索引面是兜底增强,缺失按「无法比对」计);
|
|
523
|
+
def _load_index_nodes(root) -> dict:
|
|
524
|
+
"""读 `_index.json` 的 nodes(仅 R3 有候选执行记录时调用——成本门槛)。"""
|
|
525
|
+
try:
|
|
526
|
+
with open(os.path.join(root, "_index.json"), "r",
|
|
527
|
+
encoding="utf-8", errors="replace") as f:
|
|
528
|
+
idx = json.load(f)
|
|
529
|
+
except (OSError, ValueError):
|
|
530
|
+
return {}
|
|
531
|
+
nodes = idx.get("nodes") if isinstance(idx, dict) else None
|
|
532
|
+
return nodes if isinstance(nodes, dict) else {}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------- R4
|
|
536
|
+
|
|
537
|
+
# 生效条件:root 下 hippocampus/decisions.jsonl 可读时单遍流式按 t 归入 3 个 10 天子窗(W0 最近 … W2 最远),返回读数体 dict——三个子窗均有裁定样本时 ok=(按时间升序严格递减 且 最近子窗驳回率 < R4_MAX_RATE);任一子窗样本为 0 时 ok=None(不可判,如实);驳回率=(reject+edit)/该窗全部裁定;
|
|
538
|
+
def r4_converging(root, now=None) -> dict:
|
|
539
|
+
"""R4 判断收敛(30 天内 3 个 10 天子窗,逐窗严格下降且最近 < 20%)。"""
|
|
540
|
+
now = _now(now)
|
|
541
|
+
windows = [{"lo": now - (k + 1) * R4_SUBWINDOW_D * DAY,
|
|
542
|
+
"hi": now - k * R4_SUBWINDOW_D * DAY,
|
|
543
|
+
"total": 0, "reject": 0, "edit": 0, "by_decision": {}}
|
|
544
|
+
for k in range(R4_SUBWINDOWS)]
|
|
545
|
+
dec_p = os.path.join(root, DECISIONS_LOG)
|
|
546
|
+
if os.path.isfile(dec_p):
|
|
547
|
+
for rec in _stream_jsonl(dec_p):
|
|
548
|
+
t = _ts(rec)
|
|
549
|
+
if t is None:
|
|
550
|
+
continue
|
|
551
|
+
for w in windows:
|
|
552
|
+
if w["lo"] < t <= w["hi"]:
|
|
553
|
+
d = str(rec.get("decision") or "unknown")
|
|
554
|
+
w["total"] += 1
|
|
555
|
+
w["by_decision"][d] = w["by_decision"].get(d, 0) + 1
|
|
556
|
+
if d == "reject":
|
|
557
|
+
w["reject"] += 1
|
|
558
|
+
elif d == "edit":
|
|
559
|
+
w["edit"] += 1
|
|
560
|
+
break
|
|
561
|
+
rates_desc = [] # 最近 → 最远(便于报文案)
|
|
562
|
+
for w in windows:
|
|
563
|
+
w["rate"] = ((w["reject"] + w["edit"]) / w["total"]) if w["total"] else None
|
|
564
|
+
rates_desc.append(w["rate"])
|
|
565
|
+
rates_asc = list(reversed(rates_desc)) # 时间升序(最远 → 最近)
|
|
566
|
+
empty = [i for i, r in enumerate(rates_asc) if r is None]
|
|
567
|
+
if empty:
|
|
568
|
+
ok = None
|
|
569
|
+
detail = ("30 天内子窗样本不足(%s 无裁定)——窗口不足 3 个子窗,"
|
|
570
|
+
"不可判(如实;不假装收敛也不假装不收敛)。"
|
|
571
|
+
% "、".join("W%d" % (R4_SUBWINDOWS - i) for i in empty))
|
|
572
|
+
else:
|
|
573
|
+
strictly_down = rates_asc[0] > rates_asc[1] > rates_asc[2]
|
|
574
|
+
last_below = rates_asc[2] < R4_MAX_RATE
|
|
575
|
+
ok = bool(strictly_down and last_below)
|
|
576
|
+
detail = ("子窗驳回率(远→近)%s;逐窗严格下降=%s;最近子窗 %.1f%% "
|
|
577
|
+
"%s %.0f%%——%s。"
|
|
578
|
+
% (" / ".join("%.1f%%" % (r * 100) for r in rates_asc),
|
|
579
|
+
strictly_down, rates_asc[2] * 100,
|
|
580
|
+
"<" if last_below else "≥", R4_MAX_RATE * 100,
|
|
581
|
+
"判断收敛" if ok else "未收敛或不满足门槛"))
|
|
582
|
+
return {"id": "R4", "ok": ok, "window_days": WINDOW_LONG_D,
|
|
583
|
+
"subwindow_days": R4_SUBWINDOW_D, "subwindows": R4_SUBWINDOWS,
|
|
584
|
+
"windows": [{"lo": w["lo"], "hi": w["hi"], "total": w["total"],
|
|
585
|
+
"reject": w["reject"], "edit": w["edit"],
|
|
586
|
+
"rate": w["rate"], "by_decision": w["by_decision"]}
|
|
587
|
+
for w in windows],
|
|
588
|
+
"rates_asc": rates_asc,
|
|
589
|
+
"thresholds": {"max_rate": R4_MAX_RATE,
|
|
590
|
+
"strictly_decreasing": True,
|
|
591
|
+
"subwindows": R4_SUBWINDOWS,
|
|
592
|
+
"subwindow_days": R4_SUBWINDOW_D},
|
|
593
|
+
"detail": detail}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# ---------------------------------------------------------------- 汇总与缓存
|
|
597
|
+
|
|
598
|
+
READING_IDS = ("R1", "R2", "R3", "R4")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# 生效条件:root 为目录时依次跑 R1–R4(各自独立读数体),返回 {"root","t","ok","missing","indeterminate","readings","verdict"}——ok 为四条全 True;missing 列出一切非 True(含不可判,fail-closed);本函数零写入;
|
|
602
|
+
def check(root, now=None) -> dict:
|
|
603
|
+
"""跑四条读数,返回汇总报告(只读:不改任何数据、不落任何文件)。"""
|
|
604
|
+
t0 = _now(now)
|
|
605
|
+
readings = [r1_non_degenerate(root, now=t0),
|
|
606
|
+
r2_says_no(root, now=t0),
|
|
607
|
+
r3_reversible(root, now=t0),
|
|
608
|
+
r4_converging(root, now=t0)]
|
|
609
|
+
missing = [r["id"] for r in readings if r["ok"] is not True]
|
|
610
|
+
indeterminate = [r["id"] for r in readings if r["ok"] is None]
|
|
611
|
+
ok = not missing
|
|
612
|
+
return {"root": os.path.abspath(root), "t": t0, "ok": ok,
|
|
613
|
+
"readings": readings, "missing": missing,
|
|
614
|
+
"indeterminate": indeterminate,
|
|
615
|
+
"verdict": "pass" if ok else "fail"}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
#: 进程级读数缓存:{root_abspath: {"t": float, "res": dict}}——gate() 专用。
|
|
619
|
+
_CACHE = {}
|
|
620
|
+
#: 探针计数(守卫断言缓存生效:checks=实算次数、hits=缓存命中次数)。
|
|
621
|
+
_STATS = {"checks": 0, "hits": 0}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# 生效条件:root 为目录、ttl ≥ 0 时——命中缓存(同 root、未 force、距 t 不超过 ttl 秒)则原样返回上次读数体副本(hits+1,零重扫);否则实算 check()(checks+1)并写缓存后返回;返回值 = check 报告 + {"cached": bool, "age_s": float};
|
|
625
|
+
def gate(root, now=None, ttl=None, force=False) -> dict:
|
|
626
|
+
"""读数唯一入口(TTL 缓存)——热路径不经过本函数(只读结算态)。"""
|
|
627
|
+
t = _now(now)
|
|
628
|
+
ttl = TTL_DEFAULT if ttl is None else float(ttl)
|
|
629
|
+
key = os.path.abspath(root)
|
|
630
|
+
ent = _CACHE.get(key)
|
|
631
|
+
if ent is not None and not force and (t - ent["t"]) <= ttl:
|
|
632
|
+
_STATS["hits"] += 1
|
|
633
|
+
out = dict(ent["res"])
|
|
634
|
+
out["cached"] = True
|
|
635
|
+
out["age_s"] = round(t - ent["t"], 3)
|
|
636
|
+
return out
|
|
637
|
+
res = check(root, now=t)
|
|
638
|
+
_STATS["checks"] += 1
|
|
639
|
+
_CACHE[key] = {"t": t, "res": res}
|
|
640
|
+
out = dict(res)
|
|
641
|
+
out["cached"] = False
|
|
642
|
+
out["age_s"] = 0.0
|
|
643
|
+
return out
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# 生效条件:无入参;返回探针计数副本 {"checks","hits","cached_roots"};
|
|
647
|
+
def cache_stats() -> dict:
|
|
648
|
+
"""缓存探针(守卫用来机械证明「不每次全扫」)。"""
|
|
649
|
+
return {"checks": _STATS["checks"], "hits": _STATS["hits"],
|
|
650
|
+
"cached_roots": len(_CACHE)}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# 生效条件:无入参;清空读数缓存与计数(测试/宿主重结算用),无返回值;
|
|
654
|
+
def reset_cache() -> None:
|
|
655
|
+
_CACHE.clear()
|
|
656
|
+
_STATS["checks"] = 0
|
|
657
|
+
_STATS["hits"] = 0
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# 生效条件:无入参,datapath.mdcg_root() 返回真值时返回该值,导入或调用抛异常、或返回假值(如空串)时返回仓库内 data/mdcg;与 conformance._default_root 同源同形;
|
|
661
|
+
def default_root() -> str:
|
|
662
|
+
"""root 解析沿用本仓约定:env `MDCG_ROOT` > `paths.json` > 用户级状态根。"""
|
|
663
|
+
try:
|
|
664
|
+
from .datapath import mdcg_root
|
|
665
|
+
got = mdcg_root()
|
|
666
|
+
if got:
|
|
667
|
+
return got
|
|
668
|
+
except Exception: # noqa: BLE001
|
|
669
|
+
pass
|
|
670
|
+
return os.path.join(os.path.dirname(os.path.dirname(
|
|
671
|
+
os.path.abspath(__file__))), "data", "mdcg")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------- 报告/CLI
|
|
675
|
+
|
|
676
|
+
# 生效条件:rep 为 check()/gate() 的返回体(含 readings 列表)时拼接为多行文本,返回该文本;
|
|
677
|
+
def render(rep: dict) -> str:
|
|
678
|
+
L = []
|
|
679
|
+
a = L.append
|
|
680
|
+
a("== 三档自治准入读数(R1–R4)· %s" % rep.get("root"))
|
|
681
|
+
a(" 结论:%s 缺:%s" % (
|
|
682
|
+
"够格(R1–R4 全满足)" if rep.get("ok") else "不够格",
|
|
683
|
+
"、".join(rep.get("missing") or []) or "无"))
|
|
684
|
+
for r in rep.get("readings") or []:
|
|
685
|
+
mark = {True: "ok ", False: "NO ", None: "~? "}[r.get("ok")]
|
|
686
|
+
a(" [%s] %s: %s" % (mark, r["id"], r["detail"]))
|
|
687
|
+
return "\n".join(L)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# 生效条件:argv 为 None 时 argparse 从 sys.argv 取值;--root 缺失/假值时回落 default_root();root 非目录时打印并返回 2;否则 force 实算 gate() 打印 render()(--json 时另写 JSON 到该路径),按 ok 返回 0/1;
|
|
691
|
+
def main(argv=None) -> int:
|
|
692
|
+
p = argparse.ArgumentParser(
|
|
693
|
+
prog="python -m md_cg.admission",
|
|
694
|
+
description="三档自治准入读数 R1–R4(只读,零写入)")
|
|
695
|
+
p.add_argument("--root", default=None,
|
|
696
|
+
help="认知图根(默认 MDCG_ROOT > paths.json > 用户级状态根)")
|
|
697
|
+
p.add_argument("--json", dest="json_out", default=None,
|
|
698
|
+
help="把完整读数体写成 JSON 到该路径")
|
|
699
|
+
p.add_argument("--ttl", type=float, default=None, help="读数缓存 TTL 秒")
|
|
700
|
+
try:
|
|
701
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
702
|
+
except AttributeError:
|
|
703
|
+
pass
|
|
704
|
+
a = p.parse_args(argv)
|
|
705
|
+
root = a.root or default_root()
|
|
706
|
+
if not os.path.isdir(root):
|
|
707
|
+
print("root 不存在或不是目录:%s(fail-closed,无法判读)" % root)
|
|
708
|
+
return 2
|
|
709
|
+
rep = gate(root, ttl=a.ttl, force=True)
|
|
710
|
+
print(render(rep))
|
|
711
|
+
if a.json_out:
|
|
712
|
+
with open(a.json_out, "w", encoding="utf-8") as f:
|
|
713
|
+
json.dump(rep, f, ensure_ascii=False, indent=1, default=str)
|
|
714
|
+
return 0 if rep["ok"] else 1
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
if __name__ == "__main__":
|
|
718
|
+
sys.exit(main())
|