Files
ST-Bionic-Memory-Ecology/docs/architecture/control-plane.md
youzini ea1e515694 test(recall): lock inject-decoupling reroll invariant
Add controller-level coverage for no-new-user reroll behavior:
AFTER_COMMANDS defers recall, BEFORE_COMBINE deterministically reapplies
stored per-floor recall, and misses fall back to legacy compute. Update
docs to describe compute/injection decoupling and recall cards as the
editable per-floor bme_recall source.
2026-06-01 06:56:40 +00:00

122 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 控制平面:身份、持久化、不变量
这是 ST-BME 最关键的一块。过去反复出现的 bug——"提取卡住"、"未进入聊天"、"reroll 乱召回"、"一致性审计永远说有漂移"——几乎全部源于这块的状态管理。本文档记录其结构和**必须维持的不变量**。
## 根本问题(历史背景)
早期实现里,**身份、持久化确认、加载状态、图谱可写性、向量脏标记、召回复用,全都从多个异步事件路径读写同一份模块级可变状态**`currentGraph``graphPersistenceState`、pending 标志、commit marker
没有单一事实源,也没有把"做决定"和"写入"分开。每个修复都是在这些接缝上打补丁,于是每修一个就冒出下一个。
解决方向:把控制平面抽成**纯逻辑/注入式模块**,让整类 bug 在结构上不可能发生。
## 身份解析
聊天身份是一切持久化的主键。问题从来不是"chatId 这个键选错了",而是"有好几个来源都自称是当前身份,还互相偷偷顶替"。
身份核心在 `runtime/identity-resolver.js`,把身份明确按**四类语义**区分对待,绝不互相顶替:
| 类别 | 含义 | 来源 |
| --- | --- | --- |
| **active / current identity** | 当前宿主活动聊天 | 只来自宿主上下文context integrity / hostChatId 的已知别名 / hostChatId |
| **graph-owner identity** | 图谱自带的所属身份 | 图谱 meta只用于校验/恢复 |
| **queued identity** | 排队持久化的身份 | 持久化状态,只用于校验/恢复 |
| **marker identity** | commit marker 的身份 | commit marker只用于校验/恢复 |
实现上:
- **active/current** 由独立入口 `resolveCurrentChatIdentityCore()` 解析,只认宿主上下文来源。
- **graph-owner** 由 `resolveGraphOwnerIdentityCore()` 解析。
- **queued / marker** 没有各自独立的解析入口——它们和 graph-owner 一起,在 `resolveRuntimeGraphFallbackIdentityCore()` / `resolvePersistenceChatIdCore()` 这类**恢复/兜底**聚合里被读取(含 `persistenceState.queuedPersistChatId``persistenceState.commitMarker.chatId`),并配合调用方的身份等值校验使用。
关键不在于"每类都有独立函数",而在于**只有 active/current 这条通道能产出"当前聊天",其余通道一律只进校验/恢复,不能升格为活动身份**。
**核心不变量:**
> active identity 只能来自宿主上下文。graph-owner / queued / marker 身份只能用于校验和恢复,**绝不能"偷偷"变成当前聊天**。
这正是"未进入聊天"那类 bug 的根:旧代码用一个"优先级抽奖"函数接受十几个竞争来源,结果某个非活动身份被当成了活动聊天。现在 active/current 有专门入口,恢复/兜底身份走单独的 fallback 聚合,不给"非活动身份污染活动身份"留口子。
> 身份是每次操作解析一次、显式传递的,不是从全局随用随取。
## 持久化确认状态机
持久化确认逻辑收敛在 `sync/persistence-reducer.js`,是**纯函数**:无 IO、无图谱变更、无 UI 副作用。
它把"这批记忆到底存好了没"变成关于 `(身份, 存储层 tier, 版本 revision, 证据)` 的纯计算。核心不变量:
```
已确认版本 >= 排队版本
且 同一身份
且 是规范 tiercanonicalauthority-sql / indexeddb / opfs / luker-chat-state
⟹ pendingPersist 必须为 false
```
> 实现说明:这条不变量**不是**某个单一 reducer 事件全包的。`reducePersistenceStatePatch()` 处理通用 `ACCEPTED` / `QUEUED` 事件;`buildAcceptedPersistenceStatePatch()` 在规范 tier 被接受时清 `pendingPersist`(但它本身不查排队版本/身份);"陈旧 pending 自动清除"的规划逻辑在纯函数 `planAcceptedPendingClear()``sync/legacy-persistence-repair.js`,经 reducer re-export**身份等值校验在调用方**`index.js` 接受路径)完成后才调用应用函数。所以这条不变量 = 纯规划器 + 调用方身份门禁的合成,而非单个事件转换。
**派生不变量:**
> recovery-only tier`shadow` / `metadata-full` / `runtime-recovery` 等)永远不能推进确认状态。它们只用于灾难恢复,不能被当作"数据已安全落地"的证据。
> 当 `lastAcceptedRevision >= max(batchRevision, queuedPersistRevision)` 且排队聊天与当前聊天一致时,陈旧的 `pendingPersist` 标志自动清除。
这条解决了"SQL 已确认 rev=2、但 pendingPersist 赖着不走、把提取一直卡死"的 bug。reducer 让"陈旧 pending 卡住提取"在结构上不可能发生。
历史上的语义修复Phase 2 引入不变量、Phase 5 把调用点改为显式事件)都保留在该文件头注释里。
## 图谱可写性门禁
`sync/graph-mutation-gate.js` 决定"现在能不能改图谱",避免在加载中/恢复中/未进入聊天时误写。
关键判定(注入式 impl
- `ensureGraphMutationReady` — 操作前的总门禁
- `getGraphMutationBlockReason` — 给用户的暂停原因文案
- `assertRecoveryChatStillActive` — 异步恢复过程中,校验聊天没被切走(切走则抛 abort
- `getGraphPersistenceLiveState` — 把内部状态投影成面板/调试可读形态
## 向量门禁与 reroll 代际上下文
- `vector/vector-gate.js` — 向量准备/修复前置门禁,决定 skip / repair / blocked / sync。
- `runtime/generation-context.js` — 记录宿主本轮生成的 `type``normal` / `swipe` / `regenerate` / `continue` 等),并解析本轮应绑定的父 user 楼层。
- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入;不再用一次性 marker 猜测 reroll。
**reroll 不变量:**
> reroll 助手楼层时,若上方用户楼层未变且存在可复用的持久召回记录,则复用父 user 楼层 `message.extra.bme_recall` 中的注入块;但被 reroll 的助手楼层的**图谱回滚必须保留**(走既有 `onReroll` 路径)。
换句话说:召回注入可以复用,但图谱状态该回滚还得回滚。两者不能混为一谈——这是"reroll 乱召回"修复的核心。
设计纪律:**计算与注入解耦,信任宿主生成类型,不用输入源猜 reroll**。`GENERATION_STARTED` / `GENERATION_AFTER_COMMANDS` 传入的 `type` 是权威信号:`swipe``regenerate``continue` 属于 no-new-user 生成,优先绑定上方可见 user 楼层的持久召回;`normal` 才代表新输入,需要 fresh recall。`MESSAGE_DELETED` 在 regenerate 代际中只作为预期删除处理,不会擦掉本轮召回事务。
no-new-user 的稳定路径分两段:
1. `GENERATION_AFTER_COMMANDS` 不做召回计算,直接跳过并把工作推迟到 before-combine。
2. `GENERATE_BEFORE_COMBINE_PROMPTS` 先调用 `reapplyPersistedRecallBlock`,从父 user 楼层的 `message.extra.bme_recall` 确定性重放召回块;命中后立即返回,不进入 transaction / `runRecall`。若没有记录或记录已陈旧,再落回既有 transaction + compute 兼容路径。
旧的召回事务机制仍保留为 fresh normal 和 fallback compute 的基础设施;它不再是 reroll 已存召回注入的唯一门闸。
## 副本一致性模型
Authority 场景下有三处存储,它们**不是平级的版本副本**
| 存储 | 角色 |
| --- | --- |
| Authority SQL | **规范主源**canonical primary |
| Blob checkpoint | 备份副本backup replica |
| Trivium | 搜索副本search replica |
**不变量:**
> 只有 Authority SQL 有可靠的图谱版本。当 SQL rev > Blob/Trivium rev 时,状态是"副本待同步"**不是**"数据漂移"。SQL 领先时不建议从 checkpoint 恢复(那会用旧数据覆盖新数据)。
> checkpoint 生成时,若 SQL 是主存储层,必须以 Authority SQL 快照为源SQL 导出失败/为空时checkpoint 生成失败(`authority-sql-checkpoint-source-empty`),绝不回退到可能陈旧的运行时图谱。
> 副本同步动作checkpoint 写入、Trivium/向量同步)相互独立执行,一个失败不阻塞其余。
## 依赖注入接缝
控制平面模块通过一个 `runtime` 对象拿到所有依赖,由 `index.js``create*Runtime()` builder 提供。这有个隐患:模块"期望"的 `runtime.X` 必须全部被 builder 提供,否则运行时(尤其 fallback 路径)才炸。
`tests/runtime-deps-completeness.mjs` 守住这条线。详见 [`../contributing/conventions.md`](../contributing/conventions.md) 和 [`../contributing/testing.md`](../contributing/testing.md)。