决策快照结构化落库 + 全触发路径覆盖 #86

Merged
Yushu merged 2 commits from feature/79-decision-snapshot into main 2026-07-27 09:52:39 +00:00
Member

Closes #79

把散落在各触发路径的非结构化 logger.debug 决策日志,收敛成一份结构化、可持久化、可查询的记录。既有 logger.debug 一条没删(继续作运行时可观测性),但"事后能查"这个能力不再依赖日志文本解析。本片只做写入侧与存储侧,/why 是后续 ticket。

做了什么

  • decision_snapshot.pyDecisionSnapshot / GateSnapshot / 存储 port 切片 + snapshots_to_evict 保留策略纯函数
  • 两套存储实现(内存版 + 持久化版)共用一套契约测试;持久化侧裁剪与插入同事务
  • 五条触发路径的每个决策出口都写快照,含被门控拦下那些提前 return 的分支
  • 两个窄回调(ReportRecalledEvents / ReportPendingIntents):RuntimeLoop 只报"我这轮注入了这些 id",不知道接收方拿它做什么。收集器由 _build_runtime_loop 必填收下,五条路径全经由那个入口,"某条路径忘了收集"在类型层面不可能发生

实现期的几个取舍

  • 无门控的路径记 gate=None,不伪造"全部通过"。Callback 送回(issue #53"履约不是能动性决策")真的不经门控;伪造通过记录会让 /why 无法区分"评估过并放行"与"压根没评估",也会让 ADR-0011 的阈值标定吃进不存在的通过样本。情感态仍记实测值——没有门控不等于没有情绪。
  • 保留策略只做条数,不做时长。AC 给的口径是"条数/时长",这里取条数(也是 grill 时定的):条数天然跟着写入发生,不需要一个会漏掉冷群的后台清理扫描。代价是低频 chat 留存期不设上限——快照只有标识没有内容副本,所以不构成内容留存问题;真需要 TTL 时按同一条写入路径顺手删过期行即可。取舍写在 config.py 那个字段的文档里(权威处)。
  • 快照记的情感态必须是门控判定时看的那一份_GateOutcome)。若写快照时重新读,期间 tier-1 传染/衰减都可能改它,快照就会报一个决策从未依据过的情绪值。一条触发因此收敛为只读一次。
  • trigger_pathgate.trigger_kind 是两个独立事实。私聊的即刻追问复用 Drive Tick 扫描器执行,语义上却是追问——记 trigger_path="immediate_followup" 同时记 gate.trigger_kind="periodic"
  • 已知缺口:门控放行(预算已扣)之后 Runtime Loop 抛异常或被取消时这一轮不留快照。去抖被新消息顶掉属于预期取消(后一次 flush 自己会留痕);真异常那一支留给 #83 定清读取侧需求再补,已写进 _write_decision_snapshot 文档,不让它读起来像覆盖已完备。

顺带修的越界问题

storage.py::ensure_schema 原本是一份手写模型名单,它已经漏了两张表arise_pull_consents(issue #71)与 arise_sticker_learning_settings(issue #70)从未被建过,真实部署上一碰就报 no such table。改成按"模型类定义在本包里"筛选(不用表名前缀——那会抢建任何名叫 arise* 的第三方插件的表,而那类插件通常自己用 alembic 管 schema),并补 tests/test_schema_coverage.py 守住。根因不是粗心,是此前没有任何测试断言 ensure_schema 覆盖了本包全部模型,而那两张表的持久化路径从未被跑过。

验证

1141 测试全绿(顺序 + -n auto 并行满载各一遍)。

按 AC 用变异测试验覆盖完整性:逐条把某路径的写入变成 no-op,只挂掉该路径自己的测试;两个窄回调的接线、config 旋钮、并列时间戳排序、建表覆盖网各自也变异验过。

两轴 review 后修掉的问题(第二个 commit)

标准轴与规格轴独立撞上同两个问题,都是真的:

  • 真 bug:同一时刻写入的多条快照会淘汰刚写进去的那条。sorted(reverse=True) 稳定排序、等键保持插入先后,于是"最近的在前"列表实际最旧在前,裁剪取尾部正好删最新的——恰是 snapshots_to_evict 文档里警告过的失效模式。两套实现都补 (created_at, 写入序号) 确定性排序(持久化侧加 seq 列)。
  • 三条空转测试(断言在正确实现与变异实现下都成立):_GateOutcome 那条改用第二次读会漂移的存储替身;保留条数那条改成压低 config + 跑真实触发路径(原先全程不碰 config,把读 config 那行改成硬编码也全绿);Callback 路径那次情感态读取补上断言。本轮新加的建表覆盖测试我自己也先踩了同一个坑(期望集合取自被测函数自身),已改为独立枚举推导。
  • 不可达分支:Drive Tick 的 cooldown_state is None 那支上游已 continue、且冷却记录没有删除 API,永不执行——我把一层防御性冗余包装成了 AC 要求的路径覆盖。删掉写入。
  • 归因错误:私聊追问原本记成 drive_tick/why 会把"我刚问了你、你没回"错报成"它主动想找你说话"。
  • Drive Tick 一次触发读两遍情感态(喂给模型的与写进快照的可能不同值),收敛成一次。

ADR-0010 已在 docs 分支回填上述口径(本地 commit)。

Closes #79 把散落在各触发路径的非结构化 `logger.debug` 决策日志,收敛成一份结构化、可持久化、可查询的记录。既有 `logger.debug` 一条没删(继续作运行时可观测性),但"事后能查"这个能力不再依赖日志文本解析。本片只做写入侧与存储侧,`/why` 是后续 ticket。 ## 做了什么 - `decision_snapshot.py`:`DecisionSnapshot` / `GateSnapshot` / 存储 port 切片 + `snapshots_to_evict` 保留策略纯函数 - 两套存储实现(内存版 + 持久化版)共用一套契约测试;持久化侧裁剪与插入同事务 - 五条触发路径的**每个**决策出口都写快照,含被门控拦下那些提前 return 的分支 - 两个窄回调(`ReportRecalledEvents` / `ReportPendingIntents`):`RuntimeLoop` 只报"我这轮注入了这些 id",不知道接收方拿它做什么。收集器由 `_build_runtime_loop` **必填**收下,五条路径全经由那个入口,"某条路径忘了收集"在类型层面不可能发生 ## 实现期的几个取舍 - **无门控的路径记 `gate=None`,不伪造"全部通过"**。Callback 送回(issue #53"履约不是能动性决策")真的不经门控;伪造通过记录会让 `/why` 无法区分"评估过并放行"与"压根没评估",也会让 ADR-0011 的阈值标定吃进不存在的通过样本。情感态仍记实测值——没有门控不等于没有情绪。 - **保留策略只做条数,不做时长**。AC 给的口径是"条数/时长",这里取条数(也是 grill 时定的):条数天然跟着写入发生,不需要一个会漏掉冷群的后台清理扫描。代价是低频 chat 留存期不设上限——快照只有标识没有内容副本,所以不构成内容留存问题;真需要 TTL 时按同一条写入路径顺手删过期行即可。取舍写在 `config.py` 那个字段的文档里(权威处)。 - **快照记的情感态必须是门控判定时看的那一份**(`_GateOutcome`)。若写快照时重新读,期间 tier-1 传染/衰减都可能改它,快照就会报一个决策从未依据过的情绪值。一条触发因此收敛为只读一次。 - **`trigger_path` 与 `gate.trigger_kind` 是两个独立事实**。私聊的即刻追问复用 Drive Tick 扫描器执行,语义上却是追问——记 `trigger_path="immediate_followup"` 同时记 `gate.trigger_kind="periodic"`。 - **已知缺口**:门控放行(预算已扣)之后 Runtime Loop 抛异常或被取消时这一轮不留快照。去抖被新消息顶掉属于预期取消(后一次 flush 自己会留痕);真异常那一支留给 #83 定清读取侧需求再补,已写进 `_write_decision_snapshot` 文档,不让它读起来像覆盖已完备。 ## 顺带修的越界问题 `storage.py::ensure_schema` 原本是一份手写模型名单,**它已经漏了两张表**:`arise_pull_consents`(issue #71)与 `arise_sticker_learning_settings`(issue #70)从未被建过,真实部署上一碰就报 no such table。改成按"模型类定义在本包里"筛选(不用表名前缀——那会抢建任何名叫 `arise*` 的第三方插件的表,而那类插件通常自己用 alembic 管 schema),并补 `tests/test_schema_coverage.py` 守住。根因不是粗心,是此前没有任何测试断言 ensure_schema 覆盖了本包全部模型,而那两张表的持久化路径从未被跑过。 ## 验证 1141 测试全绿(顺序 + `-n auto` 并行满载各一遍)。 按 AC 用变异测试验覆盖完整性:逐条把某路径的写入变成 no-op,只挂掉该路径自己的测试;两个窄回调的接线、config 旋钮、并列时间戳排序、建表覆盖网各自也变异验过。 ## 两轴 review 后修掉的问题(第二个 commit) 标准轴与规格轴独立撞上同两个问题,都是真的: - **真 bug**:同一时刻写入的多条快照会淘汰**刚写进去**的那条。`sorted(reverse=True)` 稳定排序、等键保持插入先后,于是"最近的在前"列表实际最旧在前,裁剪取尾部正好删最新的——恰是 `snapshots_to_evict` 文档里警告过的失效模式。两套实现都补 `(created_at, 写入序号)` 确定性排序(持久化侧加 `seq` 列)。 - **三条空转测试**(断言在正确实现与变异实现下都成立):`_GateOutcome` 那条改用第二次读会漂移的存储替身;保留条数那条改成压低 config + 跑真实触发路径(原先全程不碰 config,把读 config 那行改成硬编码也全绿);Callback 路径那次情感态读取补上断言。本轮新加的建表覆盖测试我自己也先踩了同一个坑(期望集合取自被测函数自身),已改为独立枚举推导。 - **不可达分支**:Drive Tick 的 `cooldown_state is None` 那支上游已 `continue`、且冷却记录没有删除 API,永不执行——我把一层防御性冗余包装成了 AC 要求的路径覆盖。删掉写入。 - **归因错误**:私聊追问原本记成 `drive_tick`,`/why` 会把"我刚问了你、你没回"错报成"它主动想找你说话"。 - Drive Tick 一次触发读两遍情感态(喂给模型的与写进快照的可能不同值),收敛成一次。 ADR-0010 已在 `docs` 分支回填上述口径(本地 commit)。
把散落在各触发路径的非结构化 logger.debug 决策日志,收敛成一份结构化、
可持久化、可查询的记录——"它刚才为什么不理我"第一次变成事后能查的。
既有 logger.debug 全部保留作运行时可观测性,但"事后能查"这个能力不再
依赖日志文本解析。

- decision_snapshot.py:DecisionSnapshot/GateSnapshot/存储 port 切片 +
  snapshots_to_evict 保留策略纯函数。记忆条目只存标识不存内容副本
  (内容副本等于一份绕过敏感度分桶/知情-gate 治理的记忆影子拷贝,
  且条目被更新/删除后变成谁也管不到的过期残留)
- 五条触发路径的**每个**决策出口都写快照,含被门控拦下那些提前 return
  的分支——一条被拦下的决策恰恰是 /why 最需要能回答的那种
- Callback 送回记 gate=None:那条路径真的没有门控(issue #53"履约不是
  能动性决策"),不伪造"全部通过"的门控段,否则 /why 无法区分"评估过
  并放行"和"压根没评估",ADR-0011 的阈值标定也会吃进不存在的通过样本
- _GateOutcome:门控把它**当时看的**那个情感态一并交出来。若等写快照时
  再读一次,期间 tier-1 传染/衰减都可能改它,那条快照就在说谎
- 两个窄回调(ReportRecalledEvents/ReportPendingIntents):RuntimeLoop
  只报"我这轮注入了这些 id",不知道接收方拿它做什么。收集器由
  _build_runtime_loop 必填收下,五条路径全经由那个入口,"某条路径忘了
  收集"在类型层面不可能发生
- 顺带修好 ensure_schema 的手写模型名单:它已经漏了 arise_pull_consents
  (issue #71)与 arise_sticker_learning_settings(issue #70),真实部署
  上一碰就报 no such table。改成按表名前缀筛选,结构上不会再漏

按 AC 用变异测试验过覆盖完整性:逐条把某路径的写入变成 no-op,只挂掉该
路径自己的测试;两个窄回调的接线各自也变异验过一次。

Closes #79

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
标准轴与规格轴独立撞上同两个问题(并列时间戳、_GateOutcome 无回归网),都是真的。

真 bug:
- 同一时刻写入的多条快照会淘汰**刚写进去**的那条。`sorted(reverse=True)` 是
  稳定排序、等键保持插入先后,于是"最近的在前"列表实际最旧在前,裁剪取尾部
  正好删掉最新的(retain=1 时 write 返回的 record 当场就不在库里)——恰是
  snapshots_to_evict 文档里警告过的失效模式。两套实现都补 `(created_at, 写入
  序号)` 确定性排序(持久化侧加 seq 列),并补一条并列时间戳的契约测试。

空转测试(断言在正确实现与变异实现下都成立):
- _GateOutcome 那条:改用第二次读会漂移的存储替身,现在"写快照时现读一次"的
  变异会挂。此前该抽象横跨多个调用点却没有任何回归网。
- 保留条数那条:原先直接给存储传 retain_per_chat、全程不碰 config,把读 config
  那行改成硬编码也全绿。改成压低 config + 跑两次真实触发路径。
- Callback 路径专门多读的那次情感态:无人断言,删掉退回中性也全绿。补断言。
- 建表覆盖那条(本轮新加时自己也踩了同一个坑):期望集合原本取自 own_tables()
  自身,它漏一张期望就跟着少一张。改为独立递归枚举 Model 子类推导。

不可达分支:
- Drive Tick 的 `cooldown_state is None` 那支上游已 continue、且冷却记录没有
  删除 API,永不执行——把一层防御性冗余包装成了 AC 要求的路径覆盖。删掉写入,
  保留 return 并标注理论不可达。

归因错误:
- 私聊的即刻追问复用 Drive Tick 扫描器执行,快照却记成 drive_tick,`/why` 会
  把"我刚问了你、你没回"错报成"它主动想找你说话"。due 列表带上到期原因;两个
  条件同真时追问优先(更近更具体的原因)。门控仍按 periodic 评估——那是"用哪套
  判定",与"哪条触发把我们带到这里"是两件事,快照分别记。
- Drive Tick 一次触发读两遍情感态,喂给模型的与写进快照的可能不同值。收敛成
  一次读(同时也是 record_proactive_attempt 用的那份)。

其它:
- ensure_schema 从"表名前缀"换成"模型类定义在本包里":前缀口径会抢建任何名叫
  arise* 的第三方插件的表,而那类插件通常自己用 alembic 管 schema。
- _write_decision_snapshot 收整个 _GateOutcome 而不是拆开的 gate+affective_state
  ——拆开等于把这个抽象存在的唯一理由又拆掉。
- 修 4 处指向不存在方法名的注释、gating.GateEvaluation 的过时指向、保留策略理由
  的三份重复副本;在 _write_decision_snapshot 文档里写明"放行后异常/取消不留痕"
  这个已知缺口(留给 #83 定清读取侧需求再补)。

每条修正都做了变异验证,且这次先确认变异真的完整落地——第一次做 _GateOutcome
那条时变异只落地一半(另一半没匹配上格式),测试假绿过一轮。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yushu merged commit d6185cc952 into main 2026-07-27 09:52:39 +00:00
Yushu deleted branch feature/79-decision-snapshot 2026-07-27 09:52:40 +00:00
Sign in to join this conversation.
No description provided.