This commit is contained in:
@@ -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 或窗口视觉验收已经完成。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
))
|
))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1127
-33
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+398
-19
@@ -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, ¤t.current_node)
|
.load_ancestor_chain(&request.story_id, ¤t.current_node)
|
||||||
.map_err(|error| map_store_error(&error))?;
|
.map_err(|error| map_store_error(&error))?;
|
||||||
|
let pending_checkpoint = self.prepare_provider_context(
|
||||||
|
request,
|
||||||
|
¤t,
|
||||||
|
&ancestor_chain,
|
||||||
|
¤t.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,
|
||||||
|
®eneration_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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1563
-38
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** 项测试通过。
|
||||||
|
- Contracts:1。
|
||||||
|
- Domain:5。
|
||||||
|
- Engine:21。
|
||||||
|
- Runtime:106。
|
||||||
|
- Store:65。
|
||||||
|
- Tauri 后端:17。
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings`:通过。
|
||||||
|
- Web:5 个测试文件 / **29** 项测试、TypeScript 严格检查与生产构建通过。
|
||||||
|
- 契约:**25** 份 JSON Schema 与 TypeScript DTO 无漂移。
|
||||||
|
|
||||||
|
测试覆盖包括预算边界、prompt schema v3 编排、同模型严格摘要工具、滚动与增量摘要、
|
||||||
|
检查点哈希和失效、兄弟分支隔离、schema v1 / v2 迁移、缓存删除、500 节点祖先链、
|
||||||
|
节点与检查点原子提交、压缩后取消不移动线路头、隐藏检定工具单次暴露,以及合法但
|
||||||
|
会阻断后续上下文的超长回复在提交前被拒绝。
|
||||||
|
|
||||||
|
## 尚未关闭
|
||||||
|
|
||||||
|
- 尚未使用用户实际 LAPP profile、Vault 凭据和在线模型完成端到端调用;本报告不宣称
|
||||||
|
在线 LAPP 冒烟通过。
|
||||||
|
- 本轮没有完成窗口截图或人工视觉检查;本报告不宣称视觉验收通过。
|
||||||
|
- Gitea SSH 推送在当前执行环境中仍未验证;本轮成果当前只能视为本地工作树状态,
|
||||||
|
不能宣称已经同步到远端。
|
||||||
+49
-3
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user