已读延迟窗本体+scope_ceiling三档+文档同步 #128

Merged
Yushu merged 2 commits from feat/123-read-delay-window into main 2026-08-13 04:54:11 +00:00
Member

Closes #123

实现

ADR-0017"已读延迟窗"落地:Outbox 防抖批次就绪后、Runtime Loop 组装上下文前独立计时,delay = clamp(f(energy), 0, scope_ceiling[scope])

  • read_delay.pycircadian_energy_adjustment/compute_read_delay_seconds 纯函数 + read_delay_window 薄 IO 壳(asyncio.sleep)。
  • scope_ceiling 三档静态值(私聊/群聊/冷启动,冷启动档统一取最紧值,优先于私聊/群聊判断)。
  • 昼夜节律 energy nudge(ADR-0017 原文提到但整仓零实现的缺口):占位幅度/时段,只影响本次已读延迟决策,不持久化、不进门控判定。

真正的改动面按 AC 点名不在函数体,在 flush/cancel 的归属结构Debouncer.flush() 从"防抖窗口结束立刻做"改为推迟到已读延迟窗结束之后才做。原因:flush() 立刻清空缓冲区 + _handle_reactive_message_pending_flushes[chat_id]cancel(),两者叠加意味着"flush 后 sleep"期间到达的新消息会 cancel 掉正在等待的任务、已取出的整批消息直接丢失、新消息另起缓冲区——与"新消息只并入同一累积批次、不延长等待"这条决策正相反。改法:新增模块级标记 _read_delay_in_progress,已读延迟窗进行中的 chat,新消息只 add_message 并入缓冲区,不 cancel、不重排。副作用(预期内):Debouncer.discard_message(撤回联动)的有效窗口随之自然从"防抖阶段"扩大到"防抖 + 已读延迟"两个阶段。

unlock_level/AffectiveState 在同一次 _debounced_flush 里收敛成各自一次读取(_evaluate_unified_gate 新增 unlock_level 覆盖参数,复用既有 affective_state 覆盖参数),避免同一次 flush 三处各读一次 Sent Log/画像/情感态。过程中发现并修了一个真实的正确性回归:_debounced_flush 为已读延迟窗新增的情感态读取,如果不通过覆盖参数传给门控,会变成门控"第二次读",一旦期间情感态被 tier-1 传染/衰减改动,门控判定用的就不是已读延迟窗算 energy 时看到的那份,决策快照也会记错——被 issue #79 就已存在的 test_the_snapshot_carries_the_affective_state_the_gate_judged_on 精确抓住。

两轴 review 发现与修正

两轴 review(sonnet)跑出 3 条发现,adversarial verify 后 2 条确认、1 条证伪:

  1. [HIGH,已修] test_gate_pipeline_unlock_level.py 手搓的 _FakePorts 只有 storage 一个字段,与 _evaluate_unified_gate 期望的 ArisePorts(字段齐全的具体 dataclass,非 Protocol)类型不兼容——ty/basedpyright 均报 invalid-argument-type,会在 CI 类型检查任务上红。改用仓库已有的 configure_arise fixture + get_ports() 拿一个真正合法的 ArisePorts,call-counting SentLog 通过其 sent_log 注入点接线,运行时行为不变。
  2. [MEDIUM,已修] test_read_delay_wiring_e2e.py 两条结构性回归测试靠固定 asyncio.sleep(0.1) 猜"任务应该已经进入已读延迟窗"——这正是本仓 dispatch_helpers.py 明文警告过、且 test_silence_window_wiring.py 已经真实撞过一次的满负载偶发失败模式。改为直接轮询本片新增的 _read_delay_in_progress 标记本身(超时兜底),确定性等待,不猜时长。
  3. [LOW,证伪] read_delay_window 签名"没有按 AC 字面接住 energy/scope/is_cold_start 三个输入"——核实后这个拆分理由已经逐字写在 compute_read_delay_seconds 的 docstring 里("scope/is_cold_start 由调用方算好传入,三档选择不是本函数职责"),发现声称"完全不出现"与代码矛盾,不成立。

文档

CONTEXT.md/design.md 真人感层条/mermaid gate 节点核对后确认已用现在时准确描述本机制,无需改字;design.md"上线路线"第 4 阶段枚举补了"已读延迟窗"一行;ADR-0017 补更新节记录 flush/cancel 归属结构重排的完整来龙去脉;ADR-0011 阈值表确认三个新增静态 config 字段的分类归属。

测试

  • 全量测试通过(1724 passed)。
  • ruff check/ty check 均通过。
  • 变异测试验证了 flush/cancel 归属结构、unlock_level 覆盖参数、affective_state 透传、compute_read_delay_seconds clamp、circadian_energy_adjustment 边界共 6 处新守卫(分别 mutate 后确认对应测试变红,还原后 md5 校验一致)。
  • test_read_delay_wiring_e2e.py 的确定性轮询修正后连续跑 5 次稳定通过。
Closes #123 ## 实现 ADR-0017"已读延迟窗"落地:Outbox 防抖批次就绪后、Runtime Loop 组装上下文前独立计时,`delay = clamp(f(energy), 0, scope_ceiling[scope])`。 - `read_delay.py`:`circadian_energy_adjustment`/`compute_read_delay_seconds` 纯函数 + `read_delay_window` 薄 IO 壳(`asyncio.sleep`)。 - `scope_ceiling` 三档静态值(私聊/群聊/冷启动,冷启动档统一取最紧值,优先于私聊/群聊判断)。 - 昼夜节律 energy nudge(ADR-0017 原文提到但整仓零实现的缺口):占位幅度/时段,只影响本次已读延迟决策,不持久化、不进门控判定。 **真正的改动面按 AC 点名不在函数体,在 flush/cancel 的归属结构**:`Debouncer.flush()` 从"防抖窗口结束立刻做"改为推迟到已读延迟窗**结束之后**才做。原因:`flush()` 立刻清空缓冲区 + `_handle_reactive_message` 对 `_pending_flushes[chat_id]` 会 `cancel()`,两者叠加意味着"flush 后 sleep"期间到达的新消息会 cancel 掉正在等待的任务、已取出的整批消息直接丢失、新消息另起缓冲区——与"新消息只并入同一累积批次、不延长等待"这条决策正相反。改法:新增模块级标记 `_read_delay_in_progress`,已读延迟窗进行中的 chat,新消息只 `add_message` 并入缓冲区,不 cancel、不重排。副作用(预期内):`Debouncer.discard_message`(撤回联动)的有效窗口随之自然从"防抖阶段"扩大到"防抖 + 已读延迟"两个阶段。 `unlock_level`/`AffectiveState` 在同一次 `_debounced_flush` 里收敛成各自一次读取(`_evaluate_unified_gate` 新增 `unlock_level` 覆盖参数,复用既有 `affective_state` 覆盖参数),避免同一次 flush 三处各读一次 Sent Log/画像/情感态。过程中发现并修了一个真实的正确性回归:`_debounced_flush` 为已读延迟窗新增的情感态读取,如果不通过覆盖参数传给门控,会变成门控"第二次读",一旦期间情感态被 tier-1 传染/衰减改动,门控判定用的就不是已读延迟窗算 `energy` 时看到的那份,决策快照也会记错——被 issue #79 就已存在的 `test_the_snapshot_carries_the_affective_state_the_gate_judged_on` 精确抓住。 ## 两轴 review 发现与修正 两轴 review(sonnet)跑出 3 条发现,adversarial verify 后 2 条确认、1 条证伪: 1. **[HIGH,已修]** `test_gate_pipeline_unlock_level.py` 手搓的 `_FakePorts` 只有 `storage` 一个字段,与 `_evaluate_unified_gate` 期望的 `ArisePorts`(字段齐全的具体 dataclass,非 Protocol)类型不兼容——`ty`/`basedpyright` 均报 `invalid-argument-type`,会在 CI 类型检查任务上红。改用仓库已有的 `configure_arise` fixture + `get_ports()` 拿一个真正合法的 `ArisePorts`,call-counting `SentLog` 通过其 `sent_log` 注入点接线,运行时行为不变。 2. **[MEDIUM,已修]** `test_read_delay_wiring_e2e.py` 两条结构性回归测试靠固定 `asyncio.sleep(0.1)` 猜"任务应该已经进入已读延迟窗"——这正是本仓 `dispatch_helpers.py` 明文警告过、且 `test_silence_window_wiring.py` 已经真实撞过一次的满负载偶发失败模式。改为直接轮询本片新增的 `_read_delay_in_progress` 标记本身(超时兜底),确定性等待,不猜时长。 3. **[LOW,证伪]** `read_delay_window` 签名"没有按 AC 字面接住 `energy`/`scope`/`is_cold_start` 三个输入"——核实后这个拆分理由已经逐字写在 `compute_read_delay_seconds` 的 docstring 里("`scope`/`is_cold_start` 由调用方算好传入,三档选择不是本函数职责"),发现声称"完全不出现"与代码矛盾,不成立。 ## 文档 CONTEXT.md/design.md 真人感层条/mermaid `gate` 节点核对后确认已用现在时准确描述本机制,无需改字;design.md"上线路线"第 4 阶段枚举补了"已读延迟窗"一行;ADR-0017 补更新节记录 flush/cancel 归属结构重排的完整来龙去脉;ADR-0011 阈值表确认三个新增静态 config 字段的分类归属。 ## 测试 - 全量测试通过(1724 passed)。 - `ruff check`/`ty check` 均通过。 - 变异测试验证了 flush/cancel 归属结构、`unlock_level` 覆盖参数、`affective_state` 透传、`compute_read_delay_seconds` clamp、`circadian_energy_adjustment` 边界共 6 处新守卫(分别 mutate 后确认对应测试变红,还原后 md5 校验一致)。 - `test_read_delay_wiring_e2e.py` 的确定性轮询修正后连续跑 5 次稳定通过。
ADR-0017"已读延迟窗"落地:Outbox 防抖批次就绪后、Runtime Loop 组装上下文前
独立计时,delay = clamp(f(energy), 0, scope_ceiling[scope])。

真正的改动面按 AC 点名不在函数体,在 flush/cancel 的归属结构——
Debouncer.flush() 从"防抖窗口结束立刻做"改为推迟到已读延迟窗结束才做,
期间新消息只并入同一累积批次(_read_delay_in_progress 标记 + 不 cancel
正在等待的 flush 任务),不是丢弃已取出的批次、也不是另起一批,倒计时
本身不因新消息重置。discard_message(撤回联动)的有效窗口随之自然从
"防抖阶段"延伸到"已读延迟阶段"。

scope_ceiling 三档静态值(私聊/群聊/冷启动,冷启动档统一取最紧值);
昼夜节律 energy nudge 补上此前整仓零实现的缺口(占位幅度/时段,待真实
数据标定);unlock_level 与 affective_state 在同一次 flush 里收敛成各自
一次读取,通过 _evaluate_unified_gate 新增的覆盖参数传下去,避免同一次
flush 三处各读一次 Sent Log/画像——后者同时修了一个真实发现的正确性
回归(_GateOutcome.affective_state 必须是门控当时看的那一份,不能被
已读延迟窗新增的读取悄悄变成第二次读到的漂移值)。
两轴 review(sonnet)跑出 3 条发现,adversarial verify 后 2 条确认、1 条证伪:

1. [HIGH,已修] test_gate_pipeline_unlock_level.py 手搓的 _FakePorts 只有
   storage 一个字段,与 _evaluate_unified_gate 期望的 ArisePorts(字段齐全
   的具体 dataclass,非 Protocol)类型不兼容——ty/basedpyright 均报
   invalid-argument-type,会在 CI 类型检查任务上红。改用仓库已有的
   configure_arise fixture + get_ports() 拿一个真正合法的 ArisePorts,
   calling-counting SentLog 通过其 sent_log 注入点接线,运行时行为不变。
2. [MEDIUM,已修] test_read_delay_wiring_e2e.py 两条结构性回归测试靠固定
   asyncio.sleep(0.1) 猜"任务应该已经进入已读延迟窗"——这正是本仓
   dispatch_helpers.py 明文警告过、且 test_silence_window_wiring.py 已经
   真实撞过一次的满负载偶发失败模式。改为直接轮询本片新增的
   _read_delay_in_progress 标记本身(超时兜底),确定性等待,不猜时长。
3. [LOW,证伪] read_delay_window 签名"没有按 AC 字面接住 energy/scope/
   is_cold_start 三个输入"——核实后这个拆分理由已经逐字写在
   compute_read_delay_seconds 的 docstring 里("scope/is_cold_start 由调用
   方算好传入,三档选择不是本函数职责"),发现声称"完全不出现"与代码矛盾,
   不成立。

全量测试 1724 通过,ruff/ty 均清。
Yushu merged commit d95829b6b6 into main 2026-08-13 04:54:11 +00:00
Yushu deleted branch feat/123-read-delay-window 2026-08-13 04:54:12 +00:00
Sign in to join this conversation.
No description provided.