Delta 压缩容错 + 互动极性作为 valence 产出方 #99

Merged
Yushu merged 2 commits from fix/94-delta-resilience-and-valence into main 2026-07-30 06:16:09 +00:00
Member

Closes #94

两件事必须同票:产出方要挂在 Delta 压缩那次 LLM 调用上,而那正是会丢数据的那条路径 —— 先落容错,再挂产出方

A. 压缩失败不再丢原文

drain_delta 是 select → delete → commit → return,删除先于压缩;而 delta.py 此前全文零 tryllm_client.complete 是裸调用。一次 LLM 抛异常 = 该 chat 本批对话原文永久消失,不需要 host、不需要真实流量。

降级方式是原样写回 Delta 缓冲(AC 明文允许的等价做法):原文回到它本来的位置,list_chats_with_pending_delta 下一轮自然重新捡起来 —— ADR-0009 要的「标记待重试 + 降级状态能被自愈机制探测」由此天然满足,不新增状态或字段。

try 罩住整段 per-chat 处理而不只是 compress():写事件要调 embedding、写 Knowledge 要调 judge_persona_drift,任何一处抛出的后果都一样。熔断单独接PoolExhausted → 写回 + break):__init__.py 在 cycle 开始前就查过闸,中途熔断时让剩下每个 chat 各撞一次只会得到 N 份空转和 N 条 traceback。

代价已在代码里逐条写清,不是零record_trust_disclosure 增益会重复施加、极性会再推一次 valence(极性更新在写库段之前);写回是 append 到队尾而 drain 按 id 排序,失败期间新到的消息会让重试批时序错乱;失败若源于内容本身(上下文超长/内容过滤)则无限重试、缓冲只涨不消 —— ADR-0009 为此设计的下一档降级(连续失败转低置信 tentative)本票没做,那需要跨重启的失败计数(新列 + schema 变更),是独立决定。

B. valence 三层不再恒为 0

ADR-0005/CONTEXT.md 断言 valence += f(互动极性) − k_fast·(valence − slow_valence),但 f(互动极性) 从来没有产出方 —— valence_raw_delta 在整个 src/ 里只出现在 affective_state.py 自己的签名与函数体内。三层全恒 0,下游三处全是死码。

抽取 schema 新增两个搭便车字段(零额外 LLM 调用,不碰前台回复路径):interaction_polarity(整批 → 快层)+ speaker_polarity(逐发言人 → 慢层 per-user)。

与 AC 的两处偏离(均已征询用户并获同意)

  1. AC 写「接线两个 update_fast_state 调用点」,实际新增了第三个调用点(在 cycle 内)。那两个现存调用点是环境信号 tier-1 分支与反思闭环,都跑不到普通对话上 —— 纯聊天的 chat 永远等不到它们触发,按字面接等于产出方对最常见的场景不生效。
  2. per-user 归属靠 schema 里的逐发言人极性,不是「整批极性套给每个人」—— ADR-0005 的 per-user 层就是为了区分个人,群里一个人友善一个人敌意不该拿到同一个增量。

三处刻意的设计

  • 快层只写 valence 轴。 update_fast_state 让三轴各自向慢层衰减一步,而这一步是调用驱动的;新增一个调用源若顺带推动另外两轴,等于给 energy/dominance 加了一个「每个压缩周期多衰减一次」的驱动源。那两轴不是本票的事(issue #94 明说 energy 维一直有效)。
  • 没有极性信号的这一轮一次都不读、一次都不写,理由同上;顺带保住 issue #67 那条「没有事件候选就不多读一次」的节流契约。
  • 没被提到的发言人一点都不动,不补中性 0 —— 慢层衰减是调用驱动的,补 0 等于让他白挨一次衰减。
  • 不做抗刷折扣,理由有一层反转值得记:FastAffectiveStateRecord 的文档把 last_stimulus_label 定义为互动极性刺激分类的槽位(ADR-0005),指定主人正是本票这个产出方;但实际占着它的是 tier-1 环境信号的 energy 轴判定。本票不收回槽位(那要动 tier-1 的抗刷行为,是独立决定),改依赖 300 秒调度频率兜住,并原样保留那对字段不覆盖 tier-1 的状态。

annoyed 端到端 —— AC 点名「最容易假通过的地方」

test_reflection_cycle.py 里已有一条产出 annoyed 的用例,但它是手写 update_fast_affective_state(valence=-0.2) 造出跌幅的;在接上产出方之前生产代码造不出这个跌幅,所以那条证明的是「分类器纯函数没写错」,不是「这条链在真实系统里通」。

新增 test_annoyed_end_to_end.py全程用生产默认值,跌幅由真实产出方造出,断言反思 LLM 收到的情况描述是「对方回应了,但情感态明显变差」—— offset 的正负分不出 annoyedfell-flat,不能拿它当结局证据。另带两条反向对照(暖场 / 中性都必须判 landed)。

守卫

  • AST 扫 src/:确认真的有一处以非零 valence_raw_delta 调过情感态更新(#94 之前会红)。已知局限:它防「被摘掉」有效,防「被阉掉」(改成 0.0 * polarity)无效 —— 那靠上面那些行为用例。
  • arise_valence_polarity_scale 默认值必须大于 annoyed 判定阈值,否则单次强负面互动推不出 annoyed,三态分类器实际仍只跑两态。

AC6:valence 维真的参与排序

test_the_valence_dimension_now_changes_the_ranking 把两条由真实产出方打上不同 valence 标签的事件放进同一次真实打分:负心情下负标签那条的 mood_congruence 必须更高(此前两条标签都是 0,这一维贡献相同、对排序零影响)。顺带让 recall 那道负性护栏真的触发一次。

两轴 review 抓到的(第二个 commit)

  • 第五次「事实错误的理由」:我写「不在这张票里顺手改掉一条被测试钉住的语义」——那条测试不存在(既有用例的 turn 不带极性,本批不发生更新,两种顺序同值)。我自己新写的用例还自称「正是按这个顺序验的」。已补一条同批既有极性又有事件候选的用例,那才是两种顺序唯一会分开的形状。
  • 抗刷槽位归属说反了(见上)。
  • per-group 那条理由不成立,已改为在这里跟一次 —— 那一层不再恒为 0。
  • energy/dominance 被顺带多衰减一步(未被要求的行为改变),已修 + 补断言。
  • 熔断被当普通失败吞掉导致 per-chat churn,已单独接。
  • 三个手抄的 config 缺省(第二个真源)改成 ValenceProducerConfig,同姊妹 cycle 的 ReflectionCycleConfig 先例,一致性守卫随之删除。

关于 Not in scope

「未获回应升级」与 send 失败兜底完全未触碰。_compress_one_chat 的提取是这次修复自己需要的局部提取(为了用一个 try 罩住整段而不让 60 行循环体再深一层缩进),不是 Not in scope 说的那种结构调整(拆 __init__.py、抽 StoragePort 在结构批 #97)。

验证

uv run pytest -n auto 连跑两次 1471 passed(新增 27)。ruff check 全过。

变异复验(每次都先核实变异真写进了文件):去掉原文写回 → 4 红;产出方置零 → 8 红;快层不再保留 energy/dominance → 1 红;去掉 per-group 跟随 → 1 红。还原后 md5 一致、sentinel 残留 0。

Closes #94 两件事必须同票:产出方要挂在 Delta 压缩那次 LLM 调用上,而那正是会丢数据的那条路径 —— **先落容错,再挂产出方**。 ## A. 压缩失败不再丢原文 `drain_delta` 是 select → delete → commit → return,**删除先于压缩**;而 `delta.py` 此前全文零 `try`、`llm_client.complete` 是裸调用。一次 LLM 抛异常 = 该 chat 本批对话原文永久消失,不需要 host、不需要真实流量。 降级方式是**原样写回 Delta 缓冲**(AC 明文允许的等价做法):原文回到它本来的位置,`list_chats_with_pending_delta` 下一轮自然重新捡起来 —— ADR-0009 要的「标记待重试 + 降级状态能被自愈机制探测」由此天然满足,不新增状态或字段。 `try` 罩住**整段 per-chat 处理**而不只是 `compress()`:写事件要调 embedding、写 Knowledge 要调 `judge_persona_drift`,任何一处抛出的后果都一样。**熔断单独接**(`PoolExhausted` → 写回 + `break`):`__init__.py` 在 cycle 开始前就查过闸,中途熔断时让剩下每个 chat 各撞一次只会得到 N 份空转和 N 条 traceback。 **代价已在代码里逐条写清,不是零**:`record_trust_disclosure` 增益会重复施加、极性会再推一次 valence(极性更新在写库段之前);写回是 append 到队尾而 drain 按 id 排序,失败期间新到的消息会让重试批**时序错乱**;失败若源于内容本身(上下文超长/内容过滤)则无限重试、缓冲只涨不消 —— ADR-0009 为此设计的下一档降级(连续失败转低置信 tentative)**本票没做**,那需要跨重启的失败计数(新列 + schema 变更),是独立决定。 ## B. valence 三层不再恒为 0 ADR-0005/CONTEXT.md 断言 `valence += f(互动极性) − k_fast·(valence − slow_valence)`,但 `f(互动极性)` 从来没有产出方 —— `valence_raw_delta` 在整个 `src/` 里只出现在 `affective_state.py` 自己的签名与函数体内。三层全恒 0,下游三处全是死码。 抽取 schema 新增两个搭便车字段(零额外 LLM 调用,不碰前台回复路径):`interaction_polarity`(整批 → 快层)+ `speaker_polarity`(逐发言人 → 慢层 per-user)。 ### 与 AC 的两处偏离(均已征询用户并获同意) 1. **AC 写「接线两个 `update_fast_state` 调用点」,实际新增了第三个调用点**(在 cycle 内)。那两个现存调用点是环境信号 tier-1 分支与反思闭环,**都跑不到普通对话上** —— 纯聊天的 chat 永远等不到它们触发,按字面接等于产出方对最常见的场景不生效。 2. **per-user 归属靠 schema 里的逐发言人极性**,不是「整批极性套给每个人」—— ADR-0005 的 per-user 层就是为了区分个人,群里一个人友善一个人敌意不该拿到同一个增量。 ### 三处刻意的设计 - **快层只写 valence 轴。** `update_fast_state` 让三轴各自向慢层衰减一步,而这一步是**调用驱动**的;新增一个调用源若顺带推动另外两轴,等于给 energy/dominance 加了一个「每个压缩周期多衰减一次」的驱动源。那两轴不是本票的事(issue #94 明说 energy 维一直有效)。 - **没有极性信号的这一轮一次都不读、一次都不写**,理由同上;顺带保住 issue #67 那条「没有事件候选就不多读一次」的节流契约。 - **没被提到的发言人一点都不动**,不补中性 0 —— 慢层衰减是调用驱动的,补 0 等于让他白挨一次衰减。 - **不做抗刷折扣**,理由有一层反转值得记:`FastAffectiveStateRecord` 的文档把 `last_stimulus_label` 定义为**互动极性刺激分类**的槽位(ADR-0005),指定主人正是本票这个产出方;但实际占着它的是 tier-1 环境信号的 energy 轴判定。本票**不收回**槽位(那要动 tier-1 的抗刷行为,是独立决定),改依赖 300 秒调度频率兜住,并原样保留那对字段不覆盖 tier-1 的状态。 ### `annoyed` 端到端 —— AC 点名「最容易假通过的地方」 `test_reflection_cycle.py` 里已有一条产出 `annoyed` 的用例,但它是**手写** `update_fast_affective_state(valence=-0.2)` 造出跌幅的;在接上产出方之前生产代码造不出这个跌幅,所以那条证明的是「分类器纯函数没写错」,不是「这条链在真实系统里通」。 新增 `test_annoyed_end_to_end.py`:**全程用生产默认值**,跌幅由真实产出方造出,断言反思 LLM 收到的情况描述是「对方回应了,但情感态明显变差」—— offset 的正负分不出 `annoyed` 与 `fell-flat`,不能拿它当结局证据。另带两条反向对照(暖场 / 中性都必须判 `landed`)。 ### 守卫 - **AST 扫 `src/`**:确认真的有一处以非零 `valence_raw_delta` 调过情感态更新(#94 之前会红)。已知局限:它防「被摘掉」有效,防「被阉掉」(改成 `0.0 * polarity`)无效 —— 那靠上面那些行为用例。 - `arise_valence_polarity_scale` 默认值必须大于 `annoyed` 判定阈值,否则单次强负面互动推不出 `annoyed`,三态分类器实际仍只跑两态。 ### AC6:valence 维真的参与排序 `test_the_valence_dimension_now_changes_the_ranking` 把两条**由真实产出方打上不同 valence 标签**的事件放进同一次真实打分:负心情下负标签那条的 `mood_congruence` 必须更高(此前两条标签都是 0,这一维贡献相同、对排序零影响)。顺带让 `recall` 那道负性护栏真的触发一次。 ## 两轴 review 抓到的(第二个 commit) - **第五次「事实错误的理由」**:我写「不在这张票里顺手改掉一条被测试钉住的语义」——**那条测试不存在**(既有用例的 turn 不带极性,本批不发生更新,两种顺序同值)。我自己新写的用例还自称「正是按这个顺序验的」。已补一条同批既有极性又有事件候选的用例,那才是两种顺序唯一会分开的形状。 - 抗刷槽位归属说反了(见上)。 - per-group 那条理由不成立,已改为**在这里跟一次** —— 那一层不再恒为 0。 - energy/dominance 被顺带多衰减一步(未被要求的行为改变),已修 + 补断言。 - 熔断被当普通失败吞掉导致 per-chat churn,已单独接。 - 三个手抄的 config 缺省(第二个真源)改成 `ValenceProducerConfig`,同姊妹 cycle 的 `ReflectionCycleConfig` 先例,一致性守卫随之删除。 ## 关于 `Not in scope` 「未获回应升级」与 `send` 失败兜底完全未触碰。`_compress_one_chat` 的提取是这次修复自己需要的局部提取(为了用一个 `try` 罩住整段而不让 60 行循环体再深一层缩进),不是 Not in scope 说的那种结构调整(拆 `__init__.py`、抽 `StoragePort` 在结构批 #97)。 ## 验证 `uv run pytest -n auto` 连跑两次 **1471 passed**(新增 27)。`ruff check` 全过。 变异复验(每次都先核实变异真写进了文件):去掉原文写回 → 4 红;产出方置零 → 8 红;快层不再保留 energy/dominance → 1 红;去掉 per-group 跟随 → 1 红。还原后 md5 一致、sentinel 残留 0。
两件事有先后依赖,必须同票:产出方要挂在 Delta 压缩那次 LLM 调用上,而那正是会
丢数据的那条路径——先落容错,再挂产出方,否则压缩失败会同时丢原文和情感更新。

## A. 压缩失败不再丢原文

`drain_delta` 是 select → delete → commit → return,**删除先于压缩**;而 `delta.py`
此前全文零 try、`llm_client.complete` 是裸调用。一次 LLM 抛异常 = 该 chat 本批对话
原文永久消失,不需要 host、不需要真实流量。

降级方式是**原样写回 Delta 缓冲**(AC 明文允许的等价做法):原文回到它本来的位置,
`list_chats_with_pending_delta` 下一轮自然重新捡起来——ADR-0009 要的"标记待重试 +
降级状态能被自愈机制探测"由此天然满足,不新增状态或字段。每个 chat 各自 try、不中断
循环(同一节明文要求"不阻塞后续 chat")。

try 罩住**整段 per-chat 处理**而不只是 `compress()`:写事件要调 embedding(有熔断,
会抛 PoolExhausted)、写 Knowledge 要调 judge_persona_drift(又一次 LLM),任何一处
抛出的后果都一样。已知代价:失败在写了一部分之后时,回炉重压会重复提炼出前面已落库
的事件(关系边有既有去重,tentative 画像按键覆盖)——"可能重复几条事件"明显优于
"整段对话永久消失"。

顺带把 per-chat 那段抽成 `_compress_one_chat`,不然 try 会让 60 行循环体再深一层缩进。

## B. valence 三层不再恒为 0

ADR-0005/CONTEXT.md 断言 `valence += f(互动极性) − k_fast·(valence − slow_valence)`,
但 `f(互动极性)` 从来没有产出方:`valence_raw_delta` 在整个 src/ 里只出现在
`affective_state.py` 自己的签名与函数体内。三层全恒 0,下游三处全是死码
(mood_congruence 负性护栏、`annoyed` 判定、自我披露倾向)。

抽取 schema 新增两个搭便车字段(零额外 LLM 调用,不碰前台回复路径):
`interaction_polarity`(整批,喂快层)+ `speaker_polarity`(逐发言人,喂慢层 per-user
——ADR-0005 要求按人归属,群里一个人友善一个人敌意不该拿到同一个增量)。

两处刻意的设计:
- **只在真有极性信号时才读写情感态**。无条件调 `update_fast_state` 会给情感态新增
  一个"每个压缩周期衰减一次"的驱动源(delta 是 0 也照样衰减一步),那是比 AC 大得多
  的行为改变;顺带也保住了 issue #67 那条"没有事件候选就不多读一次"的节流契约。
- **没被提到的发言人一点都不动**,不给他补一个中性 0——慢层衰减是调用驱动的,补 0
  等于让他白挨一次衰减。
- 不做抗刷折扣:那要占 `last_stimulus_label` 槽位,而它已被 tier-1 环境信号的 energy
  轴判定占着。极性的频率由调度天然限住(默认 300 秒一轮)。

`annoyed` 端到端:`test_reflection_cycle.py` 里那条 annoyed 用例是**手写**跌幅的
(生产代码造不出来)。新增 `test_annoyed_end_to_end.py` 全程用生产默认值,跌幅由真实
产出方造出,断言反思 LLM 收到的情况描述是"对方回应了,但情感态明显变差"——offset 的
正负分不出 annoyed 与 fell-flat,不能拿它当结局证据。另带两条反向对照(暖场/中性
都必须判 landed)。

守卫两条:AST 扫 src/ 确认"真的以非零 `valence_raw_delta` 调过情感态更新"(issue #94
之前会红);缺省常量与 Config() 默认值的一致性断言(两边来源不同,不是自证)。

变异复验:去掉原文写回 → 4 条容错用例全红;把两处产出方置零 → 6 条红(含 AST 守卫
与 annoyed e2e),fail-closed/中性那几条仍绿(正确,它们不依赖产出方非零)。
还原后 md5 与备份一致。全量 1467 passed(新增 23)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 两条事实错误的理由(同类第五次)

1. **「不在这张票里顺手改掉一条被测试钉住的语义」——那条测试不存在。**
   `test_delta_compression_cycle.py` 的 mood 用例 turn 不带极性 → 本批不发生情感态
   更新 → 读更新前/更新后同值,两种顺序都绿。我自己新写的用例也犯同一个毛病,还
   自称"正是按这个顺序验的"。已补 `test_the_tag_is_the_state_from_before_this_batchs_own_polarity`
   ——同一批**既有极性又有事件候选**,那是两种顺序唯一会给出不同答案的形状;docstring
   改成指向它。
2. **抗刷槽位的归属说反了。** 我写"`last_stimulus_label` 已被 tier-1 环境信号占着",
   但 `FastAffectiveStateRecord` 的文档把这对槽位定义为**互动极性刺激分类**的槽位
   (ADR-0005 2026-07-10),指定主人正是本票这个产出方;实际占着它的才是 tier-1。
   理由改成如实陈述:本票不收回槽位(那要动 tier-1 抗刷行为,是独立决定),因此
   不做极性侧抗刷,改依赖 300 秒调度频率兜住。
3. 顺带:per-group 那条"tier-1 已经在调它,快层一动它自然跟着走"也不成立——
   `update_slow_group_state` 全仓唯一调用点在 tier-1 分支内,而本票偏离 AC 的立论
   正是"纯聊天部署跑不到 tier-1"。已改为**在这里跟一次**,那一层不再恒为 0。

## energy/dominance 被顺带多衰减一步(未被要求的行为改变)

`update_fast_state` 让三轴各自向慢层衰减一步,而这一步是调用驱动的。本票在快层新增
了一个调用源,于是 energy/dominance 白挨一次衰减——而 issue #94 明说 energy 维一直
有效(环境信号在喂它),那两轴不是本票的事。改成只取算出来的 valence,另两轴原样
保留,并补一条断言。

## 熔断不该被当成普通失败吞掉

`__init__.py` 在 cycle 开始前就查过熔断闸,理由是"避免留下半截状态"。中途熔断时原来
的写法会让剩下每个 chat 各撞一次闸,得到 N 份 drain→抛→写回的空转和 N 条 traceback。
现在 `PoolExhausted` 单独接:原文照样写回,但整轮 break。

## 缺省值不再有第二个真源

原来三个裸参数各带一份手抄的 config 缺省,靠一条一致性断言兜着(一个数字要改两处)。
改成 `ValenceProducerConfig`(无缺省,同姊妹 cycle 的 `ReflectionCycleConfig` 先例):
数值只在 `Config` 里有一份,host 全量注入,测试从 `Config()` 构。一致性守卫随之删除。
`valence=None` 现在明确表示"没接产出方",只关心压缩本身的测试不必凭空多出一套情感态
动力学;"产出方还在不在"由 AST 守卫盯着,不靠缺省。

## 容错代价说清

原注释只承认"可能重复几条事件"。补齐:`record_trust_disclosure` 增益会重复施加、
极性会再推一次 valence(极性更新在写库段之前);写回是 append 到队尾而 drain 按 id
排序,失败期间新到的消息会让重试批**时序错乱**;失败若源于内容本身则无限重试、缓冲
只涨不消——ADR-0009 为此设计的下一档降级(连续失败转低置信 tentative)**本票没做**,
那需要跨重启的失败计数(新列 + schema 变更),是独立决定。

## AC6 补齐

原来只断言"事件标签非零",那是前提不是排序证据。新增
`test_the_valence_dimension_now_changes_the_ranking`:两条由真实产出方打上不同 valence
标签的事件进同一次真实打分,负心情下负标签那条 mood_congruence 必须更高;顺带让
`recall` 那道负性护栏真的触发一次(换正心情时同一条记忆的 mood 项明显更大)。

变异复验(每次都先核实变异真写进文件):去掉原文写回 → 4 红;产出方置零 → 8 红;
快层不再保留 energy/dominance → 1 红;去掉 per-group 跟随 → 1 红。还原后 md5 一致、
sentinel 残留 0。全量连跑两次 1471 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yushu merged commit f9512a623a into main 2026-07-30 06:16:09 +00:00
Yushu deleted branch fix/94-delta-resilience-and-valence 2026-07-30 06:16:10 +00:00
Sign in to join this conversation.
No description provided.