dsh-plugin-xujie-memory 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -1
- package/LICENSE +21 -21
- package/README.md +33 -8
- package/README_EN.md +27 -4
- package/cordis.patch.yml +49 -49
- package/lib/index.js +121 -0
- package/package.json +1 -1
- package/python/specs/characters/aloof.json +14 -14
- package/python/specs/characters/gentle.json +14 -14
- package/python/specs/characters/tsundere.json +14 -14
- package/python/specs/debt.v1.json +31 -31
- package/python/specs/nickname.v1.json +16 -16
- package/python/specs/preference.v1.json +16 -16
- package/python/xujie_memory/admission.py +211 -211
- package/python/xujie_memory/audit.py +377 -377
- package/python/xujie_memory/benchmark.py +281 -281
- package/python/xujie_memory/character.py +199 -199
- package/python/xujie_memory/console.py +41 -41
- package/python/xujie_memory/consolidate.py +356 -356
- package/python/xujie_memory/due.py +121 -121
- package/python/xujie_memory/emotion.py +573 -573
- package/python/xujie_memory/errors.py +98 -98
- package/python/xujie_memory/importance.py +104 -104
- package/python/xujie_memory/judge.py +350 -350
- package/python/xujie_memory/memory.py +55 -0
- package/python/xujie_memory/orchestrator.py +690 -666
- package/python/xujie_memory/phrasing.py +292 -292
- package/python/xujie_memory/server.py +24 -0
- package/python/xujie_memory/specs.py +170 -170
- package/python/xujie_memory/summary.py +238 -238
|
@@ -1,377 +1,377 @@
|
|
|
1
|
-
"""不变量验收:把方案第七节跑成代码。
|
|
2
|
-
|
|
3
|
-
方案的原文:
|
|
4
|
-
|
|
5
|
-
> 每条不变量都必须能被独立测出,否则它只是口号。
|
|
6
|
-
|
|
7
|
-
| 不变量 | 方案给的验法 | 这里的实现 |
|
|
8
|
-
|---|---|---|
|
|
9
|
-
| I1 来源性 | 随机抽 100 条输出,每条定位到 trace_id | 全量遍历 state(比抽样严) |
|
|
10
|
-
| I2 幂等性 | 同一输入跑两遍,逐字段比对 | 投影 == fold(事件流),逐字段 |
|
|
11
|
-
| I3 有界性 | 构造「什么都能匹配」的查询,看返回量 | 空过滤查询 + 大预算,看是否截断并标注 |
|
|
12
|
-
| I4 可逆性 | 抽 20 条已归档痕迹,尝试恢复 | 抽样恢复 + 恢复后归档回去 |
|
|
13
|
-
| I5 显式性 | 故障与空结果,检查返回类型 | 同一个 query 打在坏连接上 vs 空账上 |
|
|
14
|
-
|
|
15
|
-
## 为什么 I2 用「投影 == fold」而不是「跑两遍」
|
|
16
|
-
|
|
17
|
-
"跑两遍"要重放整条输入序列,那测的是**编码器稳定不稳定**(模型的事)。
|
|
18
|
-
而 I2 说的是"同一输入序列,收敛后的状态必须唯一"——它的内核含义是:
|
|
19
|
-
**状态是事件的纯函数**。
|
|
20
|
-
|
|
21
|
-
那就直接测这个函数关系:把事件流重新折一遍,和库里存的投影逐字段比。
|
|
22
|
-
不一致,说明投影带了事件流之外的信息(时间、随机数、上一次运行的残留),
|
|
23
|
-
那才是真的违反 I2。
|
|
24
|
-
|
|
25
|
-
## 为什么这些检查要独立成模块,不写成 pytest
|
|
26
|
-
|
|
27
|
-
因为它们是**验收**,不是**单元测试**:
|
|
28
|
-
|
|
29
|
-
- 单元测试断言"我知道的这条路径是对的"
|
|
30
|
-
- 验收断言"整个库现在没有违反不变量的地方"
|
|
31
|
-
|
|
32
|
-
前者跟着代码走,后者跟着数据走。一个真实部署在跑了一个月之后,
|
|
33
|
-
最该问的就是后者——而那个库不在测试目录里。
|
|
34
|
-
|
|
35
|
-
`AuditReport.verdict` 只有三种:pass / fail / partial。
|
|
36
|
-
**partial 必须带原因**,否则它就是个更委婉的 pass。
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
from __future__ import annotations
|
|
40
|
-
|
|
41
|
-
import json
|
|
42
|
-
import sqlite3
|
|
43
|
-
from dataclasses import dataclass, field
|
|
44
|
-
from typing import Any
|
|
45
|
-
|
|
46
|
-
from .recall import NOT_FOUND, UNAVAILABLE, Budget, Recall, RecallQuery
|
|
47
|
-
|
|
48
|
-
# 抽样上限。方案写的是"抽 100 条""抽 20 条",这里作为默认值。
|
|
49
|
-
DEFAULT_STATE_SAMPLE = 100
|
|
50
|
-
DEFAULT_ARCHIVE_SAMPLE = 20
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@dataclass
|
|
54
|
-
class Finding:
|
|
55
|
-
"""一条检查结果。
|
|
56
|
-
|
|
57
|
-
`ok=False` 是**违规**,不是"没查"——没查的写在 `report.skipped` 里。
|
|
58
|
-
两者混在一起,报告就没法当门禁用。
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
invariant: str
|
|
62
|
-
ok: bool
|
|
63
|
-
subject: str
|
|
64
|
-
detail: str = ""
|
|
65
|
-
|
|
66
|
-
def as_line(self) -> str:
|
|
67
|
-
mark = "✓" if self.ok else "✗"
|
|
68
|
-
body = f"{mark} {self.invariant} {self.subject}"
|
|
69
|
-
return f"{body} {self.detail}" if self.detail else body
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@dataclass
|
|
73
|
-
class AuditReport:
|
|
74
|
-
scope_id: str
|
|
75
|
-
started_at: str
|
|
76
|
-
finished_at: str = ""
|
|
77
|
-
findings: list[Finding] = field(default_factory=list)
|
|
78
|
-
checked: dict[str, int] = field(default_factory=dict)
|
|
79
|
-
skipped: list[str] = field(default_factory=list)
|
|
80
|
-
|
|
81
|
-
def add(self, f: Finding) -> None:
|
|
82
|
-
self.findings.append(f)
|
|
83
|
-
self.checked[f.invariant] = self.checked.get(f.invariant, 0) + 1
|
|
84
|
-
|
|
85
|
-
@property
|
|
86
|
-
def violations(self) -> list[Finding]:
|
|
87
|
-
return [f for f in self.findings if not f.ok]
|
|
88
|
-
|
|
89
|
-
@property
|
|
90
|
-
def verdict(self) -> str:
|
|
91
|
-
"""pass / fail / partial。
|
|
92
|
-
|
|
93
|
-
partial 只在"有检查没跑成"时出现,而且必须看 `skipped`——
|
|
94
|
-
一个永远返回 partial 的报告,和没有报告是一样的。
|
|
95
|
-
"""
|
|
96
|
-
if self.violations:
|
|
97
|
-
return "fail"
|
|
98
|
-
if self.skipped:
|
|
99
|
-
return "partial"
|
|
100
|
-
return "pass"
|
|
101
|
-
|
|
102
|
-
def summary(self) -> dict[str, Any]:
|
|
103
|
-
return {
|
|
104
|
-
"scope_id": self.scope_id,
|
|
105
|
-
"verdict": self.verdict,
|
|
106
|
-
"checked": dict(self.checked),
|
|
107
|
-
"violations": len(self.violations),
|
|
108
|
-
"skipped": list(self.skipped),
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# ============================================================ I1 来源性
|
|
113
|
-
def check_source_closure(
|
|
114
|
-
engine, scope_id: str, sample: int = DEFAULT_STATE_SAMPLE,
|
|
115
|
-
) -> list[Finding]:
|
|
116
|
-
"""每条有值的事实,都必须能沿证据链走到可访问的痕迹。
|
|
117
|
-
|
|
118
|
-
方案要求"随机抽 100 条"。这里**全量查**——抽样只能证伪,不能证明;
|
|
119
|
-
而这个检查的成本是每条一次索引查询,全量也就几十毫秒。
|
|
120
|
-
真要抽样时把 `sample` 调小即可。
|
|
121
|
-
|
|
122
|
-
只查**有值**的状态(known / disputed)。unknown 本来就没有事实要支撑。
|
|
123
|
-
"""
|
|
124
|
-
rows = engine._conn().execute(
|
|
125
|
-
"SELECT state_key FROM states WHERE scope_id=?"
|
|
126
|
-
" AND status IN ('known','disputed') ORDER BY state_key LIMIT ?",
|
|
127
|
-
(scope_id, sample),
|
|
128
|
-
).fetchall()
|
|
129
|
-
|
|
130
|
-
out: list[Finding] = []
|
|
131
|
-
for row in rows:
|
|
132
|
-
key = row["state_key"]
|
|
133
|
-
try:
|
|
134
|
-
problems = engine.check_source_closure(key)
|
|
135
|
-
except Exception as exc: # noqa: BLE001
|
|
136
|
-
out.append(Finding("I1", False, key, f"检查本身失败:{exc}"))
|
|
137
|
-
continue
|
|
138
|
-
out.append(Finding("I1", not problems, key, "; ".join(problems)))
|
|
139
|
-
return out
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
# ============================================================ I2 幂等性
|
|
143
|
-
def _projection_row(engine, state_key: str) -> dict[str, Any] | None:
|
|
144
|
-
row = engine._conn().execute(
|
|
145
|
-
"SELECT value_json, status, version FROM states WHERE state_key=?",
|
|
146
|
-
(state_key,),
|
|
147
|
-
).fetchone()
|
|
148
|
-
return dict(row) if row else None
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def check_projection_is_a_function_of_events(
|
|
152
|
-
engine, scope_id: str, sample: int = DEFAULT_STATE_SAMPLE,
|
|
153
|
-
) -> list[Finding]:
|
|
154
|
-
"""投影必须等于「把事件流重新折一遍」的结果。
|
|
155
|
-
|
|
156
|
-
这是 I2 最内核的一条:**状态是事件的纯函数**。
|
|
157
|
-
只要这条成立,同一输入序列跑两遍必然得到同一个状态——
|
|
158
|
-
因为两遍产生的事件流相同,而同一个事件流只有一个折法。
|
|
159
|
-
|
|
160
|
-
不一致的常见原因(都是真的坑):
|
|
161
|
-
投影里混进了「当前时间」(过期判断)
|
|
162
|
-
某条写路径自己改了 states,没走 _recompute
|
|
163
|
-
快照没被正确作废
|
|
164
|
-
"""
|
|
165
|
-
rows = engine._conn().execute(
|
|
166
|
-
"SELECT state_key FROM states WHERE scope_id=? ORDER BY state_key LIMIT ?",
|
|
167
|
-
(scope_id, sample),
|
|
168
|
-
).fetchall()
|
|
169
|
-
|
|
170
|
-
out: list[Finding] = []
|
|
171
|
-
# 只读:直接用现有连接折一遍。**不能**调 `_recompute`——它会写库,
|
|
172
|
-
# 验收工具一旦改数据,下一次验收看到的东西已经不一样了。
|
|
173
|
-
conn = engine._conn()
|
|
174
|
-
for row in rows:
|
|
175
|
-
key = row["state_key"]
|
|
176
|
-
spec = engine.registry.get(_relation_of(key))
|
|
177
|
-
if spec is None:
|
|
178
|
-
out.append(Finding("I2", False, key, "状态键指向未知关系"))
|
|
179
|
-
continue
|
|
180
|
-
try:
|
|
181
|
-
folded = engine._fold(conn, key, spec)
|
|
182
|
-
except Exception as exc: # noqa: BLE001
|
|
183
|
-
out.append(Finding("I2", False, key, f"重算失败:{exc}"))
|
|
184
|
-
continue
|
|
185
|
-
stored = _projection_row(engine, key)
|
|
186
|
-
if stored is None:
|
|
187
|
-
out.append(Finding("I2", False, key, "投影行消失"))
|
|
188
|
-
continue
|
|
189
|
-
|
|
190
|
-
stored_value = json.loads(stored["value_json"]) if stored["value_json"] else None
|
|
191
|
-
same = (
|
|
192
|
-
_same_number(stored_value, folded.value)
|
|
193
|
-
and stored["status"] == folded.status
|
|
194
|
-
)
|
|
195
|
-
out.append(Finding(
|
|
196
|
-
"I2", same, key,
|
|
197
|
-
"" if same else f"投影 {stored_value}/{stored['status']}"
|
|
198
|
-
f" ≠ 重算 {folded.value}/{folded.status}",
|
|
199
|
-
))
|
|
200
|
-
return out
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def _same_number(a: Any, b: Any) -> bool:
|
|
204
|
-
"""数值比较:None 只等于 None,其余按浮点容差。
|
|
205
|
-
|
|
206
|
-
不能用 `==` 直接比:JSON 读回来的 3 和 float 3.0 在语义上是一个数,
|
|
207
|
-
而它们的 `==` 也可能是 True——但 `3` 和 `3.000000001` 也是 True
|
|
208
|
-
在以 1e-9 为容差时就太松了。用相对误差更贴近"这是一笔钱"的直觉。
|
|
209
|
-
"""
|
|
210
|
-
if a is None or b is None:
|
|
211
|
-
return a is None and b is None
|
|
212
|
-
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
213
|
-
if a == b:
|
|
214
|
-
return True
|
|
215
|
-
return abs(a - b) <= max(1e-9, abs(a) * 1e-9)
|
|
216
|
-
return a == b
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _relation_of(state_key: str) -> str:
|
|
220
|
-
parts = state_key.split("|")
|
|
221
|
-
return parts[2] if len(parts) >= 3 else ""
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
# ============================================================ I3 有界性
|
|
225
|
-
def check_recall_is_bounded(engine, scope_id: str) -> list[Finding]:
|
|
226
|
-
"""「什么都能匹配」的查询也不许把库倒出来。
|
|
227
|
-
|
|
228
|
-
构造:不给定任何过滤条件 + 一个很大的预算。
|
|
229
|
-
通过条件有两条,缺一不可:
|
|
230
|
-
返回条数 ≤ max_items
|
|
231
|
-
超了就**明确标注** truncated + 原因
|
|
232
|
-
"""
|
|
233
|
-
total = engine._conn().execute(
|
|
234
|
-
"SELECT COUNT(*) FROM states WHERE scope_id=?", (scope_id,),
|
|
235
|
-
).fetchone()[0]
|
|
236
|
-
# 上限压到 1,保证哪怕库里只有两条也会真的触发截断——
|
|
237
|
-
# 否则"检查截断"这一步在大库上跑得到、在小库上跑不到,
|
|
238
|
-
# 而小库恰恰是刚上线的样子。
|
|
239
|
-
budget = Budget(max_items=1, max_candidates=200, max_rounds=2,
|
|
240
|
-
max_chars=2000, timeout_ms=2000)
|
|
241
|
-
result = Recall(engine).query(
|
|
242
|
-
scope_id, RecallQuery(kind="state", budget=budget),
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
out: list[Finding] = []
|
|
246
|
-
n = len(result.states)
|
|
247
|
-
out.append(Finding("I3", n <= budget.max_items, "returned_items",
|
|
248
|
-
f"{n} 条(上限 {budget.max_items},库里共 {total})"))
|
|
249
|
-
|
|
250
|
-
# 只有"库里确实还有更多"时,才要求它标注截断。
|
|
251
|
-
# 「返回 1 条、库里也正好 1 条」没被截断是正常的,
|
|
252
|
-
# 在那里要求 truncated=True 是把自己测出一个假故障。
|
|
253
|
-
if total > budget.max_items:
|
|
254
|
-
out.append(Finding(
|
|
255
|
-
"I3", result.truncated, "truncation_flag",
|
|
256
|
-
"" if result.truncated else "被截断却没标注,用户会以为这就是全部",
|
|
257
|
-
))
|
|
258
|
-
return out
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
# ============================================================ I4 可逆性
|
|
262
|
-
def check_archive_is_reversible(
|
|
263
|
-
engine, scope_id: str, sample: int = DEFAULT_ARCHIVE_SAMPLE,
|
|
264
|
-
) -> list[Finding]:
|
|
265
|
-
"""归档不是删除。抽若干条已归档痕迹,恢复 → 读原文 → 再归档回去。
|
|
266
|
-
|
|
267
|
-
最后那步"归档回去"很重要:验收不该改变被测系统的状态。
|
|
268
|
-
否则跑一次验收,库就变了一次样——那下次验收看到的东西已经不一样了。
|
|
269
|
-
"""
|
|
270
|
-
from .decay import Lifecycle
|
|
271
|
-
|
|
272
|
-
rows = engine._conn().execute(
|
|
273
|
-
"SELECT ref_id FROM lifecycle WHERE scope_id=? AND level='archived'"
|
|
274
|
-
" ORDER BY ref_id LIMIT ?",
|
|
275
|
-
(scope_id, sample),
|
|
276
|
-
).fetchall()
|
|
277
|
-
if not rows:
|
|
278
|
-
return []
|
|
279
|
-
|
|
280
|
-
lc = Lifecycle(engine)
|
|
281
|
-
out: list[Finding] = []
|
|
282
|
-
for row in rows:
|
|
283
|
-
tid = row["ref_id"]
|
|
284
|
-
before = engine.get_trace(tid)
|
|
285
|
-
if before is None or before["erased_at"]:
|
|
286
|
-
# 用户行使过删除权。这不是"归档不可逆",是"这条本来就不该在库里"。
|
|
287
|
-
out.append(Finding("I4", True, tid, "已擦除,跳过"))
|
|
288
|
-
continue
|
|
289
|
-
if not (before["content"] or "").strip():
|
|
290
|
-
# 原文是空的,就谈不上"恢复回来"。
|
|
291
|
-
# 这条如果算通过,I4 会被一种最糟的情况蒙过去:
|
|
292
|
-
# 内容早就没了,而检查还在说"恢复成功"——恢复了一个空字符串。
|
|
293
|
-
out.append(Finding("I4", False, tid, "原文为空,归档后无从恢复"))
|
|
294
|
-
continue
|
|
295
|
-
ok = lc.restore(tid)
|
|
296
|
-
text = engine.get_trace(tid)
|
|
297
|
-
readable = text is not None and text["content"] == before["content"]
|
|
298
|
-
out.append(Finding("I4", ok and readable, tid,
|
|
299
|
-
"" if ok and readable else "恢复后原文不可读"))
|
|
300
|
-
lc.archive(tid) # 复原成被检查前的样子
|
|
301
|
-
return out
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
# ============================================================ I5 显式性
|
|
305
|
-
class _FailingEngine:
|
|
306
|
-
"""只用来造"查询故障"。
|
|
307
|
-
|
|
308
|
-
方案 I5 的验法是:故障与空结果两种场景,返回类型必须不同。
|
|
309
|
-
要造故障,就得让底层真的抛错——所以给一个只会抛错的 connection。
|
|
310
|
-
"""
|
|
311
|
-
|
|
312
|
-
def __init__(self, now: str):
|
|
313
|
-
self._now = now
|
|
314
|
-
|
|
315
|
-
def _conn(self):
|
|
316
|
-
raise sqlite3.Error("simulated storage failure")
|
|
317
|
-
|
|
318
|
-
def now(self) -> str:
|
|
319
|
-
return self._now
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
def check_status_is_explicit(engine, scope_id: str) -> list[Finding]:
|
|
323
|
-
"""「故障」和「没有」不能返回同一个东西。
|
|
324
|
-
|
|
325
|
-
把"系统坏了"说成"没有这条记忆",是把系统问题伪装成事实问题(I5)。
|
|
326
|
-
"""
|
|
327
|
-
out: list[Finding] = []
|
|
328
|
-
|
|
329
|
-
# 空结果必须是真的空:查一个不存在的 subject。
|
|
330
|
-
# 直接查 `kind="state"` 会连整个 scope 的状态都捞出来——那不是空查询,
|
|
331
|
-
# 是"不设过滤",测出来永远是 found。
|
|
332
|
-
empty = Recall(engine).query(
|
|
333
|
-
scope_id,
|
|
334
|
-
RecallQuery(kind="state", subject="__no_such_subject__", relation="debt"),
|
|
335
|
-
)
|
|
336
|
-
out.append(Finding("I5", empty.status == NOT_FOUND, "empty_result", empty.status))
|
|
337
|
-
|
|
338
|
-
broken = Recall(_FailingEngine(engine.now())).query(
|
|
339
|
-
scope_id, RecallQuery(kind="state"),
|
|
340
|
-
)
|
|
341
|
-
out.append(Finding("I5", broken.status == UNAVAILABLE, "storage_failure",
|
|
342
|
-
broken.status))
|
|
343
|
-
|
|
344
|
-
out.append(Finding(
|
|
345
|
-
"I5", empty.status != broken.status, "distinguishable",
|
|
346
|
-
"故障与空结果返回了同一个状态" if empty.status == broken.status else "",
|
|
347
|
-
))
|
|
348
|
-
return out
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
# ============================================================ 一起跑
|
|
352
|
-
def audit(
|
|
353
|
-
engine, scope_id: str,
|
|
354
|
-
state_sample: int = DEFAULT_STATE_SAMPLE,
|
|
355
|
-
archive_sample: int = DEFAULT_ARCHIVE_SAMPLE,
|
|
356
|
-
) -> AuditReport:
|
|
357
|
-
"""跑全套五条不变量。"""
|
|
358
|
-
report = AuditReport(scope_id=scope_id, started_at=engine.now())
|
|
359
|
-
|
|
360
|
-
checks = [
|
|
361
|
-
("I1", lambda: check_source_closure(engine, scope_id, state_sample)),
|
|
362
|
-
("I2", lambda: check_projection_is_a_function_of_events(engine, scope_id, state_sample)),
|
|
363
|
-
("I3", lambda: check_recall_is_bounded(engine, scope_id)),
|
|
364
|
-
("I4", lambda: check_archive_is_reversible(engine, scope_id, archive_sample)),
|
|
365
|
-
("I5", lambda: check_status_is_explicit(engine, scope_id)),
|
|
366
|
-
]
|
|
367
|
-
for name, fn in checks:
|
|
368
|
-
try:
|
|
369
|
-
for finding in fn():
|
|
370
|
-
report.add(finding)
|
|
371
|
-
except Exception as exc: # noqa: BLE001
|
|
372
|
-
# 检查本身失败不能算"通过",也不能算"违规"——单列出来。
|
|
373
|
-
# 混进 verdict=fail 会掩盖真正的不变量违规。
|
|
374
|
-
report.skipped.append(f"{name}: {type(exc).__name__}: {exc}")
|
|
375
|
-
|
|
376
|
-
report.finished_at = engine.now()
|
|
377
|
-
return report
|
|
1
|
+
"""不变量验收:把方案第七节跑成代码。
|
|
2
|
+
|
|
3
|
+
方案的原文:
|
|
4
|
+
|
|
5
|
+
> 每条不变量都必须能被独立测出,否则它只是口号。
|
|
6
|
+
|
|
7
|
+
| 不变量 | 方案给的验法 | 这里的实现 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| I1 来源性 | 随机抽 100 条输出,每条定位到 trace_id | 全量遍历 state(比抽样严) |
|
|
10
|
+
| I2 幂等性 | 同一输入跑两遍,逐字段比对 | 投影 == fold(事件流),逐字段 |
|
|
11
|
+
| I3 有界性 | 构造「什么都能匹配」的查询,看返回量 | 空过滤查询 + 大预算,看是否截断并标注 |
|
|
12
|
+
| I4 可逆性 | 抽 20 条已归档痕迹,尝试恢复 | 抽样恢复 + 恢复后归档回去 |
|
|
13
|
+
| I5 显式性 | 故障与空结果,检查返回类型 | 同一个 query 打在坏连接上 vs 空账上 |
|
|
14
|
+
|
|
15
|
+
## 为什么 I2 用「投影 == fold」而不是「跑两遍」
|
|
16
|
+
|
|
17
|
+
"跑两遍"要重放整条输入序列,那测的是**编码器稳定不稳定**(模型的事)。
|
|
18
|
+
而 I2 说的是"同一输入序列,收敛后的状态必须唯一"——它的内核含义是:
|
|
19
|
+
**状态是事件的纯函数**。
|
|
20
|
+
|
|
21
|
+
那就直接测这个函数关系:把事件流重新折一遍,和库里存的投影逐字段比。
|
|
22
|
+
不一致,说明投影带了事件流之外的信息(时间、随机数、上一次运行的残留),
|
|
23
|
+
那才是真的违反 I2。
|
|
24
|
+
|
|
25
|
+
## 为什么这些检查要独立成模块,不写成 pytest
|
|
26
|
+
|
|
27
|
+
因为它们是**验收**,不是**单元测试**:
|
|
28
|
+
|
|
29
|
+
- 单元测试断言"我知道的这条路径是对的"
|
|
30
|
+
- 验收断言"整个库现在没有违反不变量的地方"
|
|
31
|
+
|
|
32
|
+
前者跟着代码走,后者跟着数据走。一个真实部署在跑了一个月之后,
|
|
33
|
+
最该问的就是后者——而那个库不在测试目录里。
|
|
34
|
+
|
|
35
|
+
`AuditReport.verdict` 只有三种:pass / fail / partial。
|
|
36
|
+
**partial 必须带原因**,否则它就是个更委婉的 pass。
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import sqlite3
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from .recall import NOT_FOUND, UNAVAILABLE, Budget, Recall, RecallQuery
|
|
47
|
+
|
|
48
|
+
# 抽样上限。方案写的是"抽 100 条""抽 20 条",这里作为默认值。
|
|
49
|
+
DEFAULT_STATE_SAMPLE = 100
|
|
50
|
+
DEFAULT_ARCHIVE_SAMPLE = 20
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Finding:
|
|
55
|
+
"""一条检查结果。
|
|
56
|
+
|
|
57
|
+
`ok=False` 是**违规**,不是"没查"——没查的写在 `report.skipped` 里。
|
|
58
|
+
两者混在一起,报告就没法当门禁用。
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
invariant: str
|
|
62
|
+
ok: bool
|
|
63
|
+
subject: str
|
|
64
|
+
detail: str = ""
|
|
65
|
+
|
|
66
|
+
def as_line(self) -> str:
|
|
67
|
+
mark = "✓" if self.ok else "✗"
|
|
68
|
+
body = f"{mark} {self.invariant} {self.subject}"
|
|
69
|
+
return f"{body} {self.detail}" if self.detail else body
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class AuditReport:
|
|
74
|
+
scope_id: str
|
|
75
|
+
started_at: str
|
|
76
|
+
finished_at: str = ""
|
|
77
|
+
findings: list[Finding] = field(default_factory=list)
|
|
78
|
+
checked: dict[str, int] = field(default_factory=dict)
|
|
79
|
+
skipped: list[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
def add(self, f: Finding) -> None:
|
|
82
|
+
self.findings.append(f)
|
|
83
|
+
self.checked[f.invariant] = self.checked.get(f.invariant, 0) + 1
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def violations(self) -> list[Finding]:
|
|
87
|
+
return [f for f in self.findings if not f.ok]
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def verdict(self) -> str:
|
|
91
|
+
"""pass / fail / partial。
|
|
92
|
+
|
|
93
|
+
partial 只在"有检查没跑成"时出现,而且必须看 `skipped`——
|
|
94
|
+
一个永远返回 partial 的报告,和没有报告是一样的。
|
|
95
|
+
"""
|
|
96
|
+
if self.violations:
|
|
97
|
+
return "fail"
|
|
98
|
+
if self.skipped:
|
|
99
|
+
return "partial"
|
|
100
|
+
return "pass"
|
|
101
|
+
|
|
102
|
+
def summary(self) -> dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"scope_id": self.scope_id,
|
|
105
|
+
"verdict": self.verdict,
|
|
106
|
+
"checked": dict(self.checked),
|
|
107
|
+
"violations": len(self.violations),
|
|
108
|
+
"skipped": list(self.skipped),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ============================================================ I1 来源性
|
|
113
|
+
def check_source_closure(
|
|
114
|
+
engine, scope_id: str, sample: int = DEFAULT_STATE_SAMPLE,
|
|
115
|
+
) -> list[Finding]:
|
|
116
|
+
"""每条有值的事实,都必须能沿证据链走到可访问的痕迹。
|
|
117
|
+
|
|
118
|
+
方案要求"随机抽 100 条"。这里**全量查**——抽样只能证伪,不能证明;
|
|
119
|
+
而这个检查的成本是每条一次索引查询,全量也就几十毫秒。
|
|
120
|
+
真要抽样时把 `sample` 调小即可。
|
|
121
|
+
|
|
122
|
+
只查**有值**的状态(known / disputed)。unknown 本来就没有事实要支撑。
|
|
123
|
+
"""
|
|
124
|
+
rows = engine._conn().execute(
|
|
125
|
+
"SELECT state_key FROM states WHERE scope_id=?"
|
|
126
|
+
" AND status IN ('known','disputed') ORDER BY state_key LIMIT ?",
|
|
127
|
+
(scope_id, sample),
|
|
128
|
+
).fetchall()
|
|
129
|
+
|
|
130
|
+
out: list[Finding] = []
|
|
131
|
+
for row in rows:
|
|
132
|
+
key = row["state_key"]
|
|
133
|
+
try:
|
|
134
|
+
problems = engine.check_source_closure(key)
|
|
135
|
+
except Exception as exc: # noqa: BLE001
|
|
136
|
+
out.append(Finding("I1", False, key, f"检查本身失败:{exc}"))
|
|
137
|
+
continue
|
|
138
|
+
out.append(Finding("I1", not problems, key, "; ".join(problems)))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ============================================================ I2 幂等性
|
|
143
|
+
def _projection_row(engine, state_key: str) -> dict[str, Any] | None:
|
|
144
|
+
row = engine._conn().execute(
|
|
145
|
+
"SELECT value_json, status, version FROM states WHERE state_key=?",
|
|
146
|
+
(state_key,),
|
|
147
|
+
).fetchone()
|
|
148
|
+
return dict(row) if row else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_projection_is_a_function_of_events(
|
|
152
|
+
engine, scope_id: str, sample: int = DEFAULT_STATE_SAMPLE,
|
|
153
|
+
) -> list[Finding]:
|
|
154
|
+
"""投影必须等于「把事件流重新折一遍」的结果。
|
|
155
|
+
|
|
156
|
+
这是 I2 最内核的一条:**状态是事件的纯函数**。
|
|
157
|
+
只要这条成立,同一输入序列跑两遍必然得到同一个状态——
|
|
158
|
+
因为两遍产生的事件流相同,而同一个事件流只有一个折法。
|
|
159
|
+
|
|
160
|
+
不一致的常见原因(都是真的坑):
|
|
161
|
+
投影里混进了「当前时间」(过期判断)
|
|
162
|
+
某条写路径自己改了 states,没走 _recompute
|
|
163
|
+
快照没被正确作废
|
|
164
|
+
"""
|
|
165
|
+
rows = engine._conn().execute(
|
|
166
|
+
"SELECT state_key FROM states WHERE scope_id=? ORDER BY state_key LIMIT ?",
|
|
167
|
+
(scope_id, sample),
|
|
168
|
+
).fetchall()
|
|
169
|
+
|
|
170
|
+
out: list[Finding] = []
|
|
171
|
+
# 只读:直接用现有连接折一遍。**不能**调 `_recompute`——它会写库,
|
|
172
|
+
# 验收工具一旦改数据,下一次验收看到的东西已经不一样了。
|
|
173
|
+
conn = engine._conn()
|
|
174
|
+
for row in rows:
|
|
175
|
+
key = row["state_key"]
|
|
176
|
+
spec = engine.registry.get(_relation_of(key))
|
|
177
|
+
if spec is None:
|
|
178
|
+
out.append(Finding("I2", False, key, "状态键指向未知关系"))
|
|
179
|
+
continue
|
|
180
|
+
try:
|
|
181
|
+
folded = engine._fold(conn, key, spec)
|
|
182
|
+
except Exception as exc: # noqa: BLE001
|
|
183
|
+
out.append(Finding("I2", False, key, f"重算失败:{exc}"))
|
|
184
|
+
continue
|
|
185
|
+
stored = _projection_row(engine, key)
|
|
186
|
+
if stored is None:
|
|
187
|
+
out.append(Finding("I2", False, key, "投影行消失"))
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
stored_value = json.loads(stored["value_json"]) if stored["value_json"] else None
|
|
191
|
+
same = (
|
|
192
|
+
_same_number(stored_value, folded.value)
|
|
193
|
+
and stored["status"] == folded.status
|
|
194
|
+
)
|
|
195
|
+
out.append(Finding(
|
|
196
|
+
"I2", same, key,
|
|
197
|
+
"" if same else f"投影 {stored_value}/{stored['status']}"
|
|
198
|
+
f" ≠ 重算 {folded.value}/{folded.status}",
|
|
199
|
+
))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _same_number(a: Any, b: Any) -> bool:
|
|
204
|
+
"""数值比较:None 只等于 None,其余按浮点容差。
|
|
205
|
+
|
|
206
|
+
不能用 `==` 直接比:JSON 读回来的 3 和 float 3.0 在语义上是一个数,
|
|
207
|
+
而它们的 `==` 也可能是 True——但 `3` 和 `3.000000001` 也是 True
|
|
208
|
+
在以 1e-9 为容差时就太松了。用相对误差更贴近"这是一笔钱"的直觉。
|
|
209
|
+
"""
|
|
210
|
+
if a is None or b is None:
|
|
211
|
+
return a is None and b is None
|
|
212
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
213
|
+
if a == b:
|
|
214
|
+
return True
|
|
215
|
+
return abs(a - b) <= max(1e-9, abs(a) * 1e-9)
|
|
216
|
+
return a == b
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _relation_of(state_key: str) -> str:
|
|
220
|
+
parts = state_key.split("|")
|
|
221
|
+
return parts[2] if len(parts) >= 3 else ""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ============================================================ I3 有界性
|
|
225
|
+
def check_recall_is_bounded(engine, scope_id: str) -> list[Finding]:
|
|
226
|
+
"""「什么都能匹配」的查询也不许把库倒出来。
|
|
227
|
+
|
|
228
|
+
构造:不给定任何过滤条件 + 一个很大的预算。
|
|
229
|
+
通过条件有两条,缺一不可:
|
|
230
|
+
返回条数 ≤ max_items
|
|
231
|
+
超了就**明确标注** truncated + 原因
|
|
232
|
+
"""
|
|
233
|
+
total = engine._conn().execute(
|
|
234
|
+
"SELECT COUNT(*) FROM states WHERE scope_id=?", (scope_id,),
|
|
235
|
+
).fetchone()[0]
|
|
236
|
+
# 上限压到 1,保证哪怕库里只有两条也会真的触发截断——
|
|
237
|
+
# 否则"检查截断"这一步在大库上跑得到、在小库上跑不到,
|
|
238
|
+
# 而小库恰恰是刚上线的样子。
|
|
239
|
+
budget = Budget(max_items=1, max_candidates=200, max_rounds=2,
|
|
240
|
+
max_chars=2000, timeout_ms=2000)
|
|
241
|
+
result = Recall(engine).query(
|
|
242
|
+
scope_id, RecallQuery(kind="state", budget=budget),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
out: list[Finding] = []
|
|
246
|
+
n = len(result.states)
|
|
247
|
+
out.append(Finding("I3", n <= budget.max_items, "returned_items",
|
|
248
|
+
f"{n} 条(上限 {budget.max_items},库里共 {total})"))
|
|
249
|
+
|
|
250
|
+
# 只有"库里确实还有更多"时,才要求它标注截断。
|
|
251
|
+
# 「返回 1 条、库里也正好 1 条」没被截断是正常的,
|
|
252
|
+
# 在那里要求 truncated=True 是把自己测出一个假故障。
|
|
253
|
+
if total > budget.max_items:
|
|
254
|
+
out.append(Finding(
|
|
255
|
+
"I3", result.truncated, "truncation_flag",
|
|
256
|
+
"" if result.truncated else "被截断却没标注,用户会以为这就是全部",
|
|
257
|
+
))
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ============================================================ I4 可逆性
|
|
262
|
+
def check_archive_is_reversible(
|
|
263
|
+
engine, scope_id: str, sample: int = DEFAULT_ARCHIVE_SAMPLE,
|
|
264
|
+
) -> list[Finding]:
|
|
265
|
+
"""归档不是删除。抽若干条已归档痕迹,恢复 → 读原文 → 再归档回去。
|
|
266
|
+
|
|
267
|
+
最后那步"归档回去"很重要:验收不该改变被测系统的状态。
|
|
268
|
+
否则跑一次验收,库就变了一次样——那下次验收看到的东西已经不一样了。
|
|
269
|
+
"""
|
|
270
|
+
from .decay import Lifecycle
|
|
271
|
+
|
|
272
|
+
rows = engine._conn().execute(
|
|
273
|
+
"SELECT ref_id FROM lifecycle WHERE scope_id=? AND level='archived'"
|
|
274
|
+
" ORDER BY ref_id LIMIT ?",
|
|
275
|
+
(scope_id, sample),
|
|
276
|
+
).fetchall()
|
|
277
|
+
if not rows:
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
lc = Lifecycle(engine)
|
|
281
|
+
out: list[Finding] = []
|
|
282
|
+
for row in rows:
|
|
283
|
+
tid = row["ref_id"]
|
|
284
|
+
before = engine.get_trace(tid)
|
|
285
|
+
if before is None or before["erased_at"]:
|
|
286
|
+
# 用户行使过删除权。这不是"归档不可逆",是"这条本来就不该在库里"。
|
|
287
|
+
out.append(Finding("I4", True, tid, "已擦除,跳过"))
|
|
288
|
+
continue
|
|
289
|
+
if not (before["content"] or "").strip():
|
|
290
|
+
# 原文是空的,就谈不上"恢复回来"。
|
|
291
|
+
# 这条如果算通过,I4 会被一种最糟的情况蒙过去:
|
|
292
|
+
# 内容早就没了,而检查还在说"恢复成功"——恢复了一个空字符串。
|
|
293
|
+
out.append(Finding("I4", False, tid, "原文为空,归档后无从恢复"))
|
|
294
|
+
continue
|
|
295
|
+
ok = lc.restore(tid)
|
|
296
|
+
text = engine.get_trace(tid)
|
|
297
|
+
readable = text is not None and text["content"] == before["content"]
|
|
298
|
+
out.append(Finding("I4", ok and readable, tid,
|
|
299
|
+
"" if ok and readable else "恢复后原文不可读"))
|
|
300
|
+
lc.archive(tid) # 复原成被检查前的样子
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ============================================================ I5 显式性
|
|
305
|
+
class _FailingEngine:
|
|
306
|
+
"""只用来造"查询故障"。
|
|
307
|
+
|
|
308
|
+
方案 I5 的验法是:故障与空结果两种场景,返回类型必须不同。
|
|
309
|
+
要造故障,就得让底层真的抛错——所以给一个只会抛错的 connection。
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def __init__(self, now: str):
|
|
313
|
+
self._now = now
|
|
314
|
+
|
|
315
|
+
def _conn(self):
|
|
316
|
+
raise sqlite3.Error("simulated storage failure")
|
|
317
|
+
|
|
318
|
+
def now(self) -> str:
|
|
319
|
+
return self._now
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def check_status_is_explicit(engine, scope_id: str) -> list[Finding]:
|
|
323
|
+
"""「故障」和「没有」不能返回同一个东西。
|
|
324
|
+
|
|
325
|
+
把"系统坏了"说成"没有这条记忆",是把系统问题伪装成事实问题(I5)。
|
|
326
|
+
"""
|
|
327
|
+
out: list[Finding] = []
|
|
328
|
+
|
|
329
|
+
# 空结果必须是真的空:查一个不存在的 subject。
|
|
330
|
+
# 直接查 `kind="state"` 会连整个 scope 的状态都捞出来——那不是空查询,
|
|
331
|
+
# 是"不设过滤",测出来永远是 found。
|
|
332
|
+
empty = Recall(engine).query(
|
|
333
|
+
scope_id,
|
|
334
|
+
RecallQuery(kind="state", subject="__no_such_subject__", relation="debt"),
|
|
335
|
+
)
|
|
336
|
+
out.append(Finding("I5", empty.status == NOT_FOUND, "empty_result", empty.status))
|
|
337
|
+
|
|
338
|
+
broken = Recall(_FailingEngine(engine.now())).query(
|
|
339
|
+
scope_id, RecallQuery(kind="state"),
|
|
340
|
+
)
|
|
341
|
+
out.append(Finding("I5", broken.status == UNAVAILABLE, "storage_failure",
|
|
342
|
+
broken.status))
|
|
343
|
+
|
|
344
|
+
out.append(Finding(
|
|
345
|
+
"I5", empty.status != broken.status, "distinguishable",
|
|
346
|
+
"故障与空结果返回了同一个状态" if empty.status == broken.status else "",
|
|
347
|
+
))
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ============================================================ 一起跑
|
|
352
|
+
def audit(
|
|
353
|
+
engine, scope_id: str,
|
|
354
|
+
state_sample: int = DEFAULT_STATE_SAMPLE,
|
|
355
|
+
archive_sample: int = DEFAULT_ARCHIVE_SAMPLE,
|
|
356
|
+
) -> AuditReport:
|
|
357
|
+
"""跑全套五条不变量。"""
|
|
358
|
+
report = AuditReport(scope_id=scope_id, started_at=engine.now())
|
|
359
|
+
|
|
360
|
+
checks = [
|
|
361
|
+
("I1", lambda: check_source_closure(engine, scope_id, state_sample)),
|
|
362
|
+
("I2", lambda: check_projection_is_a_function_of_events(engine, scope_id, state_sample)),
|
|
363
|
+
("I3", lambda: check_recall_is_bounded(engine, scope_id)),
|
|
364
|
+
("I4", lambda: check_archive_is_reversible(engine, scope_id, archive_sample)),
|
|
365
|
+
("I5", lambda: check_status_is_explicit(engine, scope_id)),
|
|
366
|
+
]
|
|
367
|
+
for name, fn in checks:
|
|
368
|
+
try:
|
|
369
|
+
for finding in fn():
|
|
370
|
+
report.add(finding)
|
|
371
|
+
except Exception as exc: # noqa: BLE001
|
|
372
|
+
# 检查本身失败不能算"通过",也不能算"违规"——单列出来。
|
|
373
|
+
# 混进 verdict=fail 会掩盖真正的不变量违规。
|
|
374
|
+
report.skipped.append(f"{name}: {type(exc).__name__}: {exc}")
|
|
375
|
+
|
|
376
|
+
report.finished_at = engine.now()
|
|
377
|
+
return report
|