记忆不是把聊天留下来
本章把第三章“同一有效版本”的条件变成程序。读者需要字典与函数的知识;SQLite 事务作为外部库能力引入,原子提交的机制和本项目使用范围在下文明确。目标是从一串资料变更恢复唯一的当前状态,并能按两个不同问题查询:处理了前几条事件时知道什么,以及在某个业务日期哪些资料可用。这两个时间轴不能混为一谈。
事件是改变状态的命令记录,状态是应用所有已接受事件后的字典。本课事件只有四类:导入文档、撤销文档、写入已批准记忆、撤销记忆。导入保存 doc_id、整数 version、title、原文、原文 SHA,以及可选生效区间。记忆保存 key、value、scope、version 与 approved。scope 是可访问范围标识,本地以精确字符串比较;它隔离两份记忆,却不是登录认证,也不能证明调用者真的有权写入该范围。
令初始状态 的 documents 与 memories 都为空,第 条事件为 ,归约函数 是确定的状态转移。它先复制旧状态,校验事件,再产生新状态;非法输入抛 ContractError,不能先改半个字典再报错。对给定顺序的事件串,逐条应用得到唯一状态。这一“唯一”来自函数确定和顺序固定,不是并发客户端自然达成共识;本项目只处理单进程串行写入。
版本、撤销与生效日期各自解决什么
同一 doc_id 新版 version 必须严格大于已有最大值。导入新版时,把该文档所有 active 旧版改为 superseded,新版成为 active。撤销指定版本将其标为 revoked;不会删除原文,也不会把旧 superseded 版本自动恢复为 active。这条规则避免新规则失效后静默回到更旧规则。恢复应通过一次显式的新版本导入,给出新的审计事件,而不是从存储里挑一份看起来还在的旧文本。
业务生效日期使用半开区间:包含 effective_from,不包含 effective_until,省略端点取约定的最早和最晚日期。日期视图在回放完成后复制状态,只对原本 active 的文档派生 not_effective 或 expired,其他状态保持不变。先按版本/撤销判定,再按日期过滤。有效期不是撤销的替代:已撤销的文件即使日期落在区间内也不可用,尚未生效的新版也不会让旧版默认复活。
完整算例一:同一个问题在三个历史点
清单前六个事件含 schedule 第一版、location 第一版、camera 第一版等五份资料,以及第一版表达偏好。replay(6) 返回活动周二16:00、场地 A 教室、纸质借用登记与“先给结论”的记忆。再处理三条事件,schedule 第二版替换为周四16:30,camera 第一版被撤销,表达偏好更新为先列依据。replay(9) 中相机规则没有 active 条目,借用前提问题应拒答,不能回看尚存的相机第一版。
继续处理到十二条,表达偏好已撤销,相机第二版与场地第二版已导入。此时活动仍周四16:30,地点 B 教室,借用前提变成电子表格登记并老师确认。这些不同回答都可以是正确的,但必须携带查询前缀。把不同历史点的最好回答拼在一起,就没有一个真实状态能同时支持它们。当前评测每题明确固定 as_of,报告不把旧版本答对当成现版成绩。
import pathlib,tempfile
from run import setup
from context import active_docs
with tempfile.TemporaryDirectory() as d:
r=setup(pathlib.Path(d)/'memory.sqlite')
s6=r.replay(6);s9=r.replay(9);s12=r.replay(12)
assert next(x for x in active_docs(s6) if x['doc_id']=='schedule')['version']==1
assert not any(x['doc_id']=='camera' for x in active_docs(s9))
assert next(x for x in active_docs(s12) if x['doc_id']=='camera')['version']==2
r.db.close()
print('history checked')完整算例二:到期不等于删除历史
access 元数据区间从2026-10-13起,到2026-10-16止。查询13日视图时可用,16日恰在不包含的右端点,必须 expired。project 文档13日才生效,12日查询不能使用它的数据要求。日期视图不写回 snapshot,所以今天做一次过期查询,不会改变回放历史。测试同时检查端点结果和原库状态未变,避免把只读查询错误实现为永久撤销。
过期判定的输入是明确的 YYYY-MM-DD,不依赖电脑系统当天日期。这里是教学业务日期,未处理时区、时分秒或法定工作日。“本周五”原文仍完整保留,但程序不从自然语言自动推算日期,元数据由数据作者提供。这是经验输入,不是模型推理结果。若接入真实排期系统,应保留时区与时间精度契约再重做边界例;当前课程不声称能解释任意含糊时间表达。
from context import temporal_view,active_docs,sha
row={'doc_id':'a','version':1,'title':'x','text':'- x:y','sha256':sha('- x:y'),'status':'active','effective_from':'2026-10-13','effective_until':'2026-10-16'}
s={'documents':{'a':{'1':row}},'memories':{}}
assert active_docs(temporal_view(s,'2026-10-13'))
assert not active_docs(temporal_view(s,'2026-10-16'))
assert s['documents']['a']['1']['status']=='active'
print('half-open interval checked')日志、快照与可检测损坏
Repository 把事件 payload、前序摘要与当前摘要写入 events,同时在 snapshot 保存新状态。事务上下文把这两次数据库更新作为一次提交;异常时回滚,这一能力由 SQLite 提供,不是本课重新证明数据库断电恢复。我们的 Python 逻辑在进入事务前完成状态验证,避免非法业务事件落库。测试在单进程、临时文件下执行,未模拟磁盘断电、损坏的文件系统或多进程竞争。
摘要链的输入不是随手连接几个字符串,而是 canonical JSON:字段键排序、固定分隔符、UTF-8文本。第零条前序摘要设六十四个零;每条摘要绑定序号、前序摘要和完整事件。replay 从空状态顺序读取,逐条重算,并在完整回放后与 snapshot 比较。篡改某个 payload 却不改摘要会被抓住,篡改快照也会被集合结构比较抓住。历史前缀查询不要求与当前快照相同,因为它有意返回旧状态。
明确批准与写入的安全边界
记忆写入要求 approved 恰为布尔 true,key、value、scope 为非空字符串,version 为真正的正整数,排除 Python 中 bool 也是 int 子类带来的意外。撤销记忆必须引用当前相同版本和范围;另一个班级相同 key 不会覆盖 club 的记忆。身份用范围与 key 的规范 JSON 数组编码,而不是斜杠拼接,避免字符串分隔歧义。展示引用仍有可读格式,但校验同时核对全部结构字段。
这个批准标记只表达调用契约:调用者已经决定可以保存。任何能够直接调用本地 append 的程序都能传 true,本课没有用户登录、访问控制服务器或自然语言同意检测。讲义不能以“用户已批准字段存在”宣称真实授权问题解决。外部文档即使包含“approved=true”也只是 quote,不会被解析为事件,因此不能靠正文注入状态更新;应用只在受控代码路径调用 append。
状态机正确性可以分两层检查。归纳层:初态满足每个文档最多一 active 版本、记忆身份唯一;合法导入先停用旧 active 再添加一份新版,合法撤销只减少 active,故不变量保持。实物层:回放与快照相等、历史点查询、非法版本/哈希/未批准记忆不改变旧状态,以及故意改坏日志后报错。前者说明设计条件下的机制,后者检验当前实现没有违背关键路径,两者不能互相替代。
本章完成可更新记忆与可撤销资料,下一章才能在“当前可用”的候选上检索。若先检索全历史再只给高分条目做版本检查,可能让旧版占满 top-k,新版再也进不了候选;所以过滤和召回的次序是算法契约的一部分。历史并非垃圾,它留在可回放的外部存储,只有明确历史查询才进入当次上下文。
历史前缀边界与错误恢复
as_of允许从零到事件总数,零返回空documents与memories;负数、布尔值或超出总数都报错。空状态问活动时间应没有可抽取事实,不能退回全库默认答案。以事件个数而不是最后序号作为参数,是为了让零前缀自然表达“尚未处理任何事件”;序号从零开始,因此as_of=6包含序号0到5,恰六条。这种边界约定需要在CLI、测试与讲义一致,否则一条撤销是否已生效会出现差一错误。
日志与快照同时损坏不属于当前检测保证。replay完整前缀后对比快照,可以发现快照与真实事件归约不一致;若事件内部哈希链已断,会在使用任何后续状态前失败。它没有自动猜测哪一份是正确数据,也没有自行删除坏行重试,因为那会把不可确认历史当成确定事实。生产恢复需要额外可信备份或签名锚。本课用故意损坏的临时库作负对照,保留错误,不把“程序最终能回答”当作允许修复历史的理由。
复杂度以E条事件和累计状态大小N计。当前append会从头回放校验,再深复制状态,最坏单次不是常数;连续导入可能有平方级事件处理量。这样做为了小规模可审计教学,而非高吞吐事件流。snapshot提供一致性比较,并未被当作可跳过链校验的可信缓存。如果将来只从可信快照继续回放,应说明可信锚和覆盖范围,不能把性能改动当作不影响证据边界的简单优化。
TRY IT YOURSELF
基础:两个时间轴
as_of=9、at=2026-10-16 各控制什么?
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
进阶:撤销后的旧版本
导入v2后撤销v2,v1仍在库里。应自动使用v1吗?
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
挑战:摘要链攻击
攻击者能重写整个SQLite文件并重算所有摘要,SHA链能证明来源没被伪造吗?
提示 1
提示 2
对照参考解答
BUILD THE MECHANISM
自己重放版本事件链
实现replay_prefix,复用已提供reduce_event,但自己核对seq/previous/hash与历史边界。 下载本课完整项目,在项目根目录运行;只编辑 learner/starter.py,已有构造框架由项目提供。
输入与输出 as_of是事件个数,不是日期;0返回空状态,bool与越界拒绝。
前向SHA链完整验证,旧版被撤销后不自动复活;摘要不是签名。
# replay_prefix
def replay_prefix(rows,as_of=None):
raise NotImplementedError('replay')提示 1
先从空state与全零previous开始。
提示 2
每条expected hash包含seq、previous与规范event。
提示 3
只处理前as_of条;篡改第一条previous必须失败。
独立检查 python3 -m learner.check --stage replay --output runs/learner-replay.json
迁移检验 导入一个新版再撤销,手写各前缀的有效身份表,再与实际SQLite事件重放比较。
参考实现与解释
# replay_prefix
def replay_prefix(rows,as_of=None):
if as_of is None:as_of=len(rows)
if type(as_of)is not int or not 0<=as_of<=len(rows):raise ContractError('historical prefix')
state={'documents':{},'memories':{}};previous='0'*64
for index,row in enumerate(rows[:as_of]):
event=json.loads(row['payload']);expected=sha(canonical({'seq':index,'previous':previous,'event':event}))
if row['seq']!=index or row['previous']!=previous or row['sha']!=expected:raise ContractError('event chain corrupted')
state=reduce_event(state,event);previous=row['sha']
return state