feat(runtime): preserve current branch context

This commit is contained in:
2026-07-28 22:52:06 +08:00
parent 37b3397e4a
commit d0612b399c
8 changed files with 1418 additions and 85 deletions
+96 -4
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
InvalidModelOutputKind, ProviderError, TurnControl, TurnPlan, TurnPlanProvider,
provider_interruption,
BranchHistoryProjection, InvalidModelOutputKind, ProviderError, TurnControl, TurnPlan,
TurnPlanProvider, provider_interruption,
};
pub const HIDDEN_CHECK_TOOL_NAME: &str = "request_hidden_check";
@@ -109,6 +109,19 @@ pub trait AdjudicationModel {
input: AdjudicationModelInput<'_>,
) -> 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.
///
/// Existing deterministic models remain source-compatible. Network-backed
@@ -128,6 +141,18 @@ pub trait AdjudicationModel {
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)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
@@ -411,7 +436,26 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
request: &TurnRequest,
state: &RuntimeState,
) -> 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.
@@ -420,6 +464,21 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
request: &TurnRequest,
state: &RuntimeState,
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> {
let mut records = Vec::new();
let mut last_outcome = None;
@@ -434,7 +493,9 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
AdjudicationModelInput::BeginTurn { request, state },
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)?;
match tool_call {
AdjudicationToolCall::RequestHiddenCheck(proposed) => {
@@ -512,6 +573,37 @@ 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 select_bound_actor<'a, T>(
+583 -13
View File
@@ -1,17 +1,20 @@
use std::collections::BTreeSet;
use nana_domain::{
CharacterCard, CharacterJudgmentRule, CharacterStyle, ItemPlacement, ItemSpec, KnowledgeRecord,
Persona, PlotEvent, PlotModule, PlotOutcome, PlotPressure, Promise, ResourceBundle,
ResourceHeader, ResourceId, ResourceKind, RuntimeState, SkillValue, TurnIntent, TurnRequest,
WorldBookEntry,
BeatKind, CharacterCard, CharacterJudgmentRule, CharacterStyle, ItemAcquisition, ItemPlacement,
ItemSpec, KnowledgeRecord, Persona, PlotEvent, PlotModule, PlotOutcome, PlotPressure,
PresentationBeat, PresentationSnapshot, Promise, RelationshipAxes, RelationshipView,
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, RuntimeState, SkillValue, StoryNode,
TurnIntent, TurnRequest, WorldBookEntry,
};
use nana_engine::relationship_band;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::MAX_WORLD_BOOK_ENTRIES;
pub const SCENE_CONTEXT_SCHEMA_VERSION: u32 = 1;
pub const SCENE_CONTEXT_SCHEMA_VERSION: u32 = 2;
pub const SCENE_PROMPT_SCHEMA_VERSION: u32 = 1;
pub const MAX_WORLD_BOOK_CONTEXT_BYTES: usize = 64 * 1024;
pub const MAX_PLOT_EVENTS: usize = 8;
pub const MAX_PLOT_CONTEXT_BYTES: usize = 48 * 1024;
@@ -34,6 +37,10 @@ pub struct CompiledSceneContext {
pub turn: ContextTurn,
pub character_card: ContextCharacterCard,
pub persona: ContextPersona,
#[serde(default)]
pub world_book_sources: Vec<ResourceProvenance>,
#[serde(default)]
pub branch_history: BranchHistoryProjection,
pub world_book_entries: Vec<ContextWorldBookEntry>,
pub plot_events: Vec<ContextPlotEvent>,
pub state_memory: ContextStateMemory,
@@ -54,6 +61,116 @@ pub struct ContextTurn {
pub input: String,
}
/// A caller-supplied, player-safe projection of the committed root-to-head path.
///
/// The type deliberately has no state-delta, check, relationship, inventory,
/// knowledge, provider, or unselected-suggestion fields. A suggestion is only
/// a candidate edge; the next node's `player_input` records the edge the player
/// actually chose. The caller remains responsible for selecting only ancestors
/// of the branch being continued; this module never traverses the store or
/// discovers sibling branches.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchHistoryProjection {
pub entries: Vec<BranchHistoryEntry>,
}
impl BranchHistoryProjection {
/// Project already-committed nodes into narrative-only history.
///
/// The input order is preserved. Callers should pass the current branch's
/// root-to-head path and no nodes from sibling branches.
#[must_use]
pub fn from_committed_nodes<'a>(nodes: impl IntoIterator<Item = &'a StoryNode>) -> Self {
Self {
entries: nodes
.into_iter()
.map(BranchHistoryEntry::from_committed_node)
.collect(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchHistoryEntry {
pub node_id: String,
pub player_input: String,
#[serde(default)]
pub scene: BranchHistoryScene,
#[serde(default)]
pub character: BranchHistoryCharacter,
pub beats: Vec<BranchHistoryBeat>,
}
impl BranchHistoryEntry {
#[must_use]
pub fn from_committed_node(node: &StoryNode) -> Self {
Self::from_presentation(&node.id, &node.user_input, &node.presentation)
}
#[must_use]
pub fn from_presentation(
node_id: impl Into<String>,
player_input: impl Into<String>,
presentation: &PresentationSnapshot,
) -> Self {
Self {
node_id: node_id.into(),
player_input: player_input.into(),
scene: BranchHistoryScene {
id: presentation.scene.id.clone(),
title: presentation.scene.title.clone(),
},
character: BranchHistoryCharacter {
id: presentation.character.id.clone(),
name: presentation.character.name.clone(),
expression: presentation.character.expression.clone(),
pose: presentation.character.pose.clone(),
},
beats: presentation
.beats
.iter()
.map(BranchHistoryBeat::from)
.collect(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchHistoryScene {
pub id: String,
pub title: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchHistoryCharacter {
pub id: String,
pub name: String,
pub expression: Option<String>,
pub pose: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchHistoryBeat {
pub kind: BeatKind,
pub speaker: Option<String>,
pub text: String,
}
impl From<&PresentationBeat> for BranchHistoryBeat {
fn from(beat: &PresentationBeat) -> Self {
Self {
kind: beat.kind,
speaker: beat.speaker.clone(),
text: beat.text.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceProvenance {
pub resource_id: ResourceId,
@@ -156,6 +273,8 @@ pub struct PlayerMemory {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CharacterMemory {
pub actor_id: String,
#[serde(default = "neutral_relationship_view")]
pub relationship_to_player: RelationshipView,
pub knowledge: Vec<KnowledgeRecord>,
pub promises: Vec<Promise>,
}
@@ -173,10 +292,14 @@ pub struct ContextInventoryItem {
pub name: String,
pub description: String,
pub tags: Vec<String>,
#[serde(default)]
pub owner: String,
pub quantity: u32,
pub placement: ItemPlacement,
pub condition: String,
pub state_tags: Vec<String>,
#[serde(default = "unknown_item_acquisition")]
pub acquisition: ItemAcquisition,
}
/// Summary storage is explicitly separate from factual memory.
@@ -308,7 +431,29 @@ pub fn compile_scene_context(
request: &TurnRequest,
state: &RuntimeState,
) -> Result<CompiledSceneContext, ContextCompileError> {
compile_scene_context_with_budget(bundle, request, state, ContextBudget::default())
compile_scene_context_with_history_and_budget(
bundle,
request,
state,
&BranchHistoryProjection::default(),
ContextBudget::default(),
)
}
/// Compile context with an explicit player-safe current-branch history.
pub fn compile_scene_context_with_history(
bundle: &ResourceBundle,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<CompiledSceneContext, ContextCompileError> {
compile_scene_context_with_history_and_budget(
bundle,
request,
state,
branch_history,
ContextBudget::default(),
)
}
/// Compile context with a caller-provided budget that may only tighten hard V1
@@ -318,6 +463,23 @@ pub fn compile_scene_context_with_budget(
request: &TurnRequest,
state: &RuntimeState,
budget: ContextBudget,
) -> Result<CompiledSceneContext, ContextCompileError> {
compile_scene_context_with_history_and_budget(
bundle,
request,
state,
&BranchHistoryProjection::default(),
budget,
)
}
/// Compile context with both explicit branch history and tightened V1 limits.
pub fn compile_scene_context_with_history_and_budget(
bundle: &ResourceBundle,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
budget: ContextBudget,
) -> Result<CompiledSceneContext, ContextCompileError> {
validate_turn_position(request, state)?;
let budget = budget.bounded();
@@ -340,6 +502,8 @@ pub fn compile_scene_context_with_budget(
},
character_card: character_context(character),
persona: persona_context(persona),
world_book_sources: bound_world_book_sources(bundle, character, persona, plot_module)?,
branch_history: branch_history.clone(),
world_book_entries: select_context_world_book_entries(
bundle,
character,
@@ -371,6 +535,61 @@ pub fn encode_compiled_scene_context(
serde_json::to_string(context).map_err(|_| ContextCompileError::Serialization)
}
/// Encode a cache-friendly model envelope.
///
/// Field order is a prompt contract: immutable system/resource data comes
/// first, the caller-provided current-branch history comes next, and volatile
/// state plus the current player input are last. Resource strings remain data
/// inside a user message; callers must not splice this encoding into a system
/// prompt.
pub fn encode_compiled_scene_prompt(
context: &CompiledSceneContext,
) -> Result<String, ContextCompileError> {
#[derive(Serialize)]
struct StablePrefix<'a> {
schema_version: u32,
narrative_safety: &'a NarrativeSafety,
character_card: &'a ContextCharacterCard,
persona: &'a ContextPersona,
world_book_sources: &'a [ResourceProvenance],
}
#[derive(Serialize)]
struct DynamicTail<'a> {
world_book_entries: &'a [ContextWorldBookEntry],
plot_events: &'a [ContextPlotEvent],
state_memory: &'a ContextStateMemory,
turn: &'a ContextTurn,
}
#[derive(Serialize)]
struct ModelEnvelope<'a> {
prompt_schema_version: u32,
stable_prefix: StablePrefix<'a>,
branch_history: &'a BranchHistoryProjection,
dynamic_tail: DynamicTail<'a>,
}
serde_json::to_string(&ModelEnvelope {
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
stable_prefix: StablePrefix {
schema_version: context.schema_version,
narrative_safety: &context.narrative_safety,
character_card: &context.character_card,
persona: &context.persona,
world_book_sources: &context.world_book_sources,
},
branch_history: &context.branch_history,
dynamic_tail: DynamicTail {
world_book_entries: &context.world_book_entries,
plot_events: &context.plot_events,
state_memory: &context.state_memory,
turn: &context.turn,
},
})
.map_err(|_| ContextCompileError::Serialization)
}
fn validate_turn_position(
request: &TurnRequest,
state: &RuntimeState,
@@ -500,6 +719,40 @@ fn persona_context(persona: &Persona) -> ContextPersona {
}
}
fn bound_world_book_sources(
bundle: &ResourceBundle,
character: &CharacterCard,
persona: &Persona,
plot_module: Option<&PlotModule>,
) -> Result<Vec<ResourceProvenance>, ContextCompileError> {
let bound_refs = character
.header
.dependencies
.iter()
.chain(persona.header.dependencies.iter())
.chain(
plot_module
.into_iter()
.flat_map(|module| module.header.dependencies.iter()),
)
.filter(|dependency| dependency.kind == ResourceKind::WorldBook)
.collect::<Vec<_>>();
let bound_ids = bound_refs
.iter()
.map(|dependency| dependency.id.clone())
.collect::<BTreeSet<_>>();
validate_bound_world_books(bundle, &bound_refs, &bound_ids)?;
let mut sources = bundle
.world_books
.iter()
.filter(|book| bound_ids.contains(&book.header.id))
.map(|book| provenance(&book.header))
.collect::<Vec<_>>();
sources.sort_by(|left, right| left.resource_id.cmp(&right.resource_id));
Ok(sources)
}
#[allow(clippy::too_many_arguments)]
fn select_context_world_book_entries(
bundle: &ResourceBundle,
@@ -721,6 +974,7 @@ fn state_memory_context(
},
primary_character: CharacterMemory {
actor_id: primary_character_id.to_owned(),
relationship_to_player: relationship_to_player(state, primary_character_id),
knowledge: Vec::new(),
promises: Vec::new(),
},
@@ -797,6 +1051,40 @@ fn state_memory_context(
Ok(memory)
}
fn relationship_to_player(state: &RuntimeState, primary_character_id: &str) -> RelationshipView {
let relationship_key = format!("{primary_character_id}->{PLAYER_ACTOR_ID}");
let axes = state
.relationships
.get(&relationship_key)
.copied()
.unwrap_or_else(RelationshipAxes::neutral);
relationship_view_from_axes(axes)
}
fn neutral_relationship_view() -> RelationshipView {
relationship_view_from_axes(RelationshipAxes::neutral())
}
fn unknown_item_acquisition() -> ItemAcquisition {
ItemAcquisition {
mode: nana_domain::AcquisitionMode::Initial,
from: None,
at_node: String::new(),
}
}
fn relationship_view_from_axes(axes: RelationshipAxes) -> RelationshipView {
RelationshipView {
affinity: relationship_band(axes.affinity),
trust: relationship_band(axes.trust),
hope: relationship_band(axes.hope),
respect: relationship_band(axes.respect),
intimacy: relationship_band(axes.intimacy),
attachment: relationship_band(axes.attachment),
updated_at_node: None,
}
}
fn append_knowledge<'a>(
target: &mut Vec<KnowledgeRecord>,
records: impl Iterator<Item = &'a KnowledgeRecord>,
@@ -860,10 +1148,12 @@ fn inventory_item_context(
name: spec.name.clone(),
description: spec.description.clone(),
tags: spec.tags.clone(),
owner: item.owner.clone(),
quantity: item.quantity,
placement: item.placement,
condition: item.condition.clone(),
state_tags: item.state_tags.clone(),
acquisition: item.acquisition.clone(),
}
}
@@ -978,17 +1268,21 @@ mod tests {
use std::collections::BTreeMap;
use nana_domain::{
AcquisitionMode, CharacterCard, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult,
ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty,
KnowledgeRecord, Persona, PlotEvent, PlotModule, PlotOutcome, PlotPressure, Promise,
PromiseStatus, PromiseWeight, ResourceBundle, ResourceHeader, ResourceId, ResourceKind,
AcquisitionMode, ActionSuggestion, BeatKind, CharacterCard, CharacterStyle,
CheckDifficulty, CheckRecord, CheckResult, ItemAcquisition, ItemInstance, ItemMechanics,
ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona, PlotEvent,
PlotModule, PlotOutcome, PlotPressure, PresentationSnapshot, Promise, PromiseStatus,
PromiseWeight, RelationshipBand, ResourceBundle, ResourceHeader, ResourceId, ResourceKind,
ResourceRef, RuntimeState, TurnIntent, TurnRequest, WorldBook, WorldBookEntry,
};
use super::{
ContextBudget, ContextCompileError, HiddenCheckTreatment, ResourceStringTreatment,
BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection,
BranchHistoryScene, CompiledSceneContext, ContextBudget, ContextCompileError,
HiddenCheckTreatment, ResourceStringTreatment, SCENE_PROMPT_SCHEMA_VERSION,
SummaryClassification, compile_scene_context, compile_scene_context_with_budget,
encode_compiled_scene_context,
compile_scene_context_with_history, encode_compiled_scene_context,
encode_compiled_scene_prompt,
};
const REVISION: &str = "1";
@@ -1303,6 +1597,8 @@ mod tests {
"\"turn\"",
"\"character_card\"",
"\"persona\"",
"\"world_book_sources\"",
"\"branch_history\"",
"\"world_book_entries\"",
"\"plot_events\"",
"\"state_memory\"",
@@ -1318,6 +1614,8 @@ mod tests {
"generic.character.guide"
);
assert_eq!(context.state_memory.primary_character.actor_id, "guide");
assert_eq!(context.world_book_sources.len(), 1);
assert!(context.branch_history.is_empty());
assert_eq!(
context.narrative_safety.resource_strings,
ResourceStringTreatment::UntrustedData
@@ -1328,6 +1626,225 @@ mod tests {
);
}
fn history_entry(node_id: &str, player_input: &str, narration: &str) -> BranchHistoryEntry {
BranchHistoryEntry {
node_id: node_id.into(),
player_input: player_input.into(),
scene: BranchHistoryScene {
id: "harbor_platform".into(),
title: "Storm Harbor".into(),
},
character: BranchHistoryCharacter {
id: "guide".into(),
name: "Guide".into(),
expression: Some("concerned".into()),
pose: Some("holding_lantern".into()),
},
beats: vec![BranchHistoryBeat {
kind: BeatKind::Dialogue,
speaker: Some("Guide".into()),
text: narration.into(),
}],
}
}
#[test]
fn history_projection_omits_unselected_suggestions() {
let mut presentation = PresentationSnapshot::default();
presentation.scene.id = "old_station".into();
presentation.scene.title = "Old Station".into();
presentation.character.id = "guide".into();
presentation.character.name = "Guide".into();
presentation.character.expression = Some("guarded".into());
presentation.character.pose = Some("holding_coat".into());
presentation.suggestions.push(ActionSuggestion {
id: "suggestion_other_path".into(),
label: "UNSELECTED_LABEL_CANARY".into(),
draft: "UNSELECTED_DRAFT_CANARY".into(),
});
let entry = BranchHistoryEntry::from_presentation("node_1", "chosen action", &presentation);
let encoded = serde_json::to_string(&entry).expect("history entry");
assert!(encoded.contains("chosen action"));
assert!(encoded.contains("old_station"));
assert!(encoded.contains("Old Station"));
assert!(encoded.contains("guarded"));
assert!(encoded.contains("holding_coat"));
assert!(!encoded.contains("UNSELECTED_LABEL_CANARY"));
assert!(!encoded.contains("UNSELECTED_DRAFT_CANARY"));
}
#[test]
fn v1_context_without_new_safe_projection_fields_remains_readable() {
let mut runtime = state();
runtime.items = vec![item(
"legacy_player_lamp",
"player",
"player",
ItemPlacement::Bag,
)];
let history = BranchHistoryProjection {
entries: vec![history_entry(
"node_legacy",
"Legacy player turn.",
"Legacy committed reply.",
)],
};
let context = compile_scene_context_with_history(
&base_bundle(),
&request("storm"),
&runtime,
&history,
)
.expect("current context");
let mut legacy = serde_json::to_value(context).expect("legacy JSON");
legacy["schema_version"] = serde_json::json!(1);
legacy["branch_history"]["entries"][0]
.as_object_mut()
.expect("legacy history entry")
.retain(|key, _| key != "scene" && key != "character");
legacy["state_memory"]["primary_character"]
.as_object_mut()
.expect("legacy character memory")
.remove("relationship_to_player");
legacy["state_memory"]["player"]["inventory"][0]
.as_object_mut()
.expect("legacy inventory item")
.retain(|key, _| key != "owner" && key != "acquisition");
let decoded =
serde_json::from_value::<CompiledSceneContext>(legacy).expect("readable v1 context");
assert_eq!(decoded.schema_version, 1);
assert_eq!(decoded.branch_history.entries[0].scene.id, "");
assert_eq!(decoded.branch_history.entries[0].character.id, "");
assert_eq!(
decoded
.state_memory
.primary_character
.relationship_to_player
.trust,
RelationshipBand::Warming
);
assert_eq!(decoded.state_memory.player.inventory[0].owner, "");
assert_eq!(
decoded.state_memory.player.inventory[0].acquisition.mode,
AcquisitionMode::Initial
);
}
#[test]
fn model_prompt_orders_stable_prefix_history_and_current_turn_tail() {
let first_history = BranchHistoryProjection {
entries: vec![history_entry(
"node_1",
"FIRST PLAYER TURN",
"FIRST COMMITTED REPLY",
)],
};
let third_turn_history = BranchHistoryProjection {
entries: vec![
first_history.entries[0].clone(),
history_entry("node_2", "SECOND PLAYER TURN", "SECOND COMMITTED REPLY"),
],
};
let first = compile_scene_context_with_history(
&base_bundle(),
&request("first current input"),
&state(),
&first_history,
)
.expect("first prompt context");
let third = compile_scene_context_with_history(
&base_bundle(),
&request("THIRD CURRENT INPUT"),
&state(),
&third_turn_history,
)
.expect("third prompt context");
let first_prompt = encode_compiled_scene_prompt(&first).expect("first prompt");
let third_prompt = encode_compiled_scene_prompt(&third).expect("third prompt");
assert!(first_prompt.starts_with(&format!(
"{{\"prompt_schema_version\":{SCENE_PROMPT_SCHEMA_VERSION},"
)));
let history_marker = ",\"branch_history\":";
let first_stable_end = first_prompt.find(history_marker).expect("history marker");
let third_stable_end = third_prompt.find(history_marker).expect("history marker");
assert_eq!(
&first_prompt[..first_stable_end],
&third_prompt[..third_stable_end],
"resource prefix must remain byte-identical across turns"
);
let stable_at = third_prompt
.find("\"stable_prefix\"")
.expect("stable prefix");
let history_at = third_prompt
.find("\"branch_history\"")
.expect("branch history");
let dynamic_at = third_prompt.find("\"dynamic_tail\"").expect("dynamic tail");
let first_turn_at = third_prompt.find("FIRST PLAYER TURN").expect("first turn");
let second_turn_at = third_prompt
.find("SECOND PLAYER TURN")
.expect("second turn");
let current_at = third_prompt
.find("THIRD CURRENT INPUT")
.expect("current input");
assert!(stable_at < history_at);
assert!(history_at < first_turn_at);
assert!(first_turn_at < second_turn_at);
assert!(second_turn_at < dynamic_at);
assert!(dynamic_at < current_at);
assert!(third_prompt.contains("FIRST COMMITTED REPLY"));
assert!(third_prompt.contains("SECOND COMMITTED REPLY"));
assert!(third_prompt.contains("harbor_platform"));
assert!(third_prompt.contains("concerned"));
assert!(third_prompt.contains("holding_lantern"));
}
#[test]
fn model_prompt_never_serializes_hidden_runtime_canaries() {
let mut runtime = state();
runtime.items.push(item(
"NPC_PRIVATE_INVENTORY_CANARY",
"guide",
"guide",
ItemPlacement::Hidden,
));
let history = BranchHistoryProjection {
entries: vec![history_entry(
"node_1",
"Safe player input.",
"Safe committed reply.",
)],
};
let context = compile_scene_context_with_history(
&base_bundle(),
&request("Safe current input."),
&runtime,
&history,
)
.expect("context");
let prompt = encode_compiled_scene_prompt(&context).expect("prompt");
for canary in [
"PRIVATE_CHECK",
"PRIVATE_ACTION",
"PRIVATE_SKILL",
"PRIVATE CLOCK LABEL",
"NPC_PRIVATE_INVENTORY_CANARY",
"PRIVATE ITEM FACT",
"\"roll\"",
"\"target\"",
"\"difficulty\"",
"\"delta\"",
] {
assert!(!prompt.contains(canary), "leaked {canary}");
}
}
#[test]
fn missing_or_ambiguous_bound_resources_are_compile_errors() {
let mut missing = base_bundle();
@@ -1489,8 +2006,14 @@ mod tests {
#[test]
fn player_inventory_is_public_but_npc_hidden_and_item_hidden_facts_are_excluded() {
let mut state = state();
let mut borrowed_lamp = item("player_lamp", "guide", "player", ItemPlacement::Bag);
borrowed_lamp.acquisition = ItemAcquisition {
mode: AcquisitionMode::Borrowed,
from: Some("guide".into()),
at_node: "node_borrowed".into(),
};
state.items = vec![
item("player_lamp", "player", "player", ItemPlacement::Bag),
borrowed_lamp,
item("npc_lamp", "guide", "guide", ItemPlacement::Hand),
item("concealed_lamp", "player", "player", ItemPlacement::Hidden),
];
@@ -1502,7 +2025,22 @@ mod tests {
context.state_memory.player.inventory[0].instance_id,
"player_lamp"
);
assert_eq!(context.state_memory.player.inventory[0].owner, "guide");
assert_eq!(
context.state_memory.player.inventory[0].acquisition.mode,
AcquisitionMode::Borrowed
);
assert_eq!(
context.state_memory.player.inventory[0]
.acquisition
.from
.as_deref(),
Some("guide")
);
let json = serde_json::to_string(&context).expect("json");
assert!(json.contains("\"owner\":\"guide\""));
assert!(json.contains("\"mode\":\"borrowed\""));
assert!(json.contains("\"at_node\":\"node_borrowed\""));
assert!(!json.contains("npc_lamp"));
assert!(!json.contains("concealed_lamp"));
assert!(!json.contains("PRIVATE ITEM FACT"));
@@ -1514,6 +2052,21 @@ mod tests {
let context =
compile_scene_context(&base_bundle(), &request("storm"), &state()).expect("context");
let json = serde_json::to_string(&context).expect("json");
let relationship = &context
.state_memory
.primary_character
.relationship_to_player;
assert_eq!(relationship.affinity, RelationshipBand::Distant);
assert_eq!(relationship.trust, RelationshipBand::Distant);
assert_eq!(relationship.hope, RelationshipBand::Distant);
assert_eq!(relationship.respect, RelationshipBand::Guarded);
assert_eq!(relationship.intimacy, RelationshipBand::Guarded);
assert_eq!(relationship.attachment, RelationshipBand::Guarded);
assert!(json.contains("\"affinity\":\"distant\""));
assert!(json.contains("\"respect\":\"guarded\""));
assert!(!json.contains("\"affinity\":17"));
assert!(!json.contains("\"trust\":18"));
assert!(!json.contains("\"hope\":19"));
for private_value in [
"PRIVATE_CHECK",
@@ -1531,6 +2084,23 @@ mod tests {
context.narrative_safety.hidden_checks,
HiddenCheckTreatment::OmitMechanicalDetails
);
let mut neutral_state = state();
neutral_state.relationships.clear();
let neutral = compile_scene_context(&base_bundle(), &request("storm"), &neutral_state)
.expect("neutral context");
let neutral_relationship = neutral
.state_memory
.primary_character
.relationship_to_player;
assert_eq!(neutral_relationship.affinity, RelationshipBand::Warming);
assert_eq!(neutral_relationship.trust, RelationshipBand::Warming);
assert_eq!(neutral_relationship.hope, RelationshipBand::Warming);
assert!(
serde_json::to_string(&neutral_relationship)
.expect("neutral relationship")
.contains("warming")
);
}
#[test]
+238 -37
View File
@@ -23,9 +23,9 @@ use serde_json::{Value, json};
use crate::{
AdjudicationModel, AdjudicationModelInput, AdjudicationModelResponse, AdjudicationToolCall,
HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, InvalidModelOutputKind, ProviderError, TurnControl,
TurnPlan, TurnPlanProvider, compile_scene_context, encode_compiled_scene_context,
load_default_lapp_profile, provider_interruption,
BranchHistoryProjection, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, InvalidModelOutputKind,
ProviderError, TurnControl, TurnPlan, TurnPlanProvider, compile_scene_context_with_history,
encode_compiled_scene_prompt, load_default_lapp_profile, provider_interruption,
};
pub const TURN_PLAN_TOOL_NAME: &str = "submit_turn_plan";
@@ -503,6 +503,8 @@ async fn wait_with_turn_control<Output>(
#[derive(Debug)]
pub struct LappTurnPlanProvider<Executor> {
executor: Executor,
bundle: ResourceBundle,
branch_history: BranchHistoryProjection,
}
/// Stateful LAPP adapter for the trusted hidden-check loop.
@@ -515,6 +517,7 @@ pub struct LappTurnPlanProvider<Executor> {
pub struct LappAdjudicationModel<Executor> {
executor: Executor,
bundle: ResourceBundle,
branch_history: BranchHistoryProjection,
messages: Vec<ChatMessage>,
current_request: Option<TurnRequest>,
pending_tool_call_id: Option<String>,
@@ -526,6 +529,9 @@ impl<Executor> LappAdjudicationModel<Executor> {
Self {
executor,
bundle,
branch_history: BranchHistoryProjection {
entries: Vec::new(),
},
messages: Vec::new(),
current_request: None,
pending_tool_call_id: None,
@@ -537,6 +543,21 @@ impl<Executor> LappAdjudicationModel<Executor> {
&self.executor
}
#[must_use]
pub const fn branch_history(&self) -> &BranchHistoryProjection {
&self.branch_history
}
#[must_use]
pub fn with_branch_history(mut self, branch_history: BranchHistoryProjection) -> Self {
self.branch_history = branch_history;
self
}
pub fn set_branch_history(&mut self, branch_history: BranchHistoryProjection) {
self.branch_history = branch_history;
}
#[must_use]
pub fn into_executor(self) -> Executor {
self.executor
@@ -602,7 +623,15 @@ impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executo
&mut self,
input: AdjudicationModelInput<'_>,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, None)
self.respond_inner(input, None, None)
}
fn respond_with_history(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, None, Some(branch_history))
}
fn respond_with_control(
@@ -610,7 +639,16 @@ impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executo
input: AdjudicationModelInput<'_>,
control: &TurnControl,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, Some(control))
self.respond_inner(input, Some(control), None)
}
fn respond_with_history_and_control(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<AdjudicationModelResponse, ProviderError> {
self.respond_inner(input, Some(control), Some(branch_history))
}
}
@@ -619,12 +657,19 @@ impl<Executor: ChatExecutor> LappAdjudicationModel<Executor> {
&mut self,
input: AdjudicationModelInput<'_>,
control: Option<&TurnControl>,
supplied_branch_history: Option<&BranchHistoryProjection>,
) -> Result<AdjudicationModelResponse, ProviderError> {
match input {
AdjudicationModelInput::BeginTurn { request, state } => {
let context = compile_scene_context(&self.bundle, request, state)
.map_err(|_| ProviderError::ContextEncoding)?;
let encoded = encode_compiled_scene_context(&context)
let branch_history = supplied_branch_history.unwrap_or(&self.branch_history);
let context = compile_scene_context_with_history(
&self.bundle,
request,
state,
branch_history,
)
.map_err(|_| ProviderError::ContextEncoding)?;
let encoded = encode_compiled_scene_prompt(&context)
.map_err(|_| ProviderError::ContextEncoding)?;
self.messages = vec![
ChatMessage {
@@ -724,8 +769,14 @@ impl<Executor> LappAdjudicationModel<Executor> {
impl<Executor> LappTurnPlanProvider<Executor> {
#[must_use]
pub const fn new(executor: Executor) -> Self {
Self { executor }
pub const fn new(executor: Executor, bundle: ResourceBundle) -> Self {
Self {
executor,
bundle,
branch_history: BranchHistoryProjection {
entries: Vec::new(),
},
}
}
#[must_use]
@@ -733,6 +784,21 @@ impl<Executor> LappTurnPlanProvider<Executor> {
&self.executor
}
#[must_use]
pub const fn branch_history(&self) -> &BranchHistoryProjection {
&self.branch_history
}
#[must_use]
pub fn with_branch_history(mut self, branch_history: BranchHistoryProjection) -> Self {
self.branch_history = branch_history;
self
}
pub fn set_branch_history(&mut self, branch_history: BranchHistoryProjection) {
self.branch_history = branch_history;
}
#[must_use]
pub fn into_executor(self) -> Executor {
self.executor
@@ -741,28 +807,31 @@ impl<Executor> LappTurnPlanProvider<Executor> {
impl LappTurnPlanProvider<OpenLappChatExecutor> {
/// Load the current user's LAPP profile and select its `chat` default.
pub fn from_default_profile() -> Result<Self, ProviderError> {
pub fn from_default_profile(bundle: ResourceBundle) -> Result<Self, ProviderError> {
let profile = load_default_lapp_profile()?;
Self::from_profile(&profile)
Self::from_profile(&profile, bundle)
}
pub fn from_default_profile_with_gate(
bundle: ResourceBundle,
native_call_gate: LappNativeCallGate,
) -> Result<Self, ProviderError> {
let profile = load_default_lapp_profile()?;
Self::from_profile_with_gate(&profile, native_call_gate)
Self::from_profile_with_gate(&profile, bundle, native_call_gate)
}
/// Build against an already validated LAPP profile.
pub fn from_profile(profile: &Profile) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile(profile).map(Self::new)
pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile(profile).map(|executor| Self::new(executor, bundle))
}
pub fn from_profile_with_gate(
profile: &Profile,
bundle: ResourceBundle,
native_call_gate: LappNativeCallGate,
) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile_with_gate(profile, native_call_gate).map(Self::new)
OpenLappChatExecutor::from_profile_with_gate(profile, native_call_gate)
.map(|executor| Self::new(executor, bundle))
}
}
@@ -772,7 +841,7 @@ impl<Executor: ChatExecutor> TurnPlanProvider for LappTurnPlanProvider<Executor>
request: &TurnRequest,
state: &RuntimeState,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(request, state)?;
let input = build_chat_input(&self.bundle, request, state, &self.branch_history)?;
let response = self.executor.chat(&input)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
@@ -785,7 +854,34 @@ impl<Executor: ChatExecutor> TurnPlanProvider for LappTurnPlanProvider<Executor>
state: &RuntimeState,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(request, state)?;
let input = build_chat_input(&self.bundle, request, state, &self.branch_history)?;
let response = self.executor.chat_with_control(&input, control)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
Ok(plan)
}
fn plan_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(&self.bundle, request, state, branch_history)?;
let response = self.executor.chat(&input)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
Ok(plan)
}
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
let input = build_chat_input(&self.bundle, request, state, branch_history)?;
let response = self.executor.chat_with_control(&input, control)?;
let plan = parse_chat_response(&response, request)?;
validate_generated_plan(request, &plan)?;
@@ -821,14 +917,14 @@ impl TurnPlanWire {
}
fn build_chat_input(
bundle: &ResourceBundle,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<ChatInput, ProviderError> {
let context = serde_json::to_string(&json!({
"request": request,
"runtimeState": state,
}))
.map_err(|_| ProviderError::ContextEncoding)?;
let context = compile_scene_context_with_history(bundle, request, state, branch_history)
.and_then(|context| encode_compiled_scene_prompt(&context))
.map_err(|_| ProviderError::ContextEncoding)?;
Ok(ChatInput {
messages: vec![
@@ -1132,8 +1228,8 @@ mod tests {
use std::time::{Duration, Instant};
use nana_domain::{
CheckResult, ResourceBundle, RuntimeState, StateOp, TurnFailureCode, TurnIntent,
TurnRequest,
BeatKind, CheckDifficulty, CheckRecord, CheckResult, ResourceBundle, RuntimeState, StateOp,
TurnFailureCode, TurnIntent, TurnRequest,
};
use openlapp::client::{ChatInput, ChatResponse, ChatRole, ToolCall};
use serde_json::{Value, json};
@@ -1144,7 +1240,8 @@ mod tests {
parse_chat_response, run_isolated_request, wait_with_turn_control,
};
use crate::{
AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationModel,
AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationModel, BranchHistoryBeat,
BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection, BranchHistoryScene,
InvalidModelOutputKind, TurnControl, TurnPlanProvider, map_provider_error,
};
@@ -1354,6 +1451,51 @@ mod tests {
.expect("embedded demo bundle")
}
fn two_turn_history() -> BranchHistoryProjection {
BranchHistoryProjection {
entries: vec![
BranchHistoryEntry {
node_id: "node_opening".into(),
player_input: "FIRST PLAYER TURN".into(),
scene: BranchHistoryScene {
id: "station_platform".into(),
title: "Old Station".into(),
},
character: BranchHistoryCharacter {
id: "nana".into(),
name: "Nana".into(),
expression: Some("guarded".into()),
pose: Some("holding_coat".into()),
},
beats: vec![BranchHistoryBeat {
kind: BeatKind::Dialogue,
speaker: Some("Nana".into()),
text: "FIRST COMMITTED REPLY".into(),
}],
},
BranchHistoryEntry {
node_id: "node_second".into(),
player_input: "SECOND PLAYER TURN".into(),
scene: BranchHistoryScene {
id: "station_platform".into(),
title: "Old Station".into(),
},
character: BranchHistoryCharacter {
id: "nana".into(),
name: "Nana".into(),
expression: Some("uncertain".into()),
pose: Some("holding_coat".into()),
},
beats: vec![BranchHistoryBeat {
kind: BeatKind::Narration,
speaker: None,
text: "SECOND COMMITTED REPLY".into(),
}],
},
],
}
}
fn response(text: String, tool_calls: Vec<ToolCall>) -> ChatResponse {
ChatResponse {
text,
@@ -1371,10 +1513,24 @@ mod tests {
fn text_json_produces_a_non_view_turn_plan_and_expected_chat_input() {
let executor =
ScriptedExecutor::returning(Ok(response(plan_value().to_string(), Vec::new())));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
let mut runtime = state();
runtime.checks.push(CheckRecord {
id: "HIDDEN_CHECK_CANARY".into(),
action_id: "HIDDEN_ACTION_CANARY".into(),
actor: "player".into(),
skill: "HIDDEN_SKILL_CANARY".into(),
target: 55,
difficulty: CheckDifficulty::Regular,
bonus_dice: 0,
roll: 42,
result: CheckResult::Failure,
pushed_from: None,
node_id: "node_1".into(),
});
let plan = provider
.plan_turn(&request(), &state())
.plan_turn(&request(), &runtime)
.expect("valid text plan");
assert_eq!(
@@ -1395,11 +1551,30 @@ mod tests {
assert_eq!(executor.inputs[0].messages.len(), 2);
assert_eq!(executor.inputs[0].tools.len(), 1);
assert_eq!(executor.inputs[0].tools[0].name, TURN_PLAN_TOOL_NAME);
assert!(
executor.inputs[0].messages[1]
.content
.contains("runtimeState")
);
let prompt = &executor.inputs[0].messages[1].content;
for expected in [
"\"prompt_schema_version\":1",
"\"stable_prefix\"",
"\"branch_history\"",
"\"dynamic_tail\"",
"\"character_card\"",
"独自守在废弃青川站",
] {
assert!(prompt.contains(expected), "missing {expected}");
}
for forbidden in [
"runtimeState",
"runtime_state",
"HIDDEN_CHECK_CANARY",
"HIDDEN_ACTION_CANARY",
"HIDDEN_SKILL_CANARY",
"\"roll\"",
"\"target\"",
"\"initial_relationship\"",
"\"relationship_effects\"",
] {
assert!(!prompt.contains(forbidden), "leaked {forbidden}");
}
assert!(
executor.inputs[0].messages[0]
.content
@@ -1415,7 +1590,7 @@ mod tests {
arguments: plan_value(),
};
let executor = ScriptedExecutor::returning(Ok(response(String::new(), vec![tool_call])));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
let plan = provider
.plan_turn(&request(), &state())
@@ -1460,7 +1635,7 @@ mod tests {
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog);
let plan = provider
.plan_turn(&request(), &state())
.plan_turn_with_history(&request(), &state(), &two_turn_history())
.expect("hidden check then final plan");
let recorded = plan
.delta
@@ -1497,6 +1672,32 @@ mod tests {
.content
.contains("\"value\":55")
);
for expected in [
"FIRST PLAYER TURN",
"FIRST COMMITTED REPLY",
"SECOND PLAYER TURN",
"SECOND COMMITTED REPLY",
] {
assert!(
executor.inputs[0].messages[1].content.contains(expected),
"missing {expected}"
);
}
let prompt = &executor.inputs[0].messages[1].content;
assert!(
prompt.find("\"stable_prefix\"").expect("stable prefix")
< prompt.find("\"branch_history\"").expect("history")
);
assert!(
prompt.find("\"branch_history\"").expect("history")
< prompt.find("\"dynamic_tail\"").expect("dynamic tail")
);
assert!(
prompt.find("SECOND PLAYER TURN").expect("second turn")
< prompt
.find("I will return before dawn.")
.expect("current input")
);
let continuation = &executor.inputs[1].messages;
assert_eq!(continuation[2].role, ChatRole::Assistant);
@@ -1660,7 +1861,7 @@ mod tests {
}
}]);
let executor = ScriptedExecutor::returning(Ok(response(value.to_string(), Vec::new())));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
assert!(matches!(
provider.plan_turn(&request(), &state()),
@@ -1676,7 +1877,7 @@ mod tests {
code: Some(openlapp::ErrorCode::HttpStatus),
status: None,
}));
let mut provider = LappTurnPlanProvider::new(executor);
let mut provider = LappTurnPlanProvider::new(executor, demo_bundle());
let error = provider
.plan_turn(&request(), &state())
+122 -7
View File
@@ -20,14 +20,17 @@ pub use adjudication::{
QualitativeCheckOutcome, classify_roll, deterministic_roll,
};
pub use context::{
CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard,
BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection,
BranchHistoryScene, CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard,
ContextCompileError, ContextInventoryItem, ContextJudgmentRule, ContextPersona,
ContextPlotEvent, ContextPlotOutcome, ContextPlotPressure, ContextSkill, ContextStateMemory,
ContextStatePosition, ContextSummary, ContextTurn, ContextWorldBookEntry, HiddenCheckTreatment,
NarrativeSafety, PlayerMemory, ResourceProvenance, ResourceStringTreatment,
SCENE_CONTEXT_SCHEMA_VERSION, SceneContext, SharedMemory, SummaryClassification, SummaryMemory,
SummaryTreatment, compile_scene_context, compile_scene_context_with_budget,
encode_compiled_scene_context,
SCENE_CONTEXT_SCHEMA_VERSION, SCENE_PROMPT_SCHEMA_VERSION, SceneContext, SharedMemory,
SummaryClassification, SummaryMemory, SummaryTreatment, compile_scene_context,
compile_scene_context_with_budget, compile_scene_context_with_history,
compile_scene_context_with_history_and_budget, encode_compiled_scene_context,
encode_compiled_scene_prompt,
};
pub use lapp_provider::{
ChatExecutor, LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit,
@@ -95,6 +98,21 @@ pub trait TurnPlanProvider {
state: &RuntimeState,
) -> Result<TurnPlan, ProviderError>;
/// Produce a plan with a caller-selected, player-safe root-to-head history.
///
/// The default preserves existing providers and ignores history. Providers
/// backed by a narrative model should override this method rather than
/// querying persistence directly.
fn plan_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, ProviderError> {
let _ = branch_history;
self.plan_turn(request, state)
}
/// Produce a plan while observing a one-shot turn control.
///
/// The default keeps existing providers source-compatible and discards
@@ -116,6 +134,21 @@ pub trait TurnPlanProvider {
result
}
}
/// History-aware form of [`TurnPlanProvider::plan_turn_with_control`].
///
/// This default preserves the controlled behavior of existing providers
/// and ignores history. History-aware providers should override it.
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
let _ = branch_history;
self.plan_turn_with_control(request, state, control)
}
}
impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
@@ -135,6 +168,25 @@ impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
) -> Result<TurnPlan, ProviderError> {
(**self).plan_turn_with_control(request, state, control)
}
fn plan_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, ProviderError> {
(**self).plan_turn_with_history(request, state, branch_history)
}
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
(**self).plan_turn_with_history_and_control(request, state, branch_history, control)
}
}
/// Projects only already-committed state into the player-safe read model.
@@ -204,9 +256,14 @@ where
return Err(stale_node());
}
let ancestor_chain = self
.store
.load_ancestor_chain(&request.story_id, &current.current_node)
.map_err(|error| map_store_error(&error))?;
let branch_history = BranchHistoryProjection::from_committed_nodes(ancestor_chain.iter());
let plan = self
.provider
.plan_turn_with_control(request, &current, control)
.plan_turn_with_history_and_control(request, &current, &branch_history, control)
.map_err(|error| map_provider_error(&error))?;
validate_turn_plan(request, &plan)?;
@@ -1022,13 +1079,14 @@ mod persistent_turn_tests {
use nana_store::{InMemoryStoryStore, StoryStore};
use super::{
ProviderError, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
hash_runtime_state,
BranchHistoryProjection, ProviderError, TurnControl, TurnEngine, TurnPlan,
TurnPlanProvider, TurnProjector, hash_runtime_state,
};
struct RecordingPlanProvider {
responses: VecDeque<Result<TurnPlan, ProviderError>>,
calls: usize,
histories: Vec<BranchHistoryProjection>,
}
impl RecordingPlanProvider {
@@ -1036,6 +1094,7 @@ mod persistent_turn_tests {
Self {
responses: VecDeque::from([response]),
calls: 0,
histories: Vec::new(),
}
}
}
@@ -1051,6 +1110,17 @@ mod persistent_turn_tests {
.pop_front()
.unwrap_or(Err(ProviderError::FixtureExhausted))
}
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
self.histories.push(branch_history.clone());
self.plan_turn_with_control(request, state, control)
}
}
struct CancellingPlanProvider {
@@ -1228,6 +1298,15 @@ mod persistent_turn_tests {
assert_eq!(result.player_view.branch_id, "branch_main");
assert_eq!(result.player_view.suggestions.len(), 1);
assert_eq!(engine.provider().calls, 1);
assert_eq!(engine.provider().histories.len(), 1);
assert_eq!(
engine.provider().histories[0]
.entries
.iter()
.map(|entry| entry.node_id.as_str())
.collect::<Vec<_>>(),
vec!["node_1"]
);
assert_eq!(engine.projector().calls, 1);
let committed = store
@@ -1237,6 +1316,42 @@ mod persistent_turn_tests {
assert_eq!(committed.world_flags.get("promise_spoken"), Some(&true));
}
#[test]
fn third_turn_receives_the_two_committed_ancestor_nodes() {
let store = seeded_store();
let mut second = node("node_2", Some("node_1"), "branch_main");
second.user_input = "SECOND PLAYER TURN".into();
second.presentation.beats.push(PresentationBeat {
id: "beat_second".into(),
kind: BeatKind::Dialogue,
speaker: Some("Nana".into()),
text: "SECOND COMMITTED REPLY".into(),
visual: None,
});
store
.append_node(&second, &state("node_2", "branch_main"))
.expect("second committed node");
let mut engine = TurnEngine::new(
&store,
RecordingPlanProvider::new(Ok(plan("node_3", StateDelta { ops: Vec::new() }))),
projector(&store),
);
engine.submit_turn(&request("node_2")).expect("third turn");
let history = &engine.provider().histories[0];
assert_eq!(
history
.entries
.iter()
.map(|entry| entry.node_id.as_str())
.collect::<Vec<_>>(),
vec!["node_1", "node_2"]
);
assert_eq!(history.entries[1].player_input, "SECOND PLAYER TURN");
assert_eq!(history.entries[1].beats[0].text, "SECOND COMMITTED REPLY");
}
#[test]
fn stale_request_does_not_call_provider_or_move_head() {
let store = seeded_store();
+275 -1
View File
@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, BTreeSet},
path::Path,
sync::{Mutex, MutexGuard},
time::Duration,
@@ -96,6 +96,18 @@ pub trait StoryStore: Send + Sync {
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
/// Loads the immutable path from the story root through `node_id`.
///
/// Parent links may cross branch identifiers because a branch created from
/// history shares its ancestors with the source branch. Implementations
/// therefore follow only `(story_id, node_id)` and never filter ancestors
/// by the current node's `branch_id`.
fn load_ancestor_chain(
&self,
story_id: &str,
node_id: &str,
) -> Result<Vec<StoryNode>, StoreError>;
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError>;
fn active_branch(&self, story_id: &str) -> Result<String, StoreError>;
@@ -332,6 +344,20 @@ impl StoryStore for InMemoryStoryStore {
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
}
fn load_ancestor_chain(
&self,
story_id: &str,
node_id: &str,
) -> Result<Vec<StoryNode>, StoreError> {
let data = self.lock()?;
walk_ancestor_chain(node_id, |candidate_id| {
Ok(data
.nodes
.get(&(story_id.to_owned(), candidate_id.to_owned()))
.cloned())
})
}
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError> {
let data = self.lock()?;
if !data
@@ -816,6 +842,44 @@ impl StoryStore for SqliteStoryStore {
Ok(node)
}
fn load_ancestor_chain(
&self,
story_id: &str,
node_id: &str,
) -> Result<Vec<StoryNode>, StoreError> {
let connection = self.lock()?;
let mut statement = connection.prepare(
"SELECT branch_id, parent_id, node_json
FROM nodes
WHERE story_id = ?1 AND node_id = ?2",
)?;
walk_ancestor_chain(node_id, |candidate_id| {
let stored = statement
.query_row(params![story_id, candidate_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
))
})
.optional()?;
let Some((branch_id, parent_id, node_json)) = stored else {
return Ok(None);
};
let node = deserialize_node(&node_json)?;
validate_loaded_node(
&node,
story_id,
candidate_id,
&branch_id,
parent_id.as_deref(),
)?;
Ok(Some(node))
})
}
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError> {
let connection = self.lock()?;
let story_exists = connection.query_row(
@@ -1727,6 +1791,34 @@ fn load_branch_state(
restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id)
}
fn walk_ancestor_chain(
node_id: &str,
mut load_node: impl FnMut(&str) -> Result<Option<StoryNode>, StoreError>,
) -> Result<Vec<StoryNode>, StoreError> {
let mut current_id = node_id.to_owned();
let mut visited = BTreeSet::new();
let mut chain = Vec::new();
loop {
if !visited.insert(current_id.clone()) {
return Err(StoreError::StateMismatch("ancestry cycle"));
}
let node = load_node(&current_id)?
.ok_or_else(|| StoreError::ParentNotFound(current_id.clone()))?;
let parent_id = node.parent_id.clone();
chain.push(node);
let Some(parent_id) = parent_id else {
break;
};
current_id = parent_id;
}
chain.reverse();
Ok(chain)
}
fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
if node.story_id != state.story_id {
return Err(StoreError::StateMismatch("story_id"));
@@ -2075,6 +2167,104 @@ mod tests {
.expect("main append");
}
fn assert_loads_only_the_selected_branch_path(store: &impl InspectableStoryStore) {
seed_historical_main(store);
store
.fork_branch("story_demo", "node_001", "branch_rewind")
.expect("historical fork");
store
.append_node(
&node("node_rewind", Some("node_001"), "branch_rewind"),
&state("node_rewind", "branch_rewind"),
)
.expect("rewind append");
let main_path = store
.load_ancestor_chain("story_demo", "node_002")
.expect("main ancestry");
assert_eq!(
main_path
.iter()
.map(|node| node.id.as_str())
.collect::<Vec<_>>(),
vec!["node_001", "node_002"]
);
let rewind_path = store
.load_ancestor_chain("story_demo", "node_rewind")
.expect("rewind ancestry");
assert_eq!(
rewind_path
.iter()
.map(|node| (node.id.as_str(), node.branch_id.as_str()))
.collect::<Vec<_>>(),
vec![
("node_001", "branch_main"),
("node_rewind", "branch_rewind"),
]
);
}
fn assert_loads_a_500_node_ancestor_chain(store: &impl InspectableStoryStore) {
let mut parent_id = None;
for index in 0..500 {
let node_id = format!("node_{index:03}");
store
.append_node(
&node(&node_id, parent_id.as_deref(), "branch_main"),
&state(&node_id, "branch_main"),
)
.expect("long-chain append");
parent_id = Some(node_id);
}
let chain = store
.load_ancestor_chain("story_demo", "node_499")
.expect("500-node ancestry");
assert_eq!(chain.len(), 500);
assert_eq!(chain.first().map(|node| node.id.as_str()), Some("node_000"));
assert_eq!(chain.last().map(|node| node.id.as_str()), Some("node_499"));
}
fn assert_reports_an_unknown_ancestor_start(store: &impl InspectableStoryStore) {
assert_eq!(
store.load_ancestor_chain("story_demo", "node_missing"),
Err(StoreError::ParentNotFound("node_missing".to_owned()))
);
}
fn set_memory_parent(store: &InMemoryStoryStore, node_id: &str, parent_id: Option<&str>) {
let mut data = store.data.lock().expect("memory store lock");
data.nodes
.get_mut(&("story_demo".to_owned(), node_id.to_owned()))
.expect("test node")
.parent_id = parent_id.map(ToOwned::to_owned);
}
fn set_sqlite_parent(store: &SqliteStoryStore, node_id: &str, parent_id: Option<&str>) {
let mut corrupted = store
.load_node("story_demo", node_id)
.expect("stored test node");
corrupted.parent_id = parent_id.map(ToOwned::to_owned);
let node_json = serde_json::to_string(&corrupted).expect("serializable test node");
let connection = store.connection.lock().expect("SQLite connection lock");
connection
.execute_batch("PRAGMA foreign_keys = OFF;")
.expect("disable foreign keys for corruption test");
connection
.execute(
"UPDATE nodes
SET parent_id = ?1, node_json = ?2
WHERE story_id = 'story_demo' AND node_id = ?3",
params![parent_id, node_json, node_id],
)
.expect("corrupt test ancestry");
connection
.execute_batch("PRAGMA foreign_keys = ON;")
.expect("restore foreign keys after corruption test");
}
fn assert_creates_independent_branches_from_history(store: &impl InspectableStoryStore) {
seed_historical_main(store);
@@ -2277,6 +2467,90 @@ mod tests {
assert_forks_from_an_old_node(&store);
}
#[test]
fn memory_loads_ancestors_across_shared_branch_history() {
let store = InMemoryStoryStore::new();
assert_loads_only_the_selected_branch_path(&store);
}
#[test]
fn sqlite_loads_ancestors_across_shared_branch_history() {
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
assert_loads_only_the_selected_branch_path(&store);
}
#[test]
fn memory_loads_500_ancestors_without_a_ui_depth_limit() {
let store = InMemoryStoryStore::new();
assert_loads_a_500_node_ancestor_chain(&store);
}
#[test]
fn sqlite_loads_500_ancestors_without_a_ui_depth_limit() {
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
assert_loads_a_500_node_ancestor_chain(&store);
}
#[test]
fn memory_reports_an_unknown_ancestor_start() {
let store = InMemoryStoryStore::new();
assert_reports_an_unknown_ancestor_start(&store);
}
#[test]
fn sqlite_reports_an_unknown_ancestor_start() {
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
assert_reports_an_unknown_ancestor_start(&store);
}
#[test]
fn memory_reports_a_broken_ancestor_link() {
let store = InMemoryStoryStore::new();
seed_historical_main(&store);
set_memory_parent(&store, "node_002", Some("node_missing"));
assert_eq!(
store.load_ancestor_chain("story_demo", "node_002"),
Err(StoreError::ParentNotFound("node_missing".to_owned()))
);
}
#[test]
fn sqlite_reports_a_broken_ancestor_link() {
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
seed_historical_main(&store);
set_sqlite_parent(&store, "node_002", Some("node_missing"));
assert_eq!(
store.load_ancestor_chain("story_demo", "node_002"),
Err(StoreError::ParentNotFound("node_missing".to_owned()))
);
}
#[test]
fn memory_reports_an_ancestor_cycle() {
let store = InMemoryStoryStore::new();
seed_historical_main(&store);
set_memory_parent(&store, "node_001", Some("node_002"));
assert_eq!(
store.load_ancestor_chain("story_demo", "node_002"),
Err(StoreError::StateMismatch("ancestry cycle"))
);
}
#[test]
fn sqlite_reports_an_ancestor_cycle() {
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
seed_historical_main(&store);
set_sqlite_parent(&store, "node_001", Some("node_002"));
assert_eq!(
store.load_ancestor_chain("story_demo", "node_002"),
Err(StoreError::StateMismatch("ancestry cycle"))
);
}
#[test]
fn memory_creates_independent_branch_heads_from_historical_nodes() {
let store = InMemoryStoryStore::new();
+59
View File
@@ -0,0 +1,59 @@
# M2 第七波本地开工状态
日期:2026-07-28
## 本地基线
- 从私有 Gitea 安全恢复 `integration/v1@23672e857b`,并通过 `git fsck` 检查对象完整性。
- 下载时使用的临时仓库只读 Token 已在 Gitea 撤销;临时配置、bundle 与内存中的凭据均
已清理。
- `origin` 已恢复为 Gitea SSH 地址,分支仍跟踪 `origin/integration/v1`。当前执行环境的
网络代理会关闭 SSH 2222 端口,因此本轮只提交到本地,不把“已配置公钥”误报为“已成功
推送”。
- 相邻 `lapp-rs` 已固定在 `5ba3c659e1536ec4bee16340faca603940a5cb17`,未修改其源码。
- 本地基线提交为 `37b3397 chore: establish wave 7 local baseline`
## 已完成
### Windows 可重复基线
- 新增 `.gitattributes`,固定文本 LF 与图片、音频二进制属性。
- Node 与 Rust 契约源码哈希在计算前统一 LF,避免 Windows CRLF checkout 产生假漂移。
- 增加 LF / CRLF 哈希等价测试。
- 安装并锁定 Node 24、pnpm 10.29.2、Rust 1.96.0、rustfmt 与 clippy。
- 使用锁文件安装 JavaScript 依赖。
### 当前分支连续上下文
- Store 新增按 `(story_id, node_id)``parent_id` 读取根到当前节点祖先链的接口。
- Memory 与 SQLite 后端均检测缺失父节点和父链循环;读取不依赖 `branch_id`,允许分叉
复用共享祖先,同时不会遍历兄弟分支。
- Runtime 在生成本轮前自动投影当前祖先链,并把已提交的玩家输入、演出节拍、场景和角色
视觉状态传给 Provider;未选择的行动建议不会被误当成历史事实。
- LAPP 普通回合与隐藏判定回合共用同一套缓存友好上下文协议:
`stable_prefix → branch_history → dynamic_tail`
- 动态状态只包含可用于角色扮演的安全投影,包括关系阶段与玩家可见持有物的归属、取得
方式;精确关系数值、隐藏骰点、NPC 私物、未触发世界书和状态 delta 仍不能进入提示。
- Tauri 的线路恢复复用 Store 祖先链接口,不再保留旧的 200 节点读取上限。
## 验证
- `node scripts/verify-contracts.mjs`25 份契约无漂移。
- `pnpm verify:web`TypeScript 检查、5 个测试文件 / 29 项测试及生产构建通过。
- `cargo metadata --no-deps`:通过,相邻 `lapp-rs` 路径依赖可解析。
- `cargo fmt --all -- --check`:通过。
- `git diff --check`:通过。
完整 Rust 测试、Clippy、Tauri 后端测试和 Windows 桌面编译尚未关闭,原因不是项目依赖
解析,而是本机缺少 Microsoft C++ Build Tools 与 Windows SDK`rustc` 当前找不到
`link.exe`
## 下一步
1. 安装 Microsoft C++ Build Tools 与 Windows SDK 后立即运行 `pnpm verify:rust`
`pnpm tauri build --no-bundle`
2. 为长分支实现非权威上下文检查点、来源哈希和 SQLite v3 迁移;历史不得静默截断。
3. 在连续上下文门禁关闭后,实现“风险预检 → 玩家确认 → 隐藏判定失败 → 推骰 / 重新
生成”纵向切片。
4. 最后在隔离存档上启动 Demo 窗口,验证重启恢复、终局、回溯和双线路隔离,再运行真实
LAPP 最小连接与隐藏判定工具调用。
+41 -19
View File
@@ -24,9 +24,10 @@ use nana_engine::{
SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
};
use nana_runtime::{
AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, LappNativeCallGate,
LappNativeCallPermit, OpenLappChatExecutor, ProviderError, TurnControl, TurnEngine, TurnPlan,
TurnPlanProvider, TurnProjector, load_default_lapp_profile,
AdjudicatingTurnPlanProvider, AdjudicationCatalog, BranchHistoryProjection,
LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit, OpenLappChatExecutor,
ProviderError, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
load_default_lapp_profile,
};
use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore};
use openlapp::{
@@ -359,6 +360,41 @@ impl TurnPlanProvider for RuntimePlanProvider {
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),
}
}
}
impl DemoAppState {
@@ -905,22 +941,8 @@ impl DemoAppState {
}
fn load_lineage(&self, current: &StoryNode) -> Result<Vec<StoryNode>, StoreError> {
let mut lineage = vec![current.clone()];
let mut cursor = current.parent_id.clone();
while let Some(ref node_id) = cursor {
if lineage.len() >= 200 {
return Err(StoreError::StateMismatch(
"story lineage exceeds the projection limit",
));
}
let node = self.store.load_node(&current.story_id, node_id)?;
cursor.clone_from(&node.parent_id);
lineage.push(node);
}
lineage.reverse();
Ok(lineage)
self.store
.load_ancestor_chain(&current.story_id, &current.id)
}
}
+4 -4
View File
@@ -34,10 +34,10 @@
## Task 4-5:连续上下文与检查点
- [ ] Store 按 parent_id 读取当前祖先链。
- [ ] 第三轮包含前两轮原始剧情。
- [ ] 兄弟分支上下文隔离。
- [ ] 稳定前缀与动态尾部固定编排。
- [x] Store 按 parent_id 读取当前祖先链。
- [x] 第三轮包含前两轮原始剧情。
- [x] 兄弟分支上下文隔离。
- [x] 稳定前缀与动态尾部固定编排。
- [ ] 超预算检查点与来源哈希。
- [ ] SQLite v3 迁移与 500 节点测试。