mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
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.
This commit is contained in:
@@ -28,7 +28,12 @@
|
||||
|
||||
召回输入按优先级解析(`resolveRecallInputController`):override → 待发送意图(send intent)→ 聊天尾部用户楼层 → 已发送用户 → 最新用户楼层。
|
||||
|
||||
**持久召回复用**(`resolveReusablePersistedRecallRecord`):如果当前输入匹配某条已持久化的用户楼层召回记录,可直接复用已存的注入内容,**跳过全部新检索**,返回 `llm.status="persisted"`。reroll / regenerate / continue 场景由宿主 `type` 判定为 no-new-user 生成后,会绑定父 user 楼层的持久召回,而不是根据 textarea / send-intent 等输入源猜测(见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量)。
|
||||
**持久召回复用有两条路径:**
|
||||
|
||||
1. **no-new-user 主路径**(`reapplyPersistedRecallBlock`):reroll / swipe / regenerate / continue 由宿主 `type` 判定为 no-new-user 后,`GENERATION_AFTER_COMMANDS` 不计算召回;`GENERATE_BEFORE_COMBINE_PROMPTS` 直接读取父 user 楼层的 `message.extra.bme_recall`,校验绑定文本未过期后确定性重放注入块。命中后不会进入 transaction / `runRecall` / 新检索。
|
||||
2. **compute fallback 内部复用**(`resolveReusablePersistedRecallRecord`):当主路径没有可用记录(例如无记录或陈旧)而落回 `runRecallController()` 时,如果当前输入匹配某条已持久化的用户楼层召回记录,可在控制器内复用已存注入内容,跳过新检索,返回 `llm.status="persisted"`。
|
||||
|
||||
fresh `normal` 发送仍走正常输入选择与召回计算路径;no-new-user 的父楼层绑定来自宿主生成上下文,而不是根据 textarea / send-intent 等输入源猜测(见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量)。
|
||||
|
||||
## 5. 向量预筛
|
||||
|
||||
|
||||
@@ -83,11 +83,18 @@
|
||||
|
||||
**reroll 不变量:**
|
||||
|
||||
> reroll 助手楼层时,若上方用户楼层未变且存在可复用的持久召回记录,则跳过预召回历史恢复和新向量检索;但被 reroll 的助手楼层的**图谱回滚必须保留**(走既有 `onReroll` 路径)。
|
||||
> 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 代际中只作为预期删除处理,不会擦掉本轮召回事务。
|
||||
设计纪律:**计算与注入解耦,信任宿主生成类型,不用输入源猜 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 已存召回注入的唯一门闸。
|
||||
|
||||
## 副本一致性模型
|
||||
|
||||
|
||||
@@ -6,14 +6,20 @@
|
||||
|
||||
## 做什么
|
||||
|
||||
每次生成前召回发生时,召回结果(选中的节点、注入文本、输入指纹)被持久化并绑定到对应的用户楼层。卡片把这个记录渲染在消息旁,用户可以看到、展开。
|
||||
每次生成前召回发生时,召回结果(选中的节点、注入文本、输入指纹)被持久化并绑定到对应的用户楼层,存放在该消息的 `message.extra.bme_recall`。卡片把这个记录渲染在消息旁,用户可以看到、展开、编辑。
|
||||
|
||||
## 为什么持久化
|
||||
|
||||
两个目的:
|
||||
|
||||
1. **透明度**:用户能看到记忆系统在每轮"想起了什么"。
|
||||
2. **reroll 复用**:reroll 助手楼层时,如果上方用户楼层没变,已持久化的召回记录可以直接复用,跳过新检索。详见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量和 [`../algorithms/retrieval.md`](../algorithms/retrieval.md) 的持久复用。
|
||||
2. **reroll 复用**:reroll 助手楼层时,如果上方用户楼层没变,before-combine 会把父 user 楼层 `message.extra.bme_recall` 中的召回块确定性重放,跳过新检索。详见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量和 [`../algorithms/retrieval.md`](../algorithms/retrieval.md) 的持久复用。
|
||||
|
||||
## 存储边界
|
||||
|
||||
召回卡片是**每个用户楼层可编辑的召回存储**,不是永久世界书条目。reroll 复用读取的是 `message.extra.bme_recall` 里的独立召回块,不会把内容写成长期 worldbook,也不会破坏性覆盖用户原文。
|
||||
|
||||
用户输入文本和召回注入块始终是两件事:用户楼层 `mes` 保留原始输入;召回内容作为单独 block 注入提示词。用户编辑召回卡片只改变该楼层的召回 artifact,不等同于改写用户说过的话。
|
||||
|
||||
## 控制器封装
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version:bump-manifest": "node scripts/bump-manifest-version.mjs",
|
||||
"build:native:wasm": "node scripts/build-native-wasm.mjs",
|
||||
"test:p0": "node tests/p0-regressions.mjs",
|
||||
"test:recall-inject-decoupling": "node tests/recall-inject-decoupling.mjs",
|
||||
"test:recall-reapply-block": "node tests/recall-reapply-block.mjs",
|
||||
"test:triviumdb-poc": "node tests/triviumdb-poc.mjs",
|
||||
"test:runtime-history": "node tests/runtime-history.mjs",
|
||||
|
||||
209
tests/recall-inject-decoupling.mjs
Normal file
209
tests/recall-inject-decoupling.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
onBeforeCombinePromptsController,
|
||||
onGenerationAfterCommandsController,
|
||||
} from "../host/event-binding.js";
|
||||
|
||||
function createRuntime(overrides = {}) {
|
||||
const calls = {
|
||||
applyFinalRecallInjectionForGeneration: 0,
|
||||
buildGenerationAfterCommandsRecallInput: 0,
|
||||
buildHistoryGenerationRecallInput: 0,
|
||||
buildNormalGenerationRecallInput: 0,
|
||||
createGenerationRecallContext: 0,
|
||||
reapplyPersistedRecallBlock: 0,
|
||||
runRecall: 0,
|
||||
};
|
||||
const runtime = {
|
||||
calls,
|
||||
applyFinalRecallInjectionForGeneration: () => {
|
||||
calls.applyFinalRecallInjectionForGeneration += 1;
|
||||
return { source: "default-final" };
|
||||
},
|
||||
buildGenerationAfterCommandsRecallInput: () => {
|
||||
calls.buildGenerationAfterCommandsRecallInput += 1;
|
||||
return { overrideUserMessage: "user floor" };
|
||||
},
|
||||
buildHistoryGenerationRecallInput: () => {
|
||||
calls.buildHistoryGenerationRecallInput += 1;
|
||||
return null;
|
||||
},
|
||||
buildNormalGenerationRecallInput: () => {
|
||||
calls.buildNormalGenerationRecallInput += 1;
|
||||
return { userMessage: "fresh normal" };
|
||||
},
|
||||
clearLiveRecallInjectionPromptForRewrite: () => {},
|
||||
clearPendingHostGenerationInputSnapshot: () => {},
|
||||
clearPendingRecallSendIntent: () => {},
|
||||
consumeDryRunPromptPreview: () => false,
|
||||
consumeHostGenerationInputSnapshot: () => null,
|
||||
createGenerationRecallContext: () => {
|
||||
calls.createGenerationRecallContext += 1;
|
||||
return {
|
||||
shouldRun: true,
|
||||
transaction: { id: "tx-default" },
|
||||
recallOptions: { userMessage: "default recall" },
|
||||
generationType: "normal",
|
||||
hookName: "GENERATE_BEFORE_COMBINE_PROMPTS",
|
||||
recallKey: "recall-key-default",
|
||||
};
|
||||
},
|
||||
getContext: () => ({
|
||||
chat: [{ is_user: true, mes: "fresh normal" }],
|
||||
chatId: "chat-inject-decoupling",
|
||||
}),
|
||||
getCurrentChatId: () => "chat-inject-decoupling",
|
||||
getGenerationContext: () => null,
|
||||
getGenerationRecallHookStateFromResult: () => "completed",
|
||||
getGenerationRecallTransactionResult: () => null,
|
||||
getPendingHostGenerationInputSnapshot: () => null,
|
||||
isMvuExtraAnalysisGuardActive: () => false,
|
||||
isTavernHelperPromptViewerRefreshActive: () => false,
|
||||
markCurrentGenerationTrivialSkip: () => {},
|
||||
markGenerationRecallTransactionHookState: () => {},
|
||||
reapplyPersistedRecallBlock: () => {
|
||||
calls.reapplyPersistedRecallBlock += 1;
|
||||
return { applied: false, reason: "default-miss" };
|
||||
},
|
||||
resolveGenerationRecallDeliveryMode: () => "deferred",
|
||||
runRecall: async () => {
|
||||
calls.runRecall += 1;
|
||||
return {
|
||||
status: "completed",
|
||||
didRecall: true,
|
||||
injectionText: "fresh injection",
|
||||
};
|
||||
},
|
||||
storeGenerationRecallTransactionResult: () => {},
|
||||
...overrides,
|
||||
};
|
||||
return runtime;
|
||||
}
|
||||
|
||||
{
|
||||
const runtime = createRuntime({
|
||||
getGenerationContext: () => ({ kind: "no-new-user", type: "regenerate" }),
|
||||
});
|
||||
|
||||
const result = await onGenerationAfterCommandsController(
|
||||
runtime,
|
||||
"regenerate",
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
skipped: true,
|
||||
reason: "no-new-user-deferred-to-before-combine",
|
||||
});
|
||||
assert.equal(runtime.calls.createGenerationRecallContext, 0);
|
||||
assert.equal(runtime.calls.runRecall, 0);
|
||||
assert.equal(runtime.calls.applyFinalRecallInjectionForGeneration, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const reapplied = {
|
||||
applied: true,
|
||||
source: "persisted",
|
||||
reason: "deterministic-reapply",
|
||||
};
|
||||
const runtime = createRuntime({
|
||||
getGenerationContext: () => ({ kind: "no-new-user", type: "regenerate" }),
|
||||
reapplyPersistedRecallBlock: () => {
|
||||
runtime.calls.reapplyPersistedRecallBlock += 1;
|
||||
return reapplied;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await onBeforeCombinePromptsController(runtime, {
|
||||
combinedPrompt: "prompt",
|
||||
});
|
||||
|
||||
assert.equal(result, reapplied);
|
||||
assert.equal(runtime.calls.reapplyPersistedRecallBlock, 1);
|
||||
assert.equal(runtime.calls.createGenerationRecallContext, 0);
|
||||
assert.equal(runtime.calls.runRecall, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const finalSentinel = { source: "fallback-final", applied: true };
|
||||
const transaction = { id: "tx-fallback" };
|
||||
const runtime = createRuntime({
|
||||
applyFinalRecallInjectionForGeneration: (payload) => {
|
||||
runtime.calls.applyFinalRecallInjectionForGeneration += 1;
|
||||
assert.equal(payload.transaction, transaction);
|
||||
assert.equal(payload.hookName, "GENERATE_BEFORE_COMBINE_PROMPTS");
|
||||
return finalSentinel;
|
||||
},
|
||||
createGenerationRecallContext: () => {
|
||||
runtime.calls.createGenerationRecallContext += 1;
|
||||
return {
|
||||
shouldRun: true,
|
||||
transaction,
|
||||
recallOptions: { userMessage: "fallback user" },
|
||||
generationType: "regenerate",
|
||||
hookName: "GENERATE_BEFORE_COMBINE_PROMPTS",
|
||||
recallKey: "recall-key-fallback",
|
||||
};
|
||||
},
|
||||
getGenerationContext: () => ({ kind: "no-new-user", type: "regenerate" }),
|
||||
reapplyPersistedRecallBlock: () => {
|
||||
runtime.calls.reapplyPersistedRecallBlock += 1;
|
||||
return { applied: false, reason: "no-record" };
|
||||
},
|
||||
runRecall: async (options) => {
|
||||
runtime.calls.runRecall += 1;
|
||||
assert.equal(options.hookName, "GENERATE_BEFORE_COMBINE_PROMPTS");
|
||||
return { status: "completed", didRecall: true, injectionText: "computed" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await onBeforeCombinePromptsController(runtime, {
|
||||
combinedPrompt: "prompt",
|
||||
});
|
||||
|
||||
assert.equal(result, finalSentinel);
|
||||
assert.equal(runtime.calls.reapplyPersistedRecallBlock, 1);
|
||||
assert.equal(runtime.calls.createGenerationRecallContext, 1);
|
||||
assert.equal(runtime.calls.runRecall, 1);
|
||||
assert.equal(runtime.calls.applyFinalRecallInjectionForGeneration, 1);
|
||||
}
|
||||
|
||||
{
|
||||
const normalSentinel = { source: "normal-final", applied: true };
|
||||
const runtime = createRuntime({
|
||||
applyFinalRecallInjectionForGeneration: () => {
|
||||
runtime.calls.applyFinalRecallInjectionForGeneration += 1;
|
||||
return normalSentinel;
|
||||
},
|
||||
createGenerationRecallContext: () => {
|
||||
runtime.calls.createGenerationRecallContext += 1;
|
||||
return {
|
||||
shouldRun: true,
|
||||
transaction: { id: "tx-normal" },
|
||||
recallOptions: { userMessage: "fresh normal" },
|
||||
generationType: "normal",
|
||||
hookName: "GENERATE_BEFORE_COMBINE_PROMPTS",
|
||||
recallKey: "recall-key-normal",
|
||||
};
|
||||
},
|
||||
getGenerationContext: () => ({ kind: "fresh", type: "normal" }),
|
||||
reapplyPersistedRecallBlock: () => {
|
||||
runtime.calls.reapplyPersistedRecallBlock += 1;
|
||||
return { applied: true, source: "should-not-run" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await onBeforeCombinePromptsController(runtime, {
|
||||
combinedPrompt: "prompt",
|
||||
});
|
||||
|
||||
assert.equal(result, normalSentinel);
|
||||
assert.equal(runtime.calls.reapplyPersistedRecallBlock, 0);
|
||||
assert.equal(runtime.calls.buildNormalGenerationRecallInput, 1);
|
||||
assert.equal(runtime.calls.createGenerationRecallContext, 1);
|
||||
assert.equal(runtime.calls.runRecall, 1);
|
||||
assert.equal(runtime.calls.applyFinalRecallInjectionForGeneration, 1);
|
||||
}
|
||||
|
||||
console.log("recall-inject-decoupling tests passed");
|
||||
Reference in New Issue
Block a user