Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ebc3b28af | ||
|
|
b6600c1f06 | ||
|
|
d0612b399c | ||
|
|
37b3397e4a |
@@ -0,0 +1,7 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.ico binary
|
||||||
|
*.png binary
|
||||||
|
*.webp binary
|
||||||
|
*.wav binary
|
||||||
|
*.mp3 binary
|
||||||
@@ -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 或窗口视觉验收已经完成。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
))
|
))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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** 项测试通过。
|
||||||
|
- 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 推送在当前执行环境中仍未验证;本轮成果当前只能视为本地工作树状态,
|
||||||
|
不能宣称已经同步到远端。
|
||||||
@@ -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 workspace:171 项测试通过。
|
||||||
|
- Runtime:83 项。
|
||||||
|
- Store:44 项。
|
||||||
|
- Tauri 后端:17 项。
|
||||||
|
- Domain / Engine / Contracts:27 项。
|
||||||
|
- `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 在线调用仍待后续冒烟。
|
||||||
@@ -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. 跑机械门禁
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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(¤t.story_id, ¤t.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(¤t.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(®enerate)
|
||||||
|
.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, ®enerated.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]
|
||||||
|
|||||||
@@ -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:** Medium(Core 与 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 单独请求用户操作。
|
||||||
@@ -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 3:Wave 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 连接。
|
||||||
|
- [ ] 真实隐藏判定工具调用。
|
||||||