Compare commits

...
4 Commits
Author SHA1 Message Date
klarkxy 1ebc3b28af feat(runtime): compact long branch context safely
verify / verify (push) Has been cancelled
2026-07-29 10:14:21 +08:00
klarkxy b6600c1f06 feat(runtime): prepare safe context checkpoints 2026-07-29 08:46:23 +08:00
klarkxy d0612b399c feat(runtime): preserve current branch context 2026-07-28 22:52:06 +08:00
klarkxy 37b3397e4a chore: establish wave 7 local baseline 2026-07-28 22:07:49 +08:00
72 changed files with 9047 additions and 233 deletions
+7
View File
@@ -0,0 +1,7 @@
* text=auto eol=lf
*.ico binary
*.png binary
*.webp binary
*.wav binary
*.mp3 binary
+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 或窗口视觉验收已经完成
## 开发 ## 开发
+42 -2
View File
@@ -35,7 +35,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if check { if check {
let mut stale = Vec::new(); let mut stale = Vec::new();
for (path, expected) in outputs { for (path, expected) in outputs {
if fs::read(&path).ok().as_deref() != Some(expected.as_slice()) { let matches = fs::read(&path).ok().is_some_and(|actual| {
normalize_line_endings(&actual) == normalize_line_endings(&expected)
});
if !matches {
stale.push(path); stale.push(path);
} }
} }
@@ -57,6 +60,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn normalize_line_endings(bytes: &[u8]) -> Vec<u8> {
let mut normalized = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
normalized.push(b'\n');
index += 2;
} else {
normalized.push(bytes[index]);
index += 1;
}
}
normalized
}
fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error::Error>> { fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error::Error>> {
let mut outputs = Vec::new(); let mut outputs = Vec::new();
let schema_dir = root.join("contracts/schema"); let schema_dir = root.join("contracts/schema");
@@ -100,7 +120,10 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
outputs.push((root.join("contracts/ts/index.ts"), ts.into_bytes())); outputs.push((root.join("contracts/ts/index.ts"), ts.into_bytes()));
let domain_source = fs::read(root.join("crates/nana-domain/src/lib.rs"))?; let domain_source = fs::read(root.join("crates/nana-domain/src/lib.rs"))?;
let source_hash = format!("{:x}\n", Sha256::digest(domain_source)); let source_hash = format!(
"{:x}\n",
Sha256::digest(normalize_line_endings(&domain_source))
);
outputs.push(( outputs.push((
root.join("contracts/.source.sha256"), root.join("contracts/.source.sha256"),
source_hash.into_bytes(), source_hash.into_bytes(),
@@ -205,3 +228,20 @@ fn add_schema<T: JsonSchema + Serialize>(
outputs.push((schema_dir.join(format!("{name}.schema.json")), bytes)); outputs.push((schema_dir.join(format!("{name}.schema.json")), bytes));
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::normalize_line_endings;
#[test]
fn source_hash_input_is_independent_of_checkout_line_endings() {
assert_eq!(
normalize_line_endings(b"first\r\nsecond\nthird\r"),
b"first\nsecond\nthird\r"
);
assert_eq!(
normalize_line_endings(b"first\nsecond\nthird\r"),
b"first\nsecond\nthird\r"
);
}
}
+216 -23
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::{
InvalidModelOutputKind, ProviderError, TurnControl, TurnPlan, TurnPlanProvider, BranchHistoryProjection, InvalidModelOutputKind, NarrativeCheckpoint, ProviderError,
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.
/// ///
@@ -109,6 +114,19 @@ pub trait AdjudicationModel {
input: AdjudicationModelInput<'_>, input: AdjudicationModelInput<'_>,
) -> Result<AdjudicationModelResponse, ProviderError>; ) -> Result<AdjudicationModelResponse, ProviderError>;
/// Respond with caller-selected safe branch history.
///
/// Existing models remain compatible and may ignore it. Narrative adapters
/// should override this instead of loading branch nodes themselves.
fn respond_with_history(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
) -> Result<AdjudicationModelResponse, ProviderError> {
let _ = branch_history;
self.respond(input)
}
/// Respond while observing the outer turn lifecycle. /// Respond while observing the outer turn lifecycle.
/// ///
/// Existing deterministic models remain source-compatible. Network-backed /// Existing deterministic models remain source-compatible. Network-backed
@@ -128,6 +146,58 @@ pub trait AdjudicationModel {
result result
} }
} }
/// History-aware controlled response. The compatibility default keeps a
/// model's existing cancellation behavior and ignores history.
fn respond_with_history_and_control(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<AdjudicationModelResponse, ProviderError> {
let _ = branch_history;
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)]
@@ -168,8 +238,12 @@ 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")]
RegenerationRequestedCheck,
#[error("adjudication exceeded its tool-step budget")] #[error("adjudication exceeded its tool-step budget")]
StepBudgetExceeded, StepBudgetExceeded,
#[error("pushed check did not match the player-authorized failed check")] #[error("pushed check did not match the player-authorized failed check")]
@@ -399,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
@@ -411,7 +490,26 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
request: &TurnRequest, request: &TurnRequest,
state: &RuntimeState, state: &RuntimeState,
) -> Result<TurnPlan, AdjudicationRunError> { ) -> Result<TurnPlan, AdjudicationRunError> {
self.plan_adjudicated_turn_with_control(request, state, &TurnControl::new()) self.plan_adjudicated_turn_with_history_and_control(
request,
state,
&BranchHistoryProjection::default(),
&TurnControl::new(),
)
}
pub fn plan_adjudicated_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, AdjudicationRunError> {
self.plan_adjudicated_turn_with_history_and_control(
request,
state,
branch_history,
&TurnControl::new(),
)
} }
/// Run the model/check/model loop without persisting any partial result. /// Run the model/check/model loop without persisting any partial result.
@@ -420,6 +518,21 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
request: &TurnRequest, request: &TurnRequest,
state: &RuntimeState, state: &RuntimeState,
control: &TurnControl, control: &TurnControl,
) -> Result<TurnPlan, AdjudicationRunError> {
self.plan_adjudicated_turn_with_history_and_control(
request,
state,
&BranchHistoryProjection::default(),
control,
)
}
pub fn plan_adjudicated_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, AdjudicationRunError> { ) -> Result<TurnPlan, AdjudicationRunError> {
let mut records = Vec::new(); let mut records = Vec::new();
let mut last_outcome = None; let mut last_outcome = None;
@@ -434,13 +547,21 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
AdjudicationModelInput::BeginTurn { request, state }, AdjudicationModelInput::BeginTurn { request, state },
AdjudicationModelInput::CheckResolved, AdjudicationModelInput::CheckResolved,
); );
let response = self.model.respond_with_control(input, control)?; let response =
self.model
.respond_with_history_and_control(input, branch_history, control)?;
let tool_call = exactly_one_tool(response)?; let tool_call = exactly_one_tool(response)?;
match tool_call { match tool_call {
AdjudicationToolCall::RequestHiddenCheck(proposed) => { AdjudicationToolCall::RequestHiddenCheck(proposed) => {
if matches!(request.intent, TurnIntent::Regenerate) {
return Err(AdjudicationError::RegenerationRequestedCheck.into());
}
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());
} }
@@ -512,6 +633,70 @@ impl<Model: AdjudicationModel> TurnPlanProvider for AdjudicatingTurnPlanProvider
}, },
}) })
} }
fn plan_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, ProviderError> {
self.plan_adjudicated_turn_with_history(request, state, branch_history)
.map_err(|error| match error {
AdjudicationRunError::Provider(error) => error,
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
kind: InvalidModelOutputKind::InvalidPlan,
},
})
}
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
self.plan_adjudicated_turn_with_history_and_control(request, state, branch_history, control)
.map_err(|error| match error {
AdjudicationRunError::Provider(error) => error,
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
kind: InvalidModelOutputKind::InvalidPlan,
},
})
}
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>(
@@ -1119,7 +1304,22 @@ mod tests {
} }
#[test] #[test]
fn multiple_distinct_checks_are_buffered_until_one_final_plan() { fn regeneration_cannot_request_a_new_hidden_check() {
let model = ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck(
hidden_check("replacement_check"),
))]);
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog());
assert!(matches!(
provider.plan_adjudicated_turn(&request(TurnIntent::Regenerate), &state()),
Err(AdjudicationRunError::Rejected(
AdjudicationError::RegenerationRequestedCheck
))
));
}
#[test]
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();
@@ -1133,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]
@@ -1419,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(
@@ -1430,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
File diff suppressed because it is too large Load Diff
+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
+189
View File
@@ -0,0 +1,189 @@
# Current-Branch Context Checkpoints
Status: design frozen for the Wave 7 implementation slice.
## Purpose
Long stories must stay inside the selected LAPP model's real context window without silently
dropping the current input, triggered world-book entries, runtime facts, or recent committed
scenes. A checkpoint is a disposable narrative cache. It is never an authoritative source for
flags, relationships, promises, inventory, knowledge, checks, or branch structure.
## Prerequisites
Checkpoint persistence starts only after these contracts are represented in code:
1. The selected LAPP model's `context_window` and `max_output_tokens` reach the Runtime provider.
2. The stable prompt prefix has a deterministic resource fingerprint.
3. The narrative source path has a canonical ordered hash that covers committed public story
content, not only `RuntimeState`.
4. `Regenerate` excludes the replaced node from narrative history, constrains the replacement with
the target node's committed post-state, creates a sibling on a new branch, and reuses existing
hidden-check outcomes.
## Prompt budget
The provider computes one explicit budget before every model request:
```text
hard input budget =
model context window
- requested output tokens
- system prompt and tool schema
- message framing overhead
- reserved hidden-check continuation overhead
- safety margin
```
- Requested output is `min(model.max_output_tokens ?? 4096, 4096)`.
- A missing context window uses a conservative 16,384-token V1 fallback, but the resulting
`BudgetSource::Assumed` diagnostic must remain visible to the connection/settings layer. The
fallback must not be silent.
- Cross-provider V1 estimation treats every serialized UTF-8 byte as at most one token. This may
compact early but must not optimistically overfill a model window.
- Compression starts at 70% of the hard input budget and compacts back below a lower watermark.
- The complete dynamic tail is budgeted first. If it does not fit by itself, the request fails with
`DynamicTailTooLarge`.
- At least one recent committed node remains verbatim. A single oversized node fails with
`HistoryEntryTooLarge`; beat text is never truncated.
- Every initial and hidden-check continuation `ChatInput` must remain within budget.
## Prompt layout
Prompt schema v3 will replace the current v2 history array with one production checkpoint-aware
encoder:
```text
prompt_schema_version
stable_prefix
branch_context
checkpoint?
raw_tail[]
dynamic_tail
```
`stable_prefix` remains byte-identical while the bound resources do not change. `dynamic_tail`
always contains the current input and the complete safe state projection. A checkpoint replaces
only a continuous oldest prefix of `raw_tail`.
## Checkpoint record
SQLite schema v3 adds a cache table keyed by the immutable host node, not by branch:
```sql
CREATE TABLE context_checkpoints (
story_id TEXT NOT NULL,
at_node_id TEXT NOT NULL,
covered_through_node_id TEXT NOT NULL,
retained_from_node_id TEXT,
checkpoint_schema_version INTEGER NOT NULL,
prompt_schema_version INTEGER NOT NULL,
stable_prefix_hash TEXT NOT NULL,
summary_json TEXT NOT NULL,
source_hash TEXT NOT NULL,
PRIMARY KEY (story_id, at_node_id),
FOREIGN KEY (story_id, at_node_id)
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE,
FOREIGN KEY (story_id, covered_through_node_id)
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE,
FOREIGN KEY (story_id, retained_from_node_id)
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE
);
```
- The nearest checkpoint is found only by walking the current node's `parent_id` chain.
- Shared ancestors naturally share a checkpoint; sibling-only descendants are unreachable.
- The summary is self-contained. A new checkpoint replaces the old summary instead of nesting a
chain of summaries in the prompt.
- Successful checkpoint generation may be persisted independently on the existing host node.
Failure of the later story-generation call may leave this harmless cache in place.
- Deleting every checkpoint must leave nodes, branch heads, materialized state, and `PlayerView`
byte-identical.
## Source and resource hashes
The model never supplies either hash.
`stable_prefix_hash` covers the exact canonical stable-prefix encoding, including character,
Persona, resource provenance, and prompt safety/version fields.
`source_hash` covers:
```text
checkpoint source schema version
prompt schema version
stable_prefix_hash
story id
ordered root-to-covered sequence of:
node id
parent id
player input
public scene
public character visual state
committed beats
```
Unselected suggestions, state delta, exact relationship values, checks, NPC private inventory,
untriggered world-book content, provider responses, credentials, and chain-of-thought never enter
the source manifest or compression request.
## Regenerate boundary
`Regenerate` is a full alternative rendering of one committed player choice:
- the replaced node is not included in the new prompt;
- model history ends at the replaced node's parent, while the target node's authoritative
post-state supplies only the already-committed facts and fixed qualitative check outcomes;
- the replacement creates a sibling on a new runtime-generated branch;
- hidden checks from the original action are supplied as qualitative fixed outcomes and are not
rolled again;
- a model cannot submit forged `RecordCheck` operations;
- old nodes, descendants, and checkpoints remain immutable and become naturally unreachable from
the new branch unless they are shared ancestors.
The sibling keeps the original player action identity, authoritative delta, terminal state, and
exact hidden-check records. Those immutable records intentionally retain their original source-node
provenance; the new branch is an alternative presentation of that same committed action, not a new
roll or a new state transition.
## Summary safety
The summary may retain public causal order, actual player choices, public NPC actions, revealed
facts with their certainty, unresolved conflicts, shared goals, and observable emotional residue.
It may not become a trigger or rules input.
The summary must not contain:
- exact dice mechanics or relationship numbers;
- private NPC knowledge, private inventory, or unrevealed item provenance;
- hidden flags, clocks, event conditions, or untriggered world-book entries;
- unselected suggestions, cancelled output, sibling-branch content, or inferred player thoughts;
- credentials, request headers, raw provider bodies, or chain-of-thought.
The current player inventory projection must not be fed to the summarizer until ownership and
acquisition fields have their own player-knowledge visibility boundary.
## Failure semantics
- No valid checkpoint and history above the high watermark returns `NeedsCompaction`; history is
never silently shortened.
- Invalid schema, source hash, stable-prefix hash, range, or summary size makes a checkpoint
unusable and rebuildable.
- Broken authoritative ancestry or state remains a hard store error and is not downgraded to a
cache miss.
- Cancelled, timed-out, rate-limited, or malformed compression produces no story node and moves no
branch head.
- A first load of a very long legacy branch uses bounded rolling chunks and keeps only the final
self-contained summary.
## Required gates
- Exact window boundary, one-token overflow, unknown-model-limit fallback, and smaller-model switch.
- Every normal and hidden-check request stays within the computed budget.
- 500-node first compaction and incremental compaction from an existing checkpoint.
- v2-to-v3 migration, rollback, restart recovery, cache deletion, and corruption rejection.
- Root, shared-ancestor, pre/post-checkpoint fork, regenerate, and sibling-canary isolation.
- Stable-prefix cache identity and one cache break only when a checkpoint rotates.
- Hidden canaries for checks, exact relationships, NPC facts/items, untriggered resources,
unselected suggestions, credentials, and provider bodies.
+59
View File
@@ -0,0 +1,59 @@
# M2 第七波本地开工状态
日期:2026-07-28
## 本地基线
- 从私有 Gitea 安全恢复 `integration/v1@23672e857b`,并通过 `git fsck` 检查对象完整性。
- 下载时使用的临时仓库只读 Token 已在 Gitea 撤销;临时配置、bundle 与内存中的凭据均
已清理。
- `origin` 已恢复为 Gitea SSH 地址,分支仍跟踪 `origin/integration/v1`。当前执行环境的
网络代理会关闭 SSH 2222 端口,因此本轮只提交到本地,不把“已配置公钥”误报为“已成功
推送”。
- 相邻 `lapp-rs` 已固定在 `5ba3c659e1536ec4bee16340faca603940a5cb17`,未修改其源码。
- 本地基线提交为 `37b3397 chore: establish wave 7 local baseline`
## 已完成
### Windows 可重复基线
- 新增 `.gitattributes`,固定文本 LF 与图片、音频二进制属性。
- Node 与 Rust 契约源码哈希在计算前统一 LF,避免 Windows CRLF checkout 产生假漂移。
- 增加 LF / CRLF 哈希等价测试。
- 安装并锁定 Node 24、pnpm 10.29.2、Rust 1.96.0、rustfmt 与 clippy。
- 使用锁文件安装 JavaScript 依赖。
### 当前分支连续上下文
- Store 新增按 `(story_id, node_id)``parent_id` 读取根到当前节点祖先链的接口。
- Memory 与 SQLite 后端均检测缺失父节点和父链循环;读取不依赖 `branch_id`,允许分叉
复用共享祖先,同时不会遍历兄弟分支。
- Runtime 在生成本轮前自动投影当前祖先链,并把已提交的玩家输入、演出节拍、场景和角色
视觉状态传给 Provider;未选择的行动建议不会被误当成历史事实。
- LAPP 普通回合与隐藏判定回合共用同一套缓存友好上下文协议:
`stable_prefix → branch_history → dynamic_tail`
- 动态状态只包含可用于角色扮演的安全投影,包括关系阶段与玩家可见持有物的归属、取得
方式;精确关系数值、隐藏骰点、NPC 私物、未触发世界书和状态 delta 仍不能进入提示。
- Tauri 的线路恢复复用 Store 祖先链接口,不再保留旧的 200 节点读取上限。
## 验证
- `node scripts/verify-contracts.mjs`25 份契约无漂移。
- `pnpm verify:web`TypeScript 检查、5 个测试文件 / 29 项测试及生产构建通过。
- `cargo metadata --no-deps`:通过,相邻 `lapp-rs` 路径依赖可解析。
- `cargo fmt --all -- --check`:通过。
- `git diff --check`:通过。
完整 Rust 测试、Clippy、Tauri 后端测试和 Windows 桌面编译尚未关闭,原因不是项目依赖
解析,而是本机缺少 Microsoft C++ Build Tools 与 Windows SDK`rustc` 当前找不到
`link.exe`
## 下一步
1. 安装 Microsoft C++ Build Tools 与 Windows SDK 后立即运行 `pnpm verify:rust`
`pnpm tauri build --no-bundle`
2. 为长分支实现非权威上下文检查点、来源哈希和 SQLite v3 迁移;历史不得静默截断。
3. 在连续上下文门禁关闭后,实现“风险预检 → 玩家确认 → 隐藏判定失败 → 推骰 / 重新
生成”纵向切片。
4. 最后在隔离存档上启动 Demo 窗口,验证重启恢复、终局、回溯和双线路隔离,再运行真实
LAPP 最小连接与隐藏判定工具调用。
@@ -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 推送在当前执行环境中仍未验证;本轮成果当前只能视为本地工作树状态,
不能宣称已经同步到远端。
@@ -0,0 +1,72 @@
# M2 第七波 Windows 基线与上下文前置状态
日期:2026-07-29
## 本机 Windows 基线
- 已安装 Visual Studio 2022 Build Tools 17.14、MSVC x64 工具链与 Windows 11 SDK
10.0.26100`cl.exe``link.exe``rc.exe` 均可用。
- `scripts/windows-smoke.ps1` 现在会通过 `vswhere` 选择具备 C++ 工具链的 Visual
Studio,并自动载入 x64 开发环境。普通 PowerShell 不再需要先手工运行
`VsDevCmd.bat`
- 使用现有应用图标生成 Tauri 的 Windows ICO、macOS ICNS、Linux PNG 及后续移动端
图标集合,关闭了 Windows 资源编译缺少 `icon.ico` 的阻塞。
- release 模式的 Windows 桌面程序已构建:
`target/release/nana-story-app.exe`
## Task 5 前置契约
### 模型预算
- `OpenLappChatExecutor` 从实际选中的 LAPP 模型读取 `context_window`
`max_output_tokens`
- 缺少或无效模型元数据时显式标为 `Assumed`,使用 16,384 / 4,096 的保守 V1 回退;
被应用上限或窗口边界收紧时标为 `Capped` 并保留原始来源。
- 预算值只能通过校验构造,始终保证输出预算大于零且小于上下文窗口;上层可读取来源、
fallback 与 cap 诊断。
- 普通回合、隐藏判定初始调用和所有工具续调用使用同一个模型预算;单回合输出上限为
4,096 tokens。
### 检查点来源
- 增加稳定前缀与叙事来源的强类型 SHA-256 指纹。
- 稳定前缀显式覆盖角色、Persona、剧情模块与绑定世界书的版本来源;持久化读取只接受
规范化的 `sha256:` 小写十六进制值。
- 叙事来源只接受连续的根到目标节点路径,覆盖节点 ID、父节点、玩家实际输入、公开场景、
角色视觉状态和完整演出节拍。
- 状态 delta、精确关系、隐藏判定、NPC 私物、未选择建议和兄弟分支均不能进入来源投影。
- [上下文检查点设计](../context-checkpoint-design.md) 已冻结预算、提示布局、SQLite v3、
失效规则和安全边界。
### 重新生成
- `Regenerate` 的叙事历史截止到待替换节点的父节点;模型使用目标节点的安全 post-state
与原判定定性结果生成兄弟节点,不再接收旧演出或骰点细节。
- 原玩家行动标识、authoritative delta、隐藏判定、终局状态与物化状态原样复用;模型
只能替换演出,也不能让成功 / 失败结果反转。
- 终局节点允许重生成演出,但普通继续行动仍会被终局保护拦截。
- 新版本使用独立分支并切为活动线路,旧节点、旧线路和后代保持不可变。
- Memory 与 SQLite 均在单次原子操作中校验来源线路、节点、delta 和物化状态。
## 验证
- Rust workspace171 项测试通过。
- Runtime83 项。
- Store44 项。
- Tauri 后端:17 项。
- Domain / Engine / Contracts27 项。
- `cargo clippy --workspace --all-targets -- -D warnings`:通过。
- Rust 契约生成器 `--check`:通过。
- Web:5 个测试文件 / 29 项测试、TypeScript 检查与生产构建通过。
- 契约:25 份 Schema 与 TypeScript DTO 无漂移。
- `pnpm tauri build --no-bundle`:通过,生成 Windows release 可执行文件。
- 隔离 Demo 已启动,窗口枚举标题为《听娜娜讲故事》,进程保持响应并创建独立 SQLite
存档。为重建 release 文件现已关闭该进程;隔离存档仍保留。自动截图组件不支持该
Tauri 窗口,因此本报告不宣称视觉验收完成。
## 尚未关闭
- 当前执行环境仍会关闭 Gitea SSH 2222 连接,本地提交暂时不能推送。
- SQLite v3 检查点表、同模型摘要工具、500 节点滚动压缩与实际预算编排仍属于 Task 5
主体。
- Demo 重启恢复、终局 / 双线路人工操作和真实 LAPP 在线调用仍待后续冒烟。
+4 -3
View File
@@ -28,9 +28,10 @@ git -C .\lapp-rs checkout 5ba3c659e1536ec4bee16340faca603940a5cb17
还需预先安装 Windows 的 Tauri 2 原生开发依赖、Microsoft C++ Build Tools、WebView2、 还需预先安装 Windows 的 Tauri 2 原生开发依赖、Microsoft C++ Build Tools、WebView2、
Git、rustup、Rust 1.96.0 MSVC host(含 `rustfmt``clippy`)、Node.js 24+,以及 Git、rustup、Rust 1.96.0 MSVC host(含 `rustfmt``clippy`)、Node.js 24+,以及
`package.json` 指定版本的 pnpm。脚本只检查它们,不会自动安装或升级工具链。下文使用 `package.json` 指定版本的 pnpm。脚本会通过 `vswhere` 自动载入 x64 C++ 开发环境并
PowerShell 7 的 `pwsh`;脚本也只使用 Windows PowerShell 5.1 支持的语法,可将 检查 `cl.exe``link.exe``rc.exe`,但不会自动安装或升级工具链。下文使用 PowerShell
`pwsh` 换成 `powershell.exe` 7 的 `pwsh`;脚本也只使用 Windows PowerShell 5.1 支持的语法,可将 `pwsh` 换成
`powershell.exe`
## 2. 跑机械门禁 ## 2. 跑机械门禁
+2 -1
View File
@@ -9,7 +9,8 @@ const schemaDirectory = resolve(root, "contracts/schema");
const source = await readFile(sourcePath); const source = await readFile(sourcePath);
const expectedHash = (await readFile(hashPath, "utf8")).trim(); const expectedHash = (await readFile(hashPath, "utf8")).trim();
const actualHash = createHash("sha256").update(source).digest("hex"); const normalizedSource = source.toString("utf8").replaceAll("\r\n", "\n");
const actualHash = createHash("sha256").update(normalizedSource).digest("hex");
if (actualHash !== expectedHash) { if (actualHash !== expectedHash) {
throw new Error( throw new Error(
+90 -2
View File
@@ -183,6 +183,91 @@ function Assert-TemporaryChildWithoutReparsePoint {
return $target return $target
} }
function Import-VisualStudioBuildEnvironment {
$originalPath = $env:Path
$programFilesX86 = [Environment]::GetFolderPath(
[Environment+SpecialFolder]::ProgramFilesX86
)
$vswherePath = Join-Path `
-Path $programFilesX86 `
-ChildPath "Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path -LiteralPath $vswherePath -PathType Leaf)) {
throw "Visual Studio Installer's vswhere.exe was not found. Install Microsoft C++ Build Tools."
}
$installationPath = (
& $vswherePath `
-latest `
-products "*" `
-requires "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" `
-property installationPath
).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) {
throw "Microsoft C++ Build Tools were not found."
}
$devCommandPath = Join-Path `
-Path $installationPath `
-ChildPath "Common7\Tools\VsDevCmd.bat"
if (-not (Test-Path -LiteralPath $devCommandPath -PathType Leaf)) {
throw "VsDevCmd.bat was not found in the selected Visual Studio installation."
}
$commandLine = 'call "' + $devCommandPath + '" -arch=x64 -host_arch=x64 >nul && set'
$environmentLines = @(& $env:ComSpec /d /c $commandLine)
if ($LASTEXITCODE -ne 0) {
throw "Visual Studio x64 developer environment initialization failed."
}
$developerPath = $null
foreach ($line in $environmentLines) {
$separator = $line.IndexOf("=")
if ($separator -le 0) {
continue
}
if ($line.StartsWith("PATH=", [System.StringComparison]::Ordinal)) {
$developerPath = $line.Substring($separator + 1)
continue
}
[Environment]::SetEnvironmentVariable(
$line.Substring(0, $separator),
$line.Substring($separator + 1),
"Process"
)
}
if ([string]::IsNullOrWhiteSpace($developerPath)) {
throw "Visual Studio did not publish a developer PATH."
}
$pathSegments = @($developerPath)
$userProfilePath = $env:USERPROFILE
if ([string]::IsNullOrWhiteSpace($userProfilePath)) {
$userProfilePath = [Environment]::GetFolderPath(
[Environment+SpecialFolder]::UserProfile
)
}
$cargoBinPath = Join-Path `
-Path $userProfilePath `
-ChildPath ".cargo\bin"
if (Test-Path -LiteralPath (Join-Path -Path $cargoBinPath -ChildPath "rustup.exe") -PathType Leaf) {
$pathSegments += $cargoBinPath
}
$pathSegments += $originalPath
$env:Path = $pathSegments -join [System.IO.Path]::PathSeparator
foreach ($requiredTool in @("cl.exe", "link.exe", "rc.exe")) {
$tool = Get-Command `
-Name $requiredTool `
-CommandType Application `
-ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -eq $tool) {
throw "$requiredTool was not found after loading Visual Studio Build Tools and the Windows SDK."
}
}
Write-Host ("[ok] Visual Studio x64 C++ Build Tools and Windows SDK from {0}" -f $installationPath)
}
if ($Demo -and -not $Launch) { if ($Demo -and -not $Launch) {
throw "-Demo is only valid together with -Launch." throw "-Demo is only valid together with -Launch."
} }
@@ -193,6 +278,8 @@ if (-not $Launch -and -not [string]::IsNullOrWhiteSpace($SmokeDataPath)) {
throw "-SmokeDataPath is only valid together with -Launch." throw "-SmokeDataPath is only valid together with -Launch."
} }
Import-VisualStudioBuildEnvironment
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath "..")) $projectRoot = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath ".."))
$packageJsonPath = Join-Path -Path $projectRoot -ChildPath "package.json" $packageJsonPath = Join-Path -Path $projectRoot -ChildPath "package.json"
$lappLockPath = Join-Path -Path $projectRoot -ChildPath "lapp-rs.lock" $lappLockPath = Join-Path -Path $projectRoot -ChildPath "lapp-rs.lock"
@@ -372,8 +459,9 @@ if (-not [string]::IsNullOrWhiteSpace($lappWorkTreeStatus)) {
} }
Write-Host ("[ok] lapp-rs matches {0}" -f $expectedLappCommit.Substring(0, 12)) Write-Host ("[ok] lapp-rs matches {0}" -f $expectedLappCommit.Substring(0, 12))
# Keep all automated gates deterministic and noninteractive. No environment # Keep all automated gates deterministic and noninteractive. Visual Studio's
# variables are enumerated or printed. # developer shell is imported into this process; environment values are never
# printed or persisted by this script.
$env:CI = "true" $env:CI = "true"
$env:NO_COLOR = "1" $env:NO_COLOR = "1"
$env:CARGO_TERM_COLOR = "never" $env:CARGO_TERM_COLOR = "never"
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+139 -34
View File
@@ -24,8 +24,9 @@ use nana_engine::{
SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view, SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
}; };
use nana_runtime::{ use nana_runtime::{
AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, LappNativeCallGate, AdjudicatingTurnPlanProvider, AdjudicationCatalog, BranchHistoryProjection,
LappNativeCallPermit, OpenLappChatExecutor, ProviderError, TurnControl, TurnEngine, TurnPlan, LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit, NarrativeCheckpoint,
OpenLappChatExecutor, ProviderError, TurnContextPreparation, TurnControl, TurnEngine, TurnPlan,
TurnPlanProvider, TurnProjector, 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};
@@ -359,6 +360,87 @@ impl TurnPlanProvider for RuntimePlanProvider {
Self::RetiredTest => Err(ProviderError::Cancelled), Self::RetiredTest => Err(ProviderError::Cancelled),
} }
} }
fn plan_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, ProviderError> {
match self {
Self::Demo(provider) => provider.plan_turn_with_history(request, state, branch_history),
Self::Lapp(provider) => provider.plan_turn_with_history(request, state, branch_history),
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
#[cfg(test)]
Self::RetiredTest => Err(ProviderError::Cancelled),
}
}
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
match self {
Self::Demo(provider) => {
provider.plan_turn_with_history_and_control(request, state, branch_history, control)
}
Self::Lapp(provider) => {
provider.plan_turn_with_history_and_control(request, state, branch_history, control)
}
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
#[cfg(test)]
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 {
@@ -479,6 +561,7 @@ impl DemoAppState {
.get("nana.ending.returned_before_dawn") .get("nana.ending.returned_before_dawn")
.copied() .copied()
.unwrap_or(false) .unwrap_or(false)
&& request.intent != TurnIntent::Regenerate
{ {
return Err(CommandError::invalid_input( return Err(CommandError::invalid_input(
"这一夜的故事已经结束;可从回溯中选择另一条线路。", "这一夜的故事已经结束;可从回溯中选择另一条线路。",
@@ -905,22 +988,8 @@ impl DemoAppState {
} }
fn load_lineage(&self, current: &StoryNode) -> Result<Vec<StoryNode>, StoreError> { fn load_lineage(&self, current: &StoryNode) -> Result<Vec<StoryNode>, StoreError> {
let mut lineage = vec![current.clone()]; self.store
let mut cursor = current.parent_id.clone(); .load_ancestor_chain(&current.story_id, &current.id)
while let Some(ref node_id) = cursor {
if lineage.len() >= 200 {
return Err(StoreError::StateMismatch(
"story lineage exceeds the projection limit",
));
}
let node = self.store.load_node(&current.story_id, node_id)?;
cursor.clone_from(&node.parent_id);
lineage.push(node);
}
lineage.reverse();
Ok(lineage)
} }
} }
@@ -943,7 +1012,16 @@ impl TurnPlanProvider for DemoPlanProvider {
) )
}); });
let (beats, delta, suggestions, can_continue) = if accepted_promise { let (beats, delta, suggestions, can_continue) = if request.intent == TurnIntent::Regenerate
{
let (beats, delta) = regular_turn(request);
let ending = state
.world_flags
.get("nana.ending.returned_before_dawn")
.copied()
.unwrap_or(false);
(beats, delta, Vec::new(), !ending)
} else if accepted_promise {
let (beats, delta) = promise_turn(request, &committed_node_id); let (beats, delta) = promise_turn(request, &committed_node_id);
(beats, delta, investigation_suggestions(), true) (beats, delta, investigation_suggestions(), true)
} else if state } else if state
@@ -2166,6 +2244,47 @@ mod tests {
} }
} }
fn assert_ending_can_be_regenerated(app: &DemoAppState, ending_node_id: &str) {
let rejected = app
.submit_turn(&continue_request(
DEMO_BRANCH_ID,
ending_node_id,
"action_after_ending",
))
.expect_err("ending is terminal");
assert_eq!(rejected.code, "invalid_input");
assert_eq!(
app.store
.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
.expect("unchanged ending")
.current_node,
ending_node_id
);
let mut regenerate =
continue_request(DEMO_BRANCH_ID, ending_node_id, "action_regenerate_ending");
regenerate.intent = TurnIntent::Regenerate;
regenerate.input = "this request text must not replace the original action".to_owned();
let regenerated = app
.submit_turn(&regenerate)
.expect("ending presentation can be regenerated");
assert_ne!(regenerated.player_view.branch_id, DEMO_BRANCH_ID);
assert!(!regenerated.player_view.can_continue);
assert_eq!(
app.store
.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
.expect("original ending branch retained")
.current_node,
ending_node_id
);
let regenerated_node = app
.store
.load_node(DEMO_STORY_ID, &regenerated.committed_node_id)
.expect("regenerated ending node");
assert_eq!(regenerated_node.action_id, "action_return_before_dawn");
assert!(regenerated_node.user_input.is_empty());
}
fn node(id: &str, parent_id: Option<&str>, ops: Vec<StateOp>) -> StoryNode { fn node(id: &str, parent_id: Option<&str>, ops: Vec<StateOp>) -> StoryNode {
StoryNode { StoryNode {
id: id.to_owned(), id: id.to_owned(),
@@ -2290,21 +2409,7 @@ mod tests {
.get("nana.ending.returned_before_dawn"), .get("nana.ending.returned_before_dawn"),
Some(&true) Some(&true)
); );
let rejected = app assert_ending_can_be_regenerated(&app, &returned.committed_node_id);
.submit_turn(&continue_request(
DEMO_BRANCH_ID,
&returned.committed_node_id,
"action_after_ending",
))
.expect_err("ending is terminal");
assert_eq!(rejected.code, "invalid_input");
assert_eq!(
app.store
.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
.expect("unchanged ending")
.current_node,
returned.committed_node_id
);
} }
#[test] #[test]
+286
View File
@@ -0,0 +1,286 @@
# Implementation Plan: Wave 7 本地 Windows 闭环
## Overview
`integration/v1@23672e857bfa854930b0c8ab8aca316a95ba5d09` 继续开发。先关闭真实
Windows 基线暴露出的可重复性问题,再补齐真实模型的当前分支连续上下文与超预算检查点,
随后实现“风险预检 → 玩家确认 → 隐藏检定失败 → 可推骰 / 可重新生成”的纵向切片。
每个阶段都必须保持回合原子提交、分支隔离和 `PlayerView` 脱敏边界。
## Architecture Decisions
- 继续使用 `integration/v1`,不在本地另起一套重建工程。
- `lapp-rs` 保持相邻 path dependency,并固定到 `5ba3c659…`
- 契约源码哈希统一按 LF 规范化;同时用 `.gitattributes` 固定文本文件换行,避免
Windows `core.autocrlf=true` 产生假漂移。
- 模型上下文固定为“系统约束 → 稳定资源 → 当前分支检查点 / 原始历史 → 本轮动态尾部”,
提升前缀缓存命中;兄弟分支内容不得进入上下文。
- 压缩检查点是可丢弃缓存,不是事实来源;删除检查点不得改变 `RuntimeState`
- 风险提示是“不落节点的预检结果”,只有玩家确认后才进入现有隐藏判定与原子提交链。
- `Regenerate` 复用原行动的判定记录;`PushCheck` 是带明确后果的新行动和新节点。
- 在重新生成 / 推骰语义闭环前,不扩大到内容包导入、TTS、生图或第二完整角色。
## Dependency Graph
```text
Windows 可重复契约门禁
└─ 精确 Node/pnpm + Rust/MSVC 工具链
└─ Wave 6 全量基线
└─ 当前分支连续上下文
└─ 超预算检查点压缩
└─ 风险预检契约
├─ Runtime / Store 语义
└─ Tauri 窄命令
└─ Vue 确认、推骰、重新生成界面
└─ Windows Demo + LAPP 冒烟
```
## Task 1: 修复跨平台契约哈希
**Description:** 消除 Windows CRLF checkout 对契约源码哈希的影响,同时固定未来 checkout
的文本换行。
**Acceptance criteria:**
- [x] 干净 Windows checkout 上 `node scripts/verify-contracts.mjs` 通过。
- [x] Rust 生成器对 LF 与 CRLF 输入计算相同源码哈希。
- [x] Linux 既有 `.source.sha256` 不发生无意义变化。
**Verification:**
- [x] `node scripts/verify-contracts.mjs`
- [x] `cargo test -p nana-contracts`
- [x] `cargo run -p nana-contracts -- --check`
- [x] `git diff --check`
**Dependencies:** None
**Files likely touched:**
- `.gitattributes`
- `scripts/verify-contracts.mjs`
- `crates/nana-contracts/src/main.rs`
**Estimated scope:** Medium
## Task 2: 恢复锁定的本地工具链
**Description:** 让 Windows 开发机满足仓库已声明的精确依赖,不修改项目依赖版本。
**Acceptance criteria:**
- [x] Node.js 24+ 与 pnpm 10.29.2 可用。
- [x] Rust 1.96.0、rustfmt、clippy、MSVC Build Tools、Windows SDK 可用。
- [x] 相邻 `lapp-rs` 位于固定提交且工作树干净。
**Verification:**
- [x] `node --version`
- [x] `pnpm --version`
- [x] `rustc --version`
- [x] `cargo clippy --version`
- [x] `git -C ..\lapp-rs rev-parse HEAD`
**Dependencies:** Task 1 可并行
**Files likely touched:** None(仅本机工具链与依赖目录)
**Estimated scope:** Medium
## Task 3: 关闭 Wave 6 Windows 机械门禁
**Description:** 在真实 Windows 目标上重新验证既有 119 项核心 Rust、18 项 Tauri、
29 项 Web 测试和无 bundle 桌面编译。
**Acceptance criteria:**
- [x] 依赖安装使用锁文件且不修改锁文件。
- [x] `pnpm verify` 等价的 Web / Rust / 契约门禁全绿。
- [x] `pnpm tauri build --no-bundle` 成功。
**Verification:**
- [ ] `powershell.exe -NoProfile -File .\scripts\windows-smoke.ps1 -InstallDependencies`
**Dependencies:** Tasks 1-2
**Files likely touched:** None
**Estimated scope:** Small
## Checkpoint: Windows 基线
- [x] 工作树只包含已审阅的 Wave 7 变更。
- [x] 契约、Web、Rust、Tauri 门禁均可重复。
- [x] 记录首个无法自动关闭的环境阻塞。
## Task 4: 恢复当前分支连续上下文
**Description:** Store 提供按 `parent_id` 追溯的祖先链;Runtime 将稳定资源、当前分支
历史和本轮输入按固定顺序编译给 LAPP,解决真实模型第二轮起失忆的问题。
**Acceptance criteria:**
- [x] 第三轮模型输入包含根到当前节点的前两轮玩家输入和演出节拍。
- [x] 分叉后只包含本分支祖先,兄弟分支文本和隐藏状态不泄漏。
- [x] 系统约束、角色卡和 Persona 在连续回合中保持逐字节稳定,本轮输入位于尾部。
- [x] 精确骰点、目标值、状态 delta、NPC 隐藏物品和未触发世界书不进入上下文。
**Verification:**
- [x] Store 祖先链测试覆盖分叉共享祖先。
- [x] Runtime 捕获模型输入的三轮与兄弟分支隔离测试。
- [x] 既有 PlayerView / 上下文泄密 canary 通过。
**Dependencies:** Checkpoint: Windows 基线
**Files likely touched:**
- `crates/nana-store/src/lib.rs`
- `crates/nana-runtime/src/context.rs`
- `crates/nana-runtime/src/lib.rs`
- `crates/nana-runtime/src/lapp_provider.rs`
- `src-tauri/src/lib.rs`
**Estimated scope:** Medium
## Task 5: 增加超预算分支检查点
**Description:** 根据模型上下文窗口预算保留“最近祖先检查点 + 检查点后原始节点 + 本轮
输入”;超预算时使用同一 LAPP 模型压缩旧段,并把检查点作为当前节点的非权威缓存。
**Acceptance criteria:**
- [ ] 500 节点分支恢复不扫描无关分支并保持在输入预算内。
- [ ] 检查点只继承自当前祖先链,来源哈希不符时拒绝使用。
- [ ] 删除检查点后 `RuntimeState`、分支头和 `PlayerView` 不变。
- [ ] 压缩取消、超时或畸形输出不创建节点、不移动分支头。
**Verification:**
- [ ] SQLite v2 → v3 无损迁移测试。
- [ ] 预算边界、检查点继承、来源哈希和删除重建测试。
- [ ] 压缩调用复用既有取消控制与原生单飞闸门。
**Dependencies:** Task 4
**Files likely touched:**
- `crates/nana-domain/src/lib.rs`
- `crates/nana-store/src/lib.rs`
- `crates/nana-runtime/src/context.rs`
- `crates/nana-runtime/src/lapp_provider.rs`
- `src-tauri/src/lib.rs`
**Estimated scope:** MediumCore 与 Runtime 可在内部契约冻结后并行)
## Checkpoint: 连续上下文
- [ ] 三轮真实上下文连续。
- [ ] 兄弟分支隔离。
- [ ] 稳定前缀可缓存。
- [ ] 500 节点与超预算检查点测试通过。
## Task 6: 增加不落节点的风险预检契约
**Description:** 高风险玩家行动先返回脱敏风险描述和确认令牌,不调用隐藏判定、不推进
状态、不创建故事节点。
**Acceptance criteria:**
- [ ] 普通行动仍沿用现有单次提交路径。
- [ ] 高风险行动首次提交只返回玩家可理解的风险提示。
- [ ] 预检结果不包含骰点、目标值、NPC 隐藏信息或精确关系数值。
**Verification:**
- [ ] Domain / Runtime 契约测试。
- [ ] Tauri 命令测试证明预检不写 SQLite。
- [ ] PlayerView 泄密 canary 继续通过。
**Dependencies:** Checkpoint: 连续上下文
**Files likely touched:**
- `crates/nana-domain/src/lib.rs`
- `crates/nana-runtime/src/`
- `src-tauri/src/lib.rs`
- `contracts/`
**Estimated scope:** Medium
## Task 7: 完成确认、推骰与重新生成纵切
**Description:** 把现有 `Regenerate` / `PushCheck` 领域语义接到 Tauri 与 Vue,让玩家在失败
后选择承担结果、推骰或重新生成叙述。
**Acceptance criteria:**
- [ ] 确认风险后只产生一个完整节点。
- [ ] `Regenerate` 复用原判定,不允许刷骰。
- [ ] `PushCheck` 明示后果并创建新行动、新判定和新节点。
- [ ] 回溯后两条线路的判定、物品、许诺和关系互不污染。
**Verification:**
- [ ] Runtime / Store 端到端测试。
- [ ] Tauri 命令测试。
- [ ] Vue 交互测试覆盖确认、取消、推骰、重新生成。
- [ ] 完整 `pnpm verify`
**Dependencies:** Task 6
**Files likely touched:**
- `crates/nana-runtime/src/adjudication.rs`
- `crates/nana-store/src/lib.rs`
- `src-tauri/src/lib.rs`
- `src/app/`
- `src/App.vue`
**Estimated scope:** Medium(按契约、后端、前端三个小提交交付)
## Checkpoint: 可玩纵切
- [ ] 风险提示先于隐藏检定。
- [ ] 失败、推骰、重新生成的语义可被玩家区分。
- [ ] 取消、超时和畸形响应仍不产生半轮。
- [ ] Demo 与真实 LAPP 各完成一次隐藏判定。
## Task 8: Windows 桌面冒烟与交付
**Description:** 启动隔离存档的 Demo 窗口,验证两次重启、终局、回溯与双线路隔离;随后
执行真实 LAPP 最小连接及隐藏判定。
**Acceptance criteria:**
- [ ] Demo 窗口启动并正常退出。
- [ ] 两次重启恢复、终局和双线路隔离通过。
- [ ] 真实 LAPP 连接测试及一轮工具调用通过,日志无凭据。
**Verification:**
- [ ] `scripts/windows-smoke.ps1 -Launch -Demo`
- [ ] `scripts/windows-smoke.ps1 -Launch`
**Dependencies:** Task 7
**Files likely touched:** 仅状态报告;若冒烟发现缺陷则另建小任务。
**Estimated scope:** Medium
## Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Gitea SSH 2222 被本机代理拦截 | 无法自动 fetch/push | 本地开发不依赖 fetch;提交前使用用户终端 SSH 或短期 HTTPS 凭据 |
| Rust/MSVC 未安装 | 无法关闭 Tauri 门禁 | 先完成纯文本跨平台修复;工具链作为显式环境任务 |
| 精确 pnpm 下载不稳定 | Web 验证延迟 | 保留锁文件;优先使用 Corepack 官方包并重试,不升级项目版本 |
| 祖先链错误按 branch_id 过滤 | 分叉后丢失共享历史 | 只按 parent_id 追溯并做兄弟分支隔离测试 |
| 压缩摘要被误当事实 | 删除缓存后剧情状态变化 | 检查点保持非权威,状态仍只由根状态 + delta 重建 |
| 风险预检变成第二套状态机 | 原子性回归 | 预检只产生短期确认数据,唯一 reducer / SQLite 提交路径不变 |
| 重新生成可刷骰 | 破坏公平性 | 判定绑定原 action/check;只有 PushCheck 创建新判定 |
## Open Questions
- 无产品阻塞问题。Rust/MSVC 安装若需要管理员交互,将在 Task 2 单独请求用户操作。
+62
View File
@@ -0,0 +1,62 @@
# Wave 7 Checklist
## 本地恢复
- [x] 从私有 Gitea 恢复 `integration/v1@23672e857b`
- [x] 确认 Git 对象完整且工作树干净。
- [x] 临时只读 Token 已撤销,认证临时文件与 bundle 已删除。
- [x] 克隆相邻 `lapp-rs` 并固定到 `5ba3c659…`
- [ ] 恢复可 fetch/push 的远端认证路径。
## Task 1:跨平台契约门禁
- [x] 添加文本换行约束。
- [x] Node 契约检查按 LF 规范化源码。
- [x] Rust 契约生成器按 LF 规范化源码。
- [x] 增加 LF / CRLF 等价测试。
- [x] Windows 上运行 Node 契约检查。
- [x] Rust 生成器检查与测试。
## Task 2:本机工具链
- [x] Node.js 24+。
- [x] WebView2。
- [x] pnpm 10.29.2。
- [x] Rust 1.96.0、rustfmt、clippy。
- [x] Microsoft C++ Build Tools 与 Windows SDK。
- [x] 安装锁定的 JavaScript 依赖。
## Task 3Wave 6 门禁
- [x] `pnpm verify:web`(25 份契约、29 项 Web 测试及生产构建通过)
- [x] `pnpm verify:rust` 等价门禁(171 项 Rust 测试与严格 Clippy 通过)
- [x] `pnpm tauri build --no-bundle`
## Task 4-5:连续上下文与检查点
- [x] Store 按 parent_id 读取当前祖先链。
- [x] 第三轮包含前两轮原始剧情。
- [x] 兄弟分支上下文隔离。
- [x] 稳定前缀与动态尾部固定编排。
- [x] LAPP 模型预算与缺省来源贯通。
- [x] 稳定前缀与叙事来源哈希。
- [x] `Regenerate` 从父节点创建兄弟分支并复用原判定 / 状态。
- [ ] 超预算检查点与来源哈希。
- [ ] SQLite v3 迁移与 500 节点测试。
## Task 6-7:风险 / 判定纵切
- [ ] 风险预检不落节点。
- [ ] 玩家确认后进入隐藏判定。
- [ ] 失败后提供推骰入口。
- [x] Runtime / Store 重新生成复用原判定。
- [ ] 推骰创建新行动与新判定。
- [ ] Vue / Tauri / Runtime / Store 测试全绿。
## Task 8:桌面冒烟
- [x] 隔离 Demo 窗口启动并创建存档。
- [ ] 重启恢复。
- [ ] 终局与双线路隔离。
- [ ] 真实 LAPP 连接。
- [ ] 真实隐藏判定工具调用。