feat(runtime): compact long branch context safely
verify / verify (push) Has been cancelled

This commit is contained in:
2026-07-29 10:14:21 +08:00
parent b6600c1f06
commit 1ebc3b28af
10 changed files with 4883 additions and 160 deletions
+21 -8
View File
@@ -5,7 +5,8 @@
## 当前阶段 ## 当前阶段
M0 契约基线已建立;M1 状态、投影与持久化主链已经接通;M2 已具备可取消的真实模型 M0 契约基线已建立;M1 状态、投影与持久化主链已经接通;M2 已具备可取消的真实模型
回合,正在关闭 Windows 窗口与在线 LAPP 验收。 回合,以及面向长分支的上下文检查点与滚动压缩主链。Windows 原生构建基线已经建立,
真实在线 LAPP 与视觉验收仍待关闭。
目前包括: 目前包括:
@@ -17,17 +18,29 @@ M0 契约基线已建立;M1 状态、投影与持久化主链已经接通;M2
- 最小娜娜内容包; - 最小娜娜内容包;
- 原子化状态 reducer,覆盖关系、许诺、知识、持有物、时钟与判定; - 原子化状态 reducer,覆盖关系、许诺、知识、持有物、时钟与判定;
- 保持同一语义的内存与 SQLite StoryStore,支持事务追加、恢复和回溯分叉; - 保持同一语义的内存与 SQLite StoryStore,支持事务追加、恢复和回溯分叉;
- SQLite schema v1、旧库完整性校验、WAL、busy timeout 与未来版本拒绝; - SQLite schema v3,以及从 schema v1 / v2 的无损迁移、旧库完整性校验、WAL、
busy timeout 与未来版本拒绝;
- 写入与恢复时的状态哈希校验; - 写入与恢复时的状态哈希校验;
- `RuntimeState → PlayerView` 脱敏投影与隐藏信息 canary 测试; - `RuntimeState → PlayerView` 脱敏投影与隐藏信息 canary 测试;
- `TurnPlan → reducer → SQLite → PlayerView` 单轮提交链; - `TurnPlan → reducer → SQLite → PlayerView` 单轮提交链;
- 随故事节点持久化并恢复 scene、expression、pose、beats 与 suggestions - 随故事节点持久化并恢复 scene、expression、pose、beats 与 suggestions
- 确定性的场景上下文编译器,按固定顺序筛选角色卡、Persona、世界书、剧情事件与可见记忆; - 确定性的场景上下文编译器,按固定顺序筛选角色卡、Persona、世界书、剧情事件与可见记忆;
- 缓存友好的 prompt schema v3:稳定资源前缀、当前分支检查点 / 原文尾部和动态本轮
内容各自分区;模型上下文窗口、输出上限、工具续调用与安全余量统一进入预算;
- 长分支超过预算时,由当前选中的同一个 LAPP 模型通过严格的摘要工具滚动压缩;摘要
只接受当前分支的玩家安全叙事投影,始终是可删除、可重建的非权威缓存;
- 检查点通过稳定前缀与叙事来源 SHA-256 校验,只能沿当前祖先链继承;损坏、过期或
来自兄弟分支的缓存不会参与提示词;
- 新检查点先作为待提交结果保留,最终与普通节点或重生成节点在同一存储事务中提交;
取消、超时、陈旧线路或无效输出不会留下半轮节点或孤立检查点;
- 可继续的模型回复会在提交前按同一模型预算验证其“下一轮最新原文”占用;即使输出
符合静态 Schema,只要会让后续回合永久无法编排,就不会写入线路;
- 内存与 SQLite 后端均覆盖 500 节点祖先链读取和检查点查找,不再沿用界面层的
200 节点深度限制;
- 引擎掌控的类型化隐藏检定循环,模型只收到定性结果,不能指定或读取骰点、难度与精确数值; - 引擎掌控的类型化隐藏检定循环,模型只收到定性结果,不能指定或读取骰点、难度与精确数值;
- 基于 `lapp-rs` 原生工具消息的 LAPP TurnPlan provider 与严格输出校验; - 基于 `lapp-rs` 原生工具消息的 LAPP TurnPlan provider 与严格输出校验;
- 从任意当前线路历史节点创建真实持久化分支,旧线路与新线路保持隔离; - 从任意当前线路历史节点创建真实持久化分支,旧线路与新线路保持隔离;
- 列出、重命名、切换故事线路,并在关闭应用后恢复最后活动线路; - 列出、重命名、切换故事线路,并在关闭应用后恢复最后活动线路;
- SQLite schema v2 与从 wave4 schema v1 的无损迁移;
- 应用内选择 LAPP profile 中声明了聊天与工具调用能力的模型,凭据仍只由 LAPP - 应用内选择 LAPP profile 中声明了聊天与工具调用能力的模型,凭据仍只由 LAPP
Vault 即时解析; Vault 即时解析;
- 应用内测试当前已应用模型的最小连接,不接收任意模型目标,也不返回供应商正文; - 应用内测试当前已应用模型的最小连接,不接收任意模型目标,也不返回供应商正文;
@@ -48,11 +61,11 @@ LAPP provider;只有显式设置
应用内“设置”只选择 LAPP profile 已有模型,不读取、保存或回显 API Key。若 profile 应用内“设置”只选择 LAPP profile 已有模型,不读取、保存或回显 API Key。若 profile
缺失,或没有声明 `chat``tool-call` 能力的启用模型,界面会明确显示不可用。 缺失,或没有声明 `chat``tool-call` 能力的启用模型,界面会明确显示不可用。
Rust 1.96 下的核心测试、Clippy、契约生成检查、Tauri 全 target 类型检查与后端 Rust 1.96 下的 workspace 共 215 项测试通过:Contracts 1、Domain 5、Engine 21、
单元测试已经通过。当前 Linux Work 环境缺少 WebKitGTK 等桌面开发库,因此真实 Runtime 106、Store 65、Tauri 17;全 workspace Clippy、25 份契约 Schema 检查、
桌面窗口启动与 Windows 打包仍需在具备原生依赖的环境补跑。真实 Windows 开发机的 TypeScript 检查、29 项 Web 测试与生产构建均已通过。Windows 原生构建基线与复现
固定源码、工具链、桌面编译与隔离存档冒烟见 步骤见 [`docs/windows-developer-smoke.md`](docs/windows-developer-smoke.md),本轮
[`docs/windows-developer-smoke.md`](docs/windows-developer-smoke.md) 不据此宣称真实在线 LAPP 或窗口视觉验收已经完成
## 开发 ## 开发
+102 -21
View File
@@ -2,18 +2,23 @@ use std::collections::{BTreeMap, BTreeSet};
use nana_domain::{ use nana_domain::{
CharacterCard, CheckDifficulty, CheckRecord, CheckResult, ItemPlacement, ItemSpec, Persona, CharacterCard, CheckDifficulty, CheckRecord, CheckResult, ItemPlacement, ItemSpec, Persona,
ResourceBundle, RuntimeState, StateOp, TurnIntent, TurnRequest, stable_json_hash, ResourceBundle, RuntimeState, StateOp, StoryNode, TurnIntent, TurnRequest, stable_json_hash,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
BranchHistoryProjection, InvalidModelOutputKind, ProviderError, TurnControl, TurnPlan, BranchHistoryProjection, InvalidModelOutputKind, NarrativeCheckpoint, ProviderError,
TurnPlanProvider, provider_interruption, TurnContextPreparation, TurnControl, TurnPlan, TurnPlanProvider, provider_interruption,
}; };
pub const HIDDEN_CHECK_TOOL_NAME: &str = "request_hidden_check"; pub const HIDDEN_CHECK_TOOL_NAME: &str = "request_hidden_check";
pub const DEFAULT_MAX_ADJUDICATION_STEPS: usize = 4; /// V1 permits one authoritative check before the final turn plan.
///
/// Keeping this limit explicit makes the continuation budget bounded and
/// keeps one player action from silently turning into several unrelated rolls.
pub const MAX_HIDDEN_CHECKS_PER_TURN: usize = 1;
pub const DEFAULT_MAX_ADJUDICATION_STEPS: usize = MAX_HIDDEN_CHECKS_PER_TURN + 1;
/// A typed hidden-check request proposed by the narrative model. /// A typed hidden-check request proposed by the narrative model.
/// ///
@@ -153,6 +158,46 @@ pub trait AdjudicationModel {
let _ = branch_history; let _ = branch_history;
self.respond_with_control(input, control) self.respond_with_control(input, control)
} }
#[must_use]
fn uses_context_checkpoints(&self) -> bool {
false
}
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
checkpoint: Option<&NarrativeCheckpoint>,
control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
let _ = (
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint,
);
if let Some(interruption) = control.interruption() {
return Err(provider_interruption(interruption));
}
Ok(TurnContextPreparation::Unmanaged)
}
fn validate_prospective_context_with_control(
&mut self,
state: &RuntimeState,
node: &StoryNode,
control: &TurnControl,
) -> Result<(), ProviderError> {
let _ = (state, node);
if let Some(interruption) = control.interruption() {
return Err(provider_interruption(interruption));
}
Ok(())
}
} }
#[derive(Debug, Clone, PartialEq, Eq, Error)] #[derive(Debug, Clone, PartialEq, Eq, Error)]
@@ -193,6 +238,8 @@ pub enum AdjudicationError {
DuplicateItem(String), DuplicateItem(String),
#[error("check id was already used: {0}")] #[error("check id was already used: {0}")]
DuplicateCheckId(String), DuplicateCheckId(String),
#[error("one player action may request at most one hidden check")]
TooManyHiddenChecks,
#[error("model supplied a RecordCheck state operation")] #[error("model supplied a RecordCheck state operation")]
ModelSuppliedRecordCheck, ModelSuppliedRecordCheck,
#[error("regeneration requested a new hidden check")] #[error("regeneration requested a new hidden check")]
@@ -426,6 +473,11 @@ impl<Model> AdjudicatingTurnPlanProvider<Model> {
&self.model &self.model
} }
#[must_use]
pub const fn model_mut(&mut self) -> &mut Model {
&mut self.model
}
#[must_use] #[must_use]
pub fn into_model(self) -> Model { pub fn into_model(self) -> Model {
self.model self.model
@@ -507,6 +559,9 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
if !check_ids.insert(proposed.check_id.clone()) { if !check_ids.insert(proposed.check_id.clone()) {
return Err(AdjudicationError::DuplicateCheckId(proposed.check_id).into()); return Err(AdjudicationError::DuplicateCheckId(proposed.check_id).into());
} }
if records.len() >= MAX_HIDDEN_CHECKS_PER_TURN {
return Err(AdjudicationError::TooManyHiddenChecks.into());
}
if matches!(request.intent, TurnIntent::PushCheck) && !records.is_empty() { if matches!(request.intent, TurnIntent::PushCheck) && !records.is_empty() {
return Err(AdjudicationError::PushedCheckMismatch.into()); return Err(AdjudicationError::PushedCheckMismatch.into());
} }
@@ -609,6 +664,39 @@ impl<Model: AdjudicationModel> TurnPlanProvider for AdjudicatingTurnPlanProvider
}, },
}) })
} }
fn uses_context_checkpoints(&self) -> bool {
self.model.uses_context_checkpoints()
}
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
checkpoint: Option<&NarrativeCheckpoint>,
control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
self.model.prepare_turn_context_with_control(
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint,
control,
)
}
fn validate_prospective_context_with_control(
&mut self,
state: &RuntimeState,
node: &StoryNode,
control: &TurnControl,
) -> Result<(), ProviderError> {
self.model
.validate_prospective_context_with_control(state, node, control)
}
} }
fn select_bound_actor<'a, T>( fn select_bound_actor<'a, T>(
@@ -1231,7 +1319,7 @@ mod tests {
} }
#[test] #[test]
fn multiple_distinct_checks_are_buffered_until_one_final_plan() { fn a_second_distinct_check_is_rejected_for_one_player_action() {
let mut second = hidden_check("check_2"); let mut second = hidden_check("check_2");
second.actor_id = "nana".into(); second.actor_id = "nana".into();
second.skill = "Listen".into(); second.skill = "Listen".into();
@@ -1245,19 +1333,12 @@ mod tests {
]); ]);
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog()); let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog());
let planned = provider assert!(matches!(
.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()) provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
.expect("two checks then one plan"); Err(AdjudicationRunError::Rejected(
let ids = planned AdjudicationError::TooManyHiddenChecks
.delta ))
.ops ));
.iter()
.filter_map(|op| match op {
StateOp::RecordCheck { check } => Some(check.id.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(ids, ["check_1", "check_2"]);
} }
#[test] #[test]
@@ -1531,7 +1612,7 @@ mod tests {
} }
#[test] #[test]
fn loop_stops_at_the_configured_step_budget() { fn loop_stops_before_a_second_hidden_check_even_with_a_larger_step_budget() {
let mut provider = AdjudicatingTurnPlanProvider::with_max_steps( let mut provider = AdjudicatingTurnPlanProvider::with_max_steps(
ScriptedModel::new([ ScriptedModel::new([
tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check( tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check(
@@ -1542,12 +1623,12 @@ mod tests {
))), ))),
]), ]),
catalog(), catalog(),
2, 3,
); );
assert!(matches!( assert!(matches!(
provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
Err(AdjudicationRunError::Rejected( Err(AdjudicationRunError::Rejected(
AdjudicationError::StepBudgetExceeded AdjudicationError::TooManyHiddenChecks
)) ))
)); ));
} }
+258
View File
@@ -0,0 +1,258 @@
use std::str::FromStr;
use nana_store::{
CanonicalSha256, ContextCheckpointError, ContextCheckpointInput, StoredContextCheckpoint,
};
use thiserror::Error;
use crate::context::{
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NarrativeCheckpoint, NarrativeCheckpointSourceHash,
NarrativeCheckpointSummary, StablePrefixHash, SummaryClassification,
};
/// A strict runtime-to-storage mapping failure.
///
/// Loading is deliberately different: a malformed disposable cache row is a
/// cache miss, not a story/storage failure. Saving a freshly produced runtime
/// checkpoint must instead explain why it could not cross the storage
/// boundary.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CheckpointStoreMappingError {
#[error("context checkpoint summary has the wrong classification")]
InvalidSummaryClassification,
#[error("context checkpoint summary is blank")]
BlankSummary,
#[error("context checkpoint summary exceeds the runtime byte limit")]
SummaryTooLarge,
#[error("context checkpoint summary could not be serialized")]
SummarySerialization,
#[error("context checkpoint stable-prefix hash is not canonical")]
InvalidStablePrefixHash,
#[error("context checkpoint source hash is not canonical")]
InvalidSourceHash,
#[error("context checkpoint could not be represented by the store: {0}")]
InvalidStoredCheckpoint(ContextCheckpointError),
}
/// Converts a structurally loaded storage record into a runtime checkpoint.
///
/// Checkpoints are only a disposable optimization. Any malformed JSON,
/// classification, summary, or typed hash is therefore treated as a cache
/// miss. In particular, this function never turns corrupt cache contents into
/// a [`nana_store::StoreError`].
#[must_use]
pub fn runtime_checkpoint_from_stored(
stored: &StoredContextCheckpoint,
) -> Option<NarrativeCheckpoint> {
runtime_checkpoint_from_parts(StoredCheckpointParts {
story_id: stored.story_id(),
at_node_id: stored.at_node_id(),
covered_through_node_id: stored.covered_through_node_id(),
retained_from_node_id: stored.retained_from_node_id(),
checkpoint_schema_version: stored.checkpoint_schema_version(),
prompt_schema_version: stored.prompt_schema_version(),
stable_prefix_hash: stored.stable_prefix_hash().as_str(),
summary_json: stored.summary_json(),
source_hash: stored.source_hash().as_str(),
})
}
/// Converts a trusted runtime checkpoint into the store's checked cache type.
///
/// Only [`NarrativeCheckpointSummary`] is serialized into `summary_json`; range
/// metadata and hashes stay in their dedicated store columns.
pub fn stored_checkpoint_from_runtime(
checkpoint: &NarrativeCheckpoint,
) -> Result<StoredContextCheckpoint, CheckpointStoreMappingError> {
validate_summary(&checkpoint.summary)?;
let summary_json = serde_json::to_string(&checkpoint.summary)
.map_err(|_| CheckpointStoreMappingError::SummarySerialization)?;
let stable_prefix_hash = CanonicalSha256::from_str(checkpoint.stable_prefix_hash.as_str())
.map_err(|_| CheckpointStoreMappingError::InvalidStablePrefixHash)?;
let source_hash = CanonicalSha256::from_str(checkpoint.source_hash.as_str())
.map_err(|_| CheckpointStoreMappingError::InvalidSourceHash)?;
StoredContextCheckpoint::new(ContextCheckpointInput {
story_id: checkpoint.story_id.clone(),
at_node_id: checkpoint.at_node_id.clone(),
covered_through_node_id: checkpoint.covered_through_node_id.clone(),
retained_from_node_id: checkpoint.retained_from_node_id.clone(),
checkpoint_schema_version: checkpoint.checkpoint_schema_version,
prompt_schema_version: checkpoint.prompt_schema_version,
stable_prefix_hash,
summary_json,
source_hash,
})
.map_err(CheckpointStoreMappingError::InvalidStoredCheckpoint)
}
#[derive(Debug, Clone, Copy)]
struct StoredCheckpointParts<'a> {
story_id: &'a str,
at_node_id: &'a str,
covered_through_node_id: &'a str,
retained_from_node_id: Option<&'a str>,
checkpoint_schema_version: u32,
prompt_schema_version: u32,
stable_prefix_hash: &'a str,
summary_json: &'a str,
source_hash: &'a str,
}
fn runtime_checkpoint_from_parts(parts: StoredCheckpointParts<'_>) -> Option<NarrativeCheckpoint> {
let summary = serde_json::from_str::<NarrativeCheckpointSummary>(parts.summary_json).ok()?;
validate_summary(&summary).ok()?;
let stable_prefix_hash =
StablePrefixHash::try_from(parts.stable_prefix_hash.to_owned()).ok()?;
let source_hash = NarrativeCheckpointSourceHash::try_from(parts.source_hash.to_owned()).ok()?;
Some(NarrativeCheckpoint {
story_id: parts.story_id.to_owned(),
at_node_id: parts.at_node_id.to_owned(),
covered_through_node_id: parts.covered_through_node_id.to_owned(),
retained_from_node_id: parts.retained_from_node_id.map(str::to_owned),
checkpoint_schema_version: parts.checkpoint_schema_version,
prompt_schema_version: parts.prompt_schema_version,
stable_prefix_hash,
summary,
source_hash,
})
}
fn validate_summary(
summary: &NarrativeCheckpointSummary,
) -> Result<(), CheckpointStoreMappingError> {
if summary.classification != SummaryClassification::NonAuthoritativeNarrative {
return Err(CheckpointStoreMappingError::InvalidSummaryClassification);
}
if summary.text.trim().is_empty() {
return Err(CheckpointStoreMappingError::BlankSummary);
}
if summary.text.len() > MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES {
return Err(CheckpointStoreMappingError::SummaryTooLarge);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
CheckpointStoreMappingError, StoredCheckpointParts, runtime_checkpoint_from_parts,
runtime_checkpoint_from_stored, stored_checkpoint_from_runtime,
};
use crate::context::{
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
NarrativeCheckpoint, NarrativeCheckpointSourceHash, NarrativeCheckpointSummary,
SCENE_PROMPT_SCHEMA_VERSION, StablePrefixHash, SummaryClassification,
};
const HASH_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const HASH_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn runtime_checkpoint() -> NarrativeCheckpoint {
NarrativeCheckpoint {
story_id: "story_demo".to_owned(),
at_node_id: "node_004".to_owned(),
covered_through_node_id: "node_002".to_owned(),
retained_from_node_id: Some("node_003".to_owned()),
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
stable_prefix_hash: StablePrefixHash::try_from(HASH_A.to_owned())
.expect("valid stable-prefix hash"),
summary: NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: "娜娜记得玩家答应在天亮前回来。".to_owned(),
},
source_hash: NarrativeCheckpointSourceHash::try_from(HASH_B.to_owned())
.expect("valid source hash"),
}
}
fn valid_parts(summary_json: &str) -> StoredCheckpointParts<'_> {
StoredCheckpointParts {
story_id: "story_demo",
at_node_id: "node_004",
covered_through_node_id: "node_002",
retained_from_node_id: Some("node_003"),
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
stable_prefix_hash: HASH_A,
summary_json,
source_hash: HASH_B,
}
}
#[test]
fn round_trip_serializes_only_the_summary_payload() {
let runtime = runtime_checkpoint();
let stored =
stored_checkpoint_from_runtime(&runtime).expect("runtime checkpoint should map");
let expected_summary =
serde_json::to_string(&runtime.summary).expect("summary should serialize");
assert_eq!(stored.summary_json(), expected_summary);
assert!(!stored.summary_json().contains("story_demo"));
assert!(!stored.summary_json().contains("node_004"));
assert_eq!(runtime_checkpoint_from_stored(&stored), Some(runtime));
}
#[test]
fn malformed_summary_json_is_a_cache_miss() {
assert_eq!(
runtime_checkpoint_from_parts(valid_parts("{not-json")),
None
);
}
#[test]
fn wrong_summary_classification_is_a_cache_miss() {
let summary = r#"{"classification":"authoritative_fact","text":"should miss"}"#;
assert_eq!(runtime_checkpoint_from_parts(valid_parts(summary)), None);
}
#[test]
fn blank_or_oversized_summary_is_a_cache_miss() {
let blank = r#"{"classification":"non_authoritative_narrative","text":" \n\t "}"#;
assert_eq!(runtime_checkpoint_from_parts(valid_parts(blank)), None);
let oversized_text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
let oversized = serde_json::to_string(&NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: oversized_text,
})
.expect("summary should serialize");
assert_eq!(runtime_checkpoint_from_parts(valid_parts(&oversized)), None);
}
#[test]
fn invalid_typed_hashes_are_cache_misses() {
let summary =
serde_json::to_string(&runtime_checkpoint().summary).expect("summary should serialize");
let mut invalid_stable = valid_parts(&summary);
invalid_stable.stable_prefix_hash =
"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
assert_eq!(runtime_checkpoint_from_parts(invalid_stable), None);
let mut invalid_source = valid_parts(&summary);
invalid_source.source_hash = "sha256:not-a-hash";
assert_eq!(runtime_checkpoint_from_parts(invalid_source), None);
}
#[test]
fn runtime_to_store_rejects_bad_summary_before_persistence() {
let mut checkpoint = runtime_checkpoint();
checkpoint.summary.text = " \t ".to_owned();
assert_eq!(
stored_checkpoint_from_runtime(&checkpoint),
Err(CheckpointStoreMappingError::BlankSummary)
);
checkpoint.summary.text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
assert_eq!(
stored_checkpoint_from_runtime(&checkpoint),
Err(CheckpointStoreMappingError::SummaryTooLarge)
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+398 -19
View File
@@ -5,36 +5,48 @@ use nana_domain::{
TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry,
}; };
use nana_engine::{ReduceError, apply_delta}; use nana_engine::{ReduceError, apply_delta};
use nana_store::{ForkError, StoreError, StoryStore}; use nana_store::{ForkError, StoreError, StoredContextCheckpoint, StoryStore};
use thiserror::Error; use thiserror::Error;
mod adjudication; mod adjudication;
mod checkpoint_store;
mod context; mod context;
mod lapp_provider; mod lapp_provider;
mod lifecycle; mod lifecycle;
mod summary;
pub use adjudication::{ pub use adjudication::{
AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationError, AdjudicationModel, AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationError, AdjudicationModel,
AdjudicationModelInput, AdjudicationModelResponse, AdjudicationRunError, AdjudicationToolCall, AdjudicationModelInput, AdjudicationModelResponse, AdjudicationRunError, AdjudicationToolCall,
CatalogError, DEFAULT_MAX_ADJUDICATION_STEPS, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, CatalogError, DEFAULT_MAX_ADJUDICATION_STEPS, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest,
QualitativeCheckOutcome, classify_roll, deterministic_roll, MAX_HIDDEN_CHECKS_PER_TURN, QualitativeCheckOutcome, classify_roll, deterministic_roll,
};
pub use checkpoint_store::{
CheckpointStoreMappingError, runtime_checkpoint_from_stored, stored_checkpoint_from_runtime,
}; };
pub use context::{ pub use context::{
BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection, BranchContext, BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry,
BranchHistoryScene, CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard, BranchHistoryProjection, BranchHistoryScene, CharacterMemory, CheckpointDisposition,
CheckpointMissReason, CompiledSceneContext, ContextBudget, ContextCharacterCard,
ContextCheckOutcome, ContextCompileError, ContextInventoryItem, ContextJudgmentRule, ContextCheckOutcome, ContextCompileError, ContextInventoryItem, ContextJudgmentRule,
ContextPersona, ContextPlotEvent, ContextPlotOutcome, ContextPlotPressure, ContextSkill, ContextPersona, ContextPlotEvent, ContextPlotOutcome, ContextPlotPressure, ContextPreparation,
ContextStateMemory, ContextStatePosition, ContextSummary, ContextTurn, ContextWorldBookEntry, ContextPreparationError, ContextSkill, ContextStateMemory, ContextStatePosition,
HiddenCheckTreatment, NARRATIVE_CHECKPOINT_SOURCE_SCHEMA_VERSION, NarrativeCheckpointHashError, ContextSummary, ContextTurn, ContextWorldBookEntry, HIDDEN_CHECK_CONTINUATION_RESERVE_TOKENS,
NarrativeCheckpointSourceEntry, NarrativeCheckpointSourceHash, HiddenCheckTreatment, MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, MESSAGE_FRAMING_RESERVE_TOKENS,
NarrativeCheckpointSourceProjection, NarrativeSafety, PlayerMemory, ResourceProvenance, NARRATIVE_CHECKPOINT_SCHEMA_VERSION, NARRATIVE_CHECKPOINT_SOURCE_SCHEMA_VERSION,
NarrativeCheckpoint, NarrativeCheckpointHashError, NarrativeCheckpointSourceEntry,
NarrativeCheckpointSourceHash, NarrativeCheckpointSourceProjection, NarrativeCheckpointSummary,
NarrativeSafety, NeedsCompaction, PROMPT_HIGH_WATERMARK_PERCENT,
PROMPT_LOWER_WATERMARK_PERCENT, PROMPT_SAFETY_RESERVE_TOKENS, PlayerMemory, PromptBudget,
PromptBudgetError, PromptNarrativeCheckpoint, PromptReserves, ResourceProvenance,
ResourceStringTreatment, SCENE_CONTEXT_SCHEMA_VERSION, SCENE_PROMPT_SCHEMA_VERSION, ResourceStringTreatment, SCENE_CONTEXT_SCHEMA_VERSION, SCENE_PROMPT_SCHEMA_VERSION,
STABLE_PREFIX_HASH_SCHEMA_VERSION, SceneContext, SharedMemory, StablePrefixHash, STABLE_PREFIX_HASH_SCHEMA_VERSION, SYSTEM_PROMPT_RESERVE_TOKENS, SceneContext, SharedMemory,
SummaryClassification, SummaryMemory, SummaryTreatment, compile_scene_context, StablePrefixHash, SummaryClassification, SummaryMemory, SummaryTreatment,
compile_scene_context_with_budget, compile_scene_context_with_history, TOOL_SCHEMA_RESERVE_TOKENS, compile_scene_context, compile_scene_context_with_budget,
compile_scene_context_with_history_and_budget, encode_compiled_scene_context, compile_scene_context_with_history, compile_scene_context_with_history_and_budget,
encode_compiled_scene_prompt, narrative_checkpoint_source_hash, encode_compiled_scene_context, encode_compiled_scene_prompt, narrative_checkpoint_source_hash,
narrative_checkpoint_source_projection, stable_prefix_hash, narrative_checkpoint_source_projection, prepare_compiled_scene_prompt, stable_prefix_hash,
validate_latest_history_entry_fits,
}; };
pub use lapp_provider::{ pub use lapp_provider::{
CONSERVATIVE_CONTEXT_WINDOW_TOKENS, CONSERVATIVE_MAX_OUTPUT_TOKENS, ChatExecutor, CONSERVATIVE_CONTEXT_WINDOW_TOKENS, CONSERVATIVE_MAX_OUTPUT_TOKENS, ChatExecutor,
@@ -43,6 +55,10 @@ pub use lapp_provider::{
TURN_PLAN_TOOL_NAME, TURN_PLAN_TOOL_NAME,
}; };
pub use lifecycle::{TurnControl, TurnInterruption}; pub use lifecycle::{TurnControl, TurnInterruption};
pub use summary::{
ContextSummaryModel, MAX_CONTEXT_SUMMARY_BYTES, MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES,
SummaryRequest, SummaryRequestError, SummaryResult, SummaryResultError,
};
pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17"; pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17";
pub const MAX_WORLD_BOOK_ENTRIES: usize = 8; pub const MAX_WORLD_BOOK_ENTRIES: usize = 8;
@@ -96,6 +112,18 @@ pub struct TurnPlan {
pub delta: StateDelta, pub delta: StateDelta,
} }
/// Result of a provider's optional checkpoint-aware prompt preparation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnContextPreparation {
/// The provider does not use the runtime checkpoint protocol.
Unmanaged,
/// The exact prompt for this turn is staged inside the provider.
Prepared {
/// A newly generated disposable cache record for the engine to persist.
checkpoint: Option<NarrativeCheckpoint>,
},
}
/// Produces the uncommitted model plan for a turn. /// Produces the uncommitted model plan for a turn.
pub trait TurnPlanProvider { pub trait TurnPlanProvider {
fn plan_turn( fn plan_turn(
@@ -155,6 +183,57 @@ pub trait TurnPlanProvider {
let _ = branch_history; let _ = branch_history;
self.plan_turn_with_control(request, state, control) self.plan_turn_with_control(request, state, control)
} }
/// Whether this provider participates in the runtime checkpoint protocol.
#[must_use]
fn uses_context_checkpoints(&self) -> bool {
false
}
/// Prepare and stage the exact prompt used by the subsequent plan call.
///
/// The engine owns persistence and supplies only the current trusted
/// root-to-head source path. Implementations must not query a store.
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
checkpoint: Option<&NarrativeCheckpoint>,
control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
let _ = (
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint,
);
if let Some(interruption) = control.interruption() {
return Err(provider_interruption(interruption));
}
Ok(TurnContextPreparation::Unmanaged)
}
/// Reject a generated node that could not be supplied as the mandatory
/// newest raw history entry on a later turn.
///
/// Context-managed providers should use the same model budget and resource
/// compiler as normal prompt preparation. The default keeps deterministic
/// and legacy providers source-compatible.
fn validate_prospective_context_with_control(
&mut self,
state: &RuntimeState,
node: &StoryNode,
control: &TurnControl,
) -> Result<(), ProviderError> {
let _ = (state, node);
if let Some(interruption) = control.interruption() {
return Err(provider_interruption(interruption));
}
Ok(())
}
} }
impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider { impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
@@ -193,6 +272,38 @@ impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
) -> Result<TurnPlan, ProviderError> { ) -> Result<TurnPlan, ProviderError> {
(**self).plan_turn_with_history_and_control(request, state, branch_history, control) (**self).plan_turn_with_history_and_control(request, state, branch_history, control)
} }
fn uses_context_checkpoints(&self) -> bool {
(**self).uses_context_checkpoints()
}
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
checkpoint: Option<&NarrativeCheckpoint>,
control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
(**self).prepare_turn_context_with_control(
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint,
control,
)
}
fn validate_prospective_context_with_control(
&mut self,
state: &RuntimeState,
node: &StoryNode,
control: &TurnControl,
) -> Result<(), ProviderError> {
(**self).validate_prospective_context_with_control(state, node, control)
}
} }
/// Projects only already-committed state into the player-safe read model. /// Projects only already-committed state into the player-safe read model.
@@ -269,6 +380,13 @@ where
.store .store
.load_ancestor_chain(&request.story_id, &current.current_node) .load_ancestor_chain(&request.story_id, &current.current_node)
.map_err(|error| map_store_error(&error))?; .map_err(|error| map_store_error(&error))?;
let pending_checkpoint = self.prepare_provider_context(
request,
&current,
&ancestor_chain,
&current.current_node,
control,
)?;
let branch_history = BranchHistoryProjection::from_committed_nodes(ancestor_chain.iter()); let branch_history = BranchHistoryProjection::from_committed_nodes(ancestor_chain.iter());
let plan = self let plan = self
.provider .provider
@@ -292,6 +410,9 @@ where
delta: plan.delta, delta: plan.delta,
state_hash, state_hash,
}; };
self.provider
.validate_prospective_context_with_control(&committed, &node, control)
.map_err(|error| map_provider_error(&error))?;
control.begin_commit().map_err(|error| match error { control.begin_commit().map_err(|error| match error {
lifecycle::BeginCommitError::Cancelled => cancelled_turn(), lifecycle::BeginCommitError::Cancelled => cancelled_turn(),
@@ -301,7 +422,7 @@ where
} }
})?; })?;
self.store self.store
.append_node(&node, &committed) .append_node_with_checkpoint(&node, &committed, pending_checkpoint.as_ref())
.map_err(|error| map_store_error(&error))?; .map_err(|error| map_store_error(&error))?;
let mut player_view = self.projector.project_committed_turn(&committed, &node); let mut player_view = self.projector.project_committed_turn(&committed, &node);
@@ -355,6 +476,13 @@ where
// never treats arbitrary request text as an edit. // never treats arbitrary request text as an edit.
input: replaced_node.user_input.clone(), input: replaced_node.user_input.clone(),
}; };
let pending_checkpoint = self.prepare_provider_context(
&provider_request,
&regeneration_state,
&ancestor_chain,
parent_id,
control,
)?;
let plan = self let plan = self
.provider .provider
.plan_turn_with_history_and_control( .plan_turn_with_history_and_control(
@@ -400,6 +528,9 @@ where
delta: replaced_node.delta, delta: replaced_node.delta,
state_hash, state_hash,
}; };
self.provider
.validate_prospective_context_with_control(&committed, &node, control)
.map_err(|error| map_provider_error(&error))?;
control.begin_commit().map_err(|error| match error { control.begin_commit().map_err(|error| match error {
lifecycle::BeginCommitError::Cancelled => cancelled_turn(), lifecycle::BeginCommitError::Cancelled => cancelled_turn(),
@@ -409,11 +540,12 @@ where
} }
})?; })?;
self.store self.store
.append_regenerated_node( .append_regenerated_node_with_checkpoint(
&request.branch_id, &request.branch_id,
&request.expected_node_id, &request.expected_node_id,
&node, &node,
&committed, &committed,
pending_checkpoint.as_ref(),
) )
.map_err(|error| map_fork_error(&error))?; .map_err(|error| map_fork_error(&error))?;
@@ -427,6 +559,60 @@ where
}) })
} }
fn prepare_provider_context(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
control: &TurnControl,
) -> Result<Option<StoredContextCheckpoint>, TurnFailure> {
if !self.provider.uses_context_checkpoints() {
return Ok(None);
}
let stored = self
.store
.nearest_context_checkpoint(&request.story_id, expected_history_head_node_id)
.map_err(|error| map_store_error(&error))?;
let checkpoint = stored.as_ref().and_then(runtime_checkpoint_from_stored);
let preparation = self
.provider
.prepare_turn_context_with_control(
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint.as_ref(),
control,
)
.map_err(|error| map_provider_error(&error))?;
let TurnContextPreparation::Prepared { checkpoint } = preparation else {
return Err(internal_failure(
"context-managed provider did not prepare the turn",
));
};
let Some(checkpoint) = checkpoint else {
return Ok(None);
};
if let Some(interruption) = control.interruption() {
return Err(match interruption {
TurnInterruption::Cancelled => cancelled_turn(),
TurnInterruption::TimedOut => timed_out_turn(),
});
}
let stored = stored_checkpoint_from_runtime(&checkpoint)
.map_err(|_| internal_failure("context checkpoint could not be prepared"))?;
if let Some(interruption) = control.interruption() {
return Err(match interruption {
TurnInterruption::Cancelled => cancelled_turn(),
TurnInterruption::TimedOut => timed_out_turn(),
});
}
Ok(Some(stored))
}
#[must_use] #[must_use]
pub fn provider(&self) -> &Provider { pub fn provider(&self) -> &Provider {
&self.provider &self.provider
@@ -721,6 +907,7 @@ fn map_store_error(error: &StoreError) -> TurnFailure {
StoreError::NodeAlreadyExists(_) StoreError::NodeAlreadyExists(_)
| StoreError::ParentNotFound(_) | StoreError::ParentNotFound(_)
| StoreError::StateMismatch(_) | StoreError::StateMismatch(_)
| StoreError::InvalidCheckpointRange(_)
| StoreError::StateHashMismatch { .. } | StoreError::StateHashMismatch { .. }
| StoreError::Sqlite(_) | StoreError::Sqlite(_)
| StoreError::Serialization(_) | StoreError::Serialization(_)
@@ -1217,8 +1404,11 @@ mod persistent_turn_tests {
use nana_store::{InMemoryStoryStore, SqliteStoryStore, StoryStore}; use nana_store::{InMemoryStoryStore, SqliteStoryStore, StoryStore};
use super::{ use super::{
BranchHistoryProjection, ProviderError, TurnControl, TurnEngine, TurnPlan, BranchHistoryProjection, NARRATIVE_CHECKPOINT_SCHEMA_VERSION, NarrativeCheckpoint,
TurnPlanProvider, TurnProjector, branch_id_for_regeneration, hash_runtime_state, NarrativeCheckpointSourceHash, NarrativeCheckpointSummary, ProviderError,
SCENE_PROMPT_SCHEMA_VERSION, StablePrefixHash, SummaryClassification,
TurnContextPreparation, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
branch_id_for_regeneration, hash_runtime_state,
}; };
struct RecordingPlanProvider { struct RecordingPlanProvider {
@@ -1273,6 +1463,81 @@ mod persistent_turn_tests {
calls: usize, calls: usize,
} }
struct RejectingProspectiveProvider {
response: TurnPlan,
validations: usize,
}
struct CheckpointingPlanProvider {
response: TurnPlan,
control: Option<TurnControl>,
prepared: usize,
}
impl TurnPlanProvider for CheckpointingPlanProvider {
fn plan_turn(
&mut self,
_request: &TurnRequest,
_state: &RuntimeState,
) -> Result<TurnPlan, ProviderError> {
if let Some(control) = &self.control {
assert!(control.cancel());
}
Ok(self.response.clone())
}
fn uses_context_checkpoints(&self) -> bool {
true
}
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
_state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
_checkpoint: Option<&NarrativeCheckpoint>,
_control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
self.prepared += 1;
assert_eq!(
source_nodes.last().map(|node| node.id.as_str()),
Some(expected_history_head_node_id)
);
let covered = source_nodes
.first()
.expect("test checkpoint has a covered root");
let retained = source_nodes
.get(1)
.expect("test checkpoint retains the current node");
assert_eq!(retained.id, expected_history_head_node_id);
Ok(TurnContextPreparation::Prepared {
checkpoint: Some(NarrativeCheckpoint {
story_id: request.story_id.clone(),
at_node_id: retained.id.clone(),
covered_through_node_id: covered.id.clone(),
retained_from_node_id: Some(retained.id.clone()),
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
stable_prefix_hash: StablePrefixHash::try_from(
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_owned(),
)
.expect("hash"),
summary: NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: "The root scene is safely summarized.".into(),
},
source_hash: NarrativeCheckpointSourceHash::try_from(
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
.to_owned(),
)
.expect("hash"),
}),
})
}
}
impl TurnPlanProvider for CancellingPlanProvider { impl TurnPlanProvider for CancellingPlanProvider {
fn plan_turn( fn plan_turn(
&mut self, &mut self,
@@ -1285,6 +1550,28 @@ mod persistent_turn_tests {
} }
} }
impl TurnPlanProvider for RejectingProspectiveProvider {
fn plan_turn(
&mut self,
_request: &TurnRequest,
_state: &RuntimeState,
) -> Result<TurnPlan, ProviderError> {
Ok(self.response.clone())
}
fn validate_prospective_context_with_control(
&mut self,
_state: &RuntimeState,
_node: &StoryNode,
_control: &TurnControl,
) -> Result<(), ProviderError> {
self.validations += 1;
Err(ProviderError::InvalidModelOutput {
kind: super::InvalidModelOutputKind::InvalidPlan,
})
}
}
struct RecordingProjector<'store, Store> { struct RecordingProjector<'store, Store> {
store: &'store Store, store: &'store Store,
calls: usize, calls: usize,
@@ -1372,6 +1659,17 @@ mod persistent_turn_tests {
store store
} }
fn store_at_second_node() -> InMemoryStoryStore {
let store = seeded_store();
store
.append_node(
&node("node_2", Some("node_1"), "branch_main"),
&state("node_2", "branch_main"),
)
.expect("seed second node");
store
}
fn request(expected_node_id: &str) -> TurnRequest { fn request(expected_node_id: &str) -> TurnRequest {
TurnRequest { TurnRequest {
story_id: "story_1".into(), story_id: "story_1".into(),
@@ -1598,6 +1896,29 @@ mod persistent_turn_tests {
assert_eq!(committed.world_flags.get("promise_spoken"), Some(&true)); assert_eq!(committed.world_flags.get("promise_spoken"), Some(&true));
} }
#[test]
fn prospective_context_failure_rejects_the_plan_before_commit() {
let store = seeded_store();
let provider = RejectingProspectiveProvider {
response: plan("node_2", StateDelta { ops: Vec::new() }),
validations: 0,
};
let mut engine = TurnEngine::new(&store, provider, projector(&store));
let failure = engine
.submit_turn(&request("node_1"))
.expect_err("unplayable next context must fail closed");
assert_eq!(failure.code, TurnFailureCode::InvalidModelOutput);
assert_eq!(engine.provider().validations, 1);
assert_eq!(engine.projector().calls, 0);
assert_eq!(
store.branch_head("story_1", "branch_main").expect("head"),
Some("node_1".into())
);
assert!(store.load_node("story_1", "node_2").is_err());
}
#[test] #[test]
fn in_memory_regeneration_uses_parent_context_and_reuses_authoritative_state() { fn in_memory_regeneration_uses_parent_context_and_reuses_authoritative_state() {
let store = InMemoryStoryStore::new(); let store = InMemoryStoryStore::new();
@@ -1695,6 +2016,64 @@ mod persistent_turn_tests {
assert!(store.load_node("story_1", "node_2").is_err()); assert!(store.load_node("story_1", "node_2").is_err());
} }
#[test]
fn checkpoint_is_committed_atomically_with_the_final_story_node() {
let store = store_at_second_node();
let provider = CheckpointingPlanProvider {
response: plan("node_3", StateDelta { ops: Vec::new() }),
control: None,
prepared: 0,
};
let mut engine = TurnEngine::new(&store, provider, projector(&store));
engine
.submit_turn(&request("node_2"))
.expect("node and checkpoint commit");
assert_eq!(engine.provider().prepared, 1);
assert_eq!(
store
.nearest_context_checkpoint("story_1", "node_3")
.expect("checkpoint lookup")
.map(|checkpoint| checkpoint.at_node_id().to_owned()),
Some("node_2".into())
);
assert_eq!(
store.branch_head("story_1", "branch_main").expect("head"),
Some("node_3".into())
);
}
#[test]
fn cancellation_after_preparation_discards_pending_checkpoint_and_story_node() {
let store = store_at_second_node();
let control = TurnControl::new();
let provider = CheckpointingPlanProvider {
response: plan("node_3", StateDelta { ops: Vec::new() }),
control: Some(control.clone()),
prepared: 0,
};
let mut engine = TurnEngine::new(&store, provider, projector(&store));
let failure = engine
.submit_turn_with_control(&request("node_2"), &control)
.expect_err("cancelled final plan");
assert_eq!(failure.code, TurnFailureCode::Cancelled);
assert_eq!(engine.provider().prepared, 1);
assert!(
store
.nearest_context_checkpoint("story_1", "node_2")
.expect("checkpoint lookup")
.is_none()
);
assert!(store.load_node("story_1", "node_3").is_err());
assert_eq!(
store.branch_head("story_1", "branch_main").expect("head"),
Some("node_2".into())
);
}
#[test] #[test]
fn expired_deadline_does_not_call_provider_or_move_the_branch() { fn expired_deadline_does_not_call_provider_or_move_the_branch() {
let store = seeded_store(); let store = seeded_store();
+310
View File
@@ -0,0 +1,310 @@
use std::collections::BTreeSet;
use thiserror::Error;
use crate::{
NarrativeCheckpointSourceEntry, NarrativeCheckpointSummary, ProviderError,
SummaryClassification, TurnControl,
};
pub const MAX_CONTEXT_SUMMARY_BYTES: usize = 64 * 1024;
pub const MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES: usize = 512;
/// Player-safe, contiguous narrative material selected by the context planner.
///
/// It has no representation for runtime state, state deltas, hidden checks,
/// private inventory, credentials, or provider responses. Source identities
/// are retained for host validation and are not delegated to the model.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryRequest {
prior_summary: Option<NarrativeCheckpointSummary>,
prior_covered_through_node_id: Option<String>,
entries: Vec<NarrativeCheckpointSourceEntry>,
max_summary_bytes: usize,
}
impl SummaryRequest {
pub fn new(
prior_summary: Option<NarrativeCheckpointSummary>,
prior_covered_through_node_id: Option<String>,
entries: Vec<NarrativeCheckpointSourceEntry>,
max_summary_bytes: usize,
) -> Result<Self, SummaryRequestError> {
if max_summary_bytes == 0 || max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES {
return Err(SummaryRequestError::InvalidSummaryLimit);
}
if entries.is_empty() {
return Err(SummaryRequestError::EmptyEntries);
}
if entries.len() > MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES {
return Err(SummaryRequestError::TooManyEntries);
}
if prior_summary.is_some() != prior_covered_through_node_id.is_some() {
return Err(SummaryRequestError::IncompletePriorSummary);
}
if prior_summary.as_ref().is_some_and(|summary| {
summary.classification != SummaryClassification::NonAuthoritativeNarrative
}) {
return Err(SummaryRequestError::InvalidPriorClassification);
}
if prior_summary
.as_ref()
.is_some_and(|summary| summary.text.trim().is_empty())
{
return Err(SummaryRequestError::EmptyPriorSummary);
}
if prior_summary
.as_ref()
.is_some_and(|summary| summary.text.len() > MAX_CONTEXT_SUMMARY_BYTES)
{
return Err(SummaryRequestError::PriorSummaryTooLarge);
}
let mut node_ids = BTreeSet::new();
for node_id in entries.iter().map(|entry| &entry.node_id) {
if node_id.trim().is_empty() {
return Err(SummaryRequestError::EmptyNodeId);
}
if !node_ids.insert(node_id.clone()) {
return Err(SummaryRequestError::DuplicateNodeId(node_id.clone()));
}
}
if let Some(prior_covered) = prior_covered_through_node_id.as_deref() {
if prior_covered.trim().is_empty() {
return Err(SummaryRequestError::EmptyNodeId);
}
if entries[0].parent_id.as_deref() != Some(prior_covered) {
return Err(SummaryRequestError::NonContiguousEntries);
}
} else if entries[0].parent_id.is_some() {
return Err(SummaryRequestError::NonContiguousEntries);
}
if entries
.windows(2)
.any(|pair| pair[1].parent_id.as_deref() != Some(pair[0].node_id.as_str()))
{
return Err(SummaryRequestError::NonContiguousEntries);
}
Ok(Self {
prior_summary,
prior_covered_through_node_id,
entries,
max_summary_bytes,
})
}
#[must_use]
pub const fn prior_summary(&self) -> Option<&NarrativeCheckpointSummary> {
self.prior_summary.as_ref()
}
#[must_use]
pub fn prior_covered_through_node_id(&self) -> Option<&str> {
self.prior_covered_through_node_id.as_deref()
}
#[must_use]
pub fn entries(&self) -> &[NarrativeCheckpointSourceEntry] {
&self.entries
}
#[must_use]
pub const fn max_summary_bytes(&self) -> usize {
self.max_summary_bytes
}
#[must_use]
pub fn covered_through_node_id(&self) -> &str {
self.entries
.last()
.map_or("", |entry| entry.node_id.as_str())
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum SummaryRequestError {
#[error("a summary request requires at least one complete narrative entry")]
EmptyEntries,
#[error("a summary request contains too many narrative entries")]
TooManyEntries,
#[error("a summary request contains an empty source node id")]
EmptyNodeId,
#[error("a summary request repeats source node id `{0}`")]
DuplicateNodeId(String),
#[error("a summary request must provide both prior summary and covered node, or neither")]
IncompletePriorSummary,
#[error("a prior summary has an unsupported classification")]
InvalidPriorClassification,
#[error("summary source entries are not one contiguous path")]
NonContiguousEntries,
#[error("a prior narrative summary cannot be empty")]
EmptyPriorSummary,
#[error("a prior narrative summary exceeds the summary size limit")]
PriorSummaryTooLarge,
#[error("the requested narrative summary size limit is invalid")]
InvalidSummaryLimit,
}
/// The only model-authored value accepted from a summary call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryResult {
text: String,
}
impl SummaryResult {
pub fn new(text: String, max_summary_bytes: usize) -> Result<Self, SummaryResultError> {
if text.trim().is_empty() {
return Err(SummaryResultError::Empty);
}
if max_summary_bytes == 0
|| max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES
|| text.len() > max_summary_bytes
{
return Err(SummaryResultError::TooLarge);
}
Ok(Self { text })
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn into_checkpoint_summary(self) -> NarrativeCheckpointSummary {
NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: self.text,
}
}
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum SummaryResultError {
#[error("a narrative summary cannot be empty")]
Empty,
#[error("a narrative summary exceeds the summary size limit")]
TooLarge,
}
/// Provider-neutral seam for one same-model narrative compaction call.
///
/// Implementations must use only [`SummaryRequest`], observe the existing turn
/// control, and return no persistence metadata. The runtime stamps hashes,
/// schema versions, and covered ranges after validating the result.
pub trait ContextSummaryModel {
fn summarize_with_control(
&mut self,
request: &SummaryRequest,
control: &TurnControl,
) -> Result<SummaryResult, ProviderError>;
}
#[cfg(test)]
mod tests {
use nana_domain::{BeatKind, PresentationBeat, VisualDirective};
use super::{
MAX_CONTEXT_SUMMARY_BYTES, SummaryRequest, SummaryRequestError, SummaryResult,
SummaryResultError,
};
use crate::{
BranchHistoryCharacter, BranchHistoryScene, NarrativeCheckpointSourceEntry,
NarrativeCheckpointSummary, SummaryClassification,
};
fn entry(node_id: &str, parent_id: Option<&str>) -> NarrativeCheckpointSourceEntry {
NarrativeCheckpointSourceEntry {
node_id: node_id.into(),
parent_id: parent_id.map(str::to_owned),
user_input: "Wait here.".into(),
scene: BranchHistoryScene {
id: "station".into(),
title: "Station".into(),
},
character: BranchHistoryCharacter {
id: "nana".into(),
name: "Nana".into(),
expression: Some("guarded".into()),
pose: None,
},
beats: vec![PresentationBeat {
id: format!("beat_{node_id}"),
kind: BeatKind::Dialogue,
speaker: Some("Nana".into()),
text: "I will wait.".into(),
visual: Some(VisualDirective {
character: Some("nana".into()),
expression: Some("guarded".into()),
pose: None,
scene: None,
}),
}],
}
}
#[test]
fn summary_request_owns_an_exact_non_repeating_source_sequence() {
let request = SummaryRequest::new(
Some(NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: "Earlier events.".into(),
}),
Some("node_root".into()),
vec![
entry("node_1", Some("node_root")),
entry("node_2", Some("node_1")),
],
1_024,
)
.expect("valid request");
assert_eq!(request.covered_through_node_id(), "node_2");
let result = SummaryResult::new("Self-contained recap.".into(), 1_024).expect("summary");
let summary = result.into_checkpoint_summary();
assert_eq!(
summary.classification,
SummaryClassification::NonAuthoritativeNarrative
);
}
#[test]
fn summary_request_rejects_empty_duplicate_and_oversized_values() {
assert_eq!(
SummaryRequest::new(None, None, Vec::new(), 1_024),
Err(SummaryRequestError::EmptyEntries)
);
assert!(matches!(
SummaryRequest::new(
Some(NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: "Earlier.".into(),
}),
Some("node_1".into()),
vec![entry("node_2", Some("node_other"))],
1_024,
),
Err(SummaryRequestError::NonContiguousEntries)
));
assert_eq!(
SummaryResult::new(String::new(), 1_024),
Err(SummaryResultError::Empty)
);
assert_eq!(
SummaryResult::new(
"x".repeat(MAX_CONTEXT_SUMMARY_BYTES + 1),
MAX_CONTEXT_SUMMARY_BYTES,
),
Err(SummaryResultError::TooLarge)
);
assert_eq!(
SummaryRequest::new(None, None, vec![entry("node_1", None)], 0),
Err(SummaryRequestError::InvalidSummaryLimit)
);
assert_eq!(
SummaryResult::new("12345".into(), 4),
Err(SummaryResultError::TooLarge)
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
# M2 第七波:当前分支上下文检查点
日期:2026-07-29
## 本轮结果
本轮完成了长分支上下文主链:运行时按实际 LAPP 模型预算编排 prompt schema v3
超出预算时使用当前选中的同一个模型生成玩家安全的滚动摘要;摘要与来源指纹组成
非权威检查点,并在最终回合成功时与新故事节点原子提交。历史不会为了适配窗口而被
静默截断。
这套机制适用于普通继续与重生成。它不引入导演 Agent、独立摘要模型、向量记忆库或
跨分支记忆。
## SQLite schema v3
- 新增 `context_checkpoints` 缓存表,以不可变的故事节点作为 host,保存覆盖范围、
prompt / checkpoint schema 版本、稳定前缀哈希、摘要和叙事来源哈希。
- schema v1 与 v2 数据库都能迁移至 v3;迁移不会修改故事节点、线路头、物化状态或
活动线路。
- Memory 与 SQLite Store 使用同一检查点语义:只沿目标节点的 `parent_id` 祖先链
查找最近的可达检查点,兄弟分支和其他故事不可见。
- 缓存行的类型、版本、大小、规范 SHA-256、host 和覆盖范围都会被校验。损坏或未知
版本的行被当作可重建的 cache miss,并继续查找更早的有效祖先;权威祖先链损坏仍是
硬错误。
- 删除全部检查点不会改变节点、线路、状态哈希或 `PlayerView`
- 内存和 SQLite 后端均覆盖 500 节点祖先链读取与检查点查找,长线路不再受界面层
200 节点展示限制。
## 缓存友好的 prompt schema v3
模型输入固定分成三段:
```text
stable_prefix
branch_context
checkpoint?
raw_tail[]
dynamic_tail
```
- `stable_prefix` 只包含角色、Persona、剧情模块、绑定世界书的版本来源及稳定安全
规则;资源未变化时保持字节一致,便于供应商前缀缓存命中。
- `branch_context` 使用一个已验证的祖先检查点加最近原文;至少保留最新的已提交节点,
节拍文本不会被截断。
- `dynamic_tail` 保留本轮输入、实际触发的世界书条目、剧情事件、可见状态与重生成的
固定定性结果,不允许被摘要替代。
- 输入预算来自当前 LAPP 模型的 `context_window``max_output_tokens`;缺失元数据
使用显式标记的保守回退。输出、system、工具 schema、消息 framing、一次隐藏检定
续调用与安全余量都会先从窗口中扣除。
- 完整请求在发给 provider 前再次预检。动态尾部、固定部分或单个历史节点本身无法
容纳时会失败关闭,而不是删字、截断或越过模型窗口。
- V1 每次玩家行动最多进行一次隐藏检定;这使为工具调用及其续写保留的预算存在明确
上界。初始请求会明确提示该限制,检定结果返回后以及重生成回合只向模型暴露
`submit_turn_plan`,不会再提供第二次检定工具。
- 可继续的候选节点会在 `begin_commit` 前,以提交后的状态、同一资源编译器和同一
模型预算验证一次最小“下一轮继续”上下文。即使回复满足静态字段上限,只要它作为
强制保留的最新原文会让下一轮无法编排,本轮就整体失败且不移动线路头。
## 同模型滚动摘要
- 压缩复用当前 TurnPlan provider 的同一 LAPP executor、模型、调用闸门和
`TurnControl`,不会暗中切换模型或绕过供应商配置。
- 每次摘要请求都是新的单用途会话,只开放一个严格的 `submit_context_summary`
工具;正常文本、额外工具调用、未知字段、空白或超大摘要都会被拒绝。
- 首次加载超长分支时按有界块滚动摘要;后续压缩把上一个自包含摘要与新增的连续节点
一起压成新的自包含摘要,不在 prompt 中嵌套摘要链。
- 摘要来源仅包含当前根到覆盖节点路径上的实际玩家输入、公开场景、角色视觉状态和
已提交演出节拍。
- 精确关系值、骰点和难度、状态 delta、NPC 私物和私密知识、未触发世界书、未选择
建议、兄弟分支、凭据、原始供应商响应与推理过程都不进入摘要请求。
- 摘要分类由运行时固定为 `non_authoritative_narrative`;模型只能提供文本,摘要不能
作为规则触发、状态恢复或判定事实来源。
## 原子提交与中断边界
- 压缩发生在模型生成正式 TurnPlan 之前。生成的检查点先停留在内存中,不会立即写库。
- 普通回合使用 `append_node_with_checkpoint`,重生成使用
`append_regenerated_node_with_checkpoint`;节点、物化状态、线路头与待提交检查点
在同一 Store 原子操作中落库。
- 检查点必须 host 在提交前的历史头上,并与该祖先链的覆盖范围一致。陈旧线路、
错误故事、无效范围或状态不一致都会使整次写入失败,节点与检查点均不落库。
- 取消或超时可发生在摘要、正式模型调用或最终提交边界之前。只要中断先于
`begin_commit`,待提交检查点与故事节点都会被丢弃,线路头保持不变。
- 一旦 `begin_commit` 成功,取消不再与写入竞争;调用方接收该原子提交的确定结果。
- 重生成继续复用原玩家行动、权威 delta、隐藏检定与终局状态;检查点只覆盖待替换
节点的父路径,旧节点及旧分支保持不可变。
## 验证
- Rust workspace**215** 项测试通过。
- Contracts1。
- Domain5。
- Engine21。
- Runtime106。
- Store65。
- Tauri 后端:17。
- `cargo clippy --workspace --all-targets -- -D warnings`:通过。
- Web5 个测试文件 / **29** 项测试、TypeScript 严格检查与生产构建通过。
- 契约:**25** 份 JSON Schema 与 TypeScript DTO 无漂移。
测试覆盖包括预算边界、prompt schema v3 编排、同模型严格摘要工具、滚动与增量摘要、
检查点哈希和失效、兄弟分支隔离、schema v1 / v2 迁移、缓存删除、500 节点祖先链、
节点与检查点原子提交、压缩后取消不移动线路头、隐藏检定工具单次暴露,以及合法但
会阻断后续上下文的超长回复在提交前被拒绝。
## 尚未关闭
- 尚未使用用户实际 LAPP profile、Vault 凭据和在线模型完成端到端调用;本报告不宣称
在线 LAPP 冒烟通过。
- 本轮没有完成窗口截图或人工视觉检查;本报告不宣称视觉验收通过。
- Gitea SSH 推送在当前执行环境中仍未验证;本轮成果当前只能视为本地工作树状态,
不能宣称已经同步到远端。
+49 -3
View File
@@ -25,9 +25,9 @@ use nana_engine::{
}; };
use nana_runtime::{ use nana_runtime::{
AdjudicatingTurnPlanProvider, AdjudicationCatalog, BranchHistoryProjection, AdjudicatingTurnPlanProvider, AdjudicationCatalog, BranchHistoryProjection,
LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit, OpenLappChatExecutor, LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit, NarrativeCheckpoint,
ProviderError, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, OpenLappChatExecutor, ProviderError, TurnContextPreparation, TurnControl, TurnEngine, TurnPlan,
load_default_lapp_profile, TurnPlanProvider, TurnProjector, load_default_lapp_profile,
}; };
use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore}; use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore};
use openlapp::{ use openlapp::{
@@ -395,6 +395,52 @@ impl TurnPlanProvider for RuntimePlanProvider {
Self::RetiredTest => Err(ProviderError::Cancelled), Self::RetiredTest => Err(ProviderError::Cancelled),
} }
} }
fn uses_context_checkpoints(&self) -> bool {
matches!(self, Self::Lapp(provider) if provider.uses_context_checkpoints())
}
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
checkpoint: Option<&NarrativeCheckpoint>,
control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
match self {
Self::Lapp(provider) => provider.prepare_turn_context_with_control(
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint,
control,
),
Self::Demo(_) => Ok(TurnContextPreparation::Unmanaged),
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
#[cfg(test)]
Self::RetiredTest => Err(ProviderError::Cancelled),
}
}
fn validate_prospective_context_with_control(
&mut self,
state: &RuntimeState,
node: &StoryNode,
control: &TurnControl,
) -> Result<(), ProviderError> {
match self {
Self::Lapp(provider) => {
provider.validate_prospective_context_with_control(state, node, control)
}
Self::Demo(_) => Ok(()),
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
#[cfg(test)]
Self::RetiredTest => Err(ProviderError::Cancelled),
}
}
} }
impl DemoAppState { impl DemoAppState {