This commit is contained in:
@@ -2,18 +2,23 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use nana_domain::{
|
||||
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 thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
BranchHistoryProjection, InvalidModelOutputKind, ProviderError, TurnControl, TurnPlan,
|
||||
TurnPlanProvider, provider_interruption,
|
||||
BranchHistoryProjection, InvalidModelOutputKind, NarrativeCheckpoint, ProviderError,
|
||||
TurnContextPreparation, TurnControl, TurnPlan, TurnPlanProvider, provider_interruption,
|
||||
};
|
||||
|
||||
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.
|
||||
///
|
||||
@@ -153,6 +158,46 @@ pub trait AdjudicationModel {
|
||||
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)]
|
||||
@@ -193,6 +238,8 @@ pub enum AdjudicationError {
|
||||
DuplicateItem(String),
|
||||
#[error("check id was already used: {0}")]
|
||||
DuplicateCheckId(String),
|
||||
#[error("one player action may request at most one hidden check")]
|
||||
TooManyHiddenChecks,
|
||||
#[error("model supplied a RecordCheck state operation")]
|
||||
ModelSuppliedRecordCheck,
|
||||
#[error("regeneration requested a new hidden check")]
|
||||
@@ -426,6 +473,11 @@ impl<Model> AdjudicatingTurnPlanProvider<Model> {
|
||||
&self.model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn model_mut(&mut self) -> &mut Model {
|
||||
&mut self.model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_model(self) -> Model {
|
||||
self.model
|
||||
@@ -507,6 +559,9 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
|
||||
if !check_ids.insert(proposed.check_id.clone()) {
|
||||
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() {
|
||||
return Err(AdjudicationError::PushedCheckMismatch.into());
|
||||
}
|
||||
@@ -609,6 +664,39 @@ impl<Model: AdjudicationModel> TurnPlanProvider for AdjudicatingTurnPlanProvider
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_context_checkpoints(&self) -> bool {
|
||||
self.model.uses_context_checkpoints()
|
||||
}
|
||||
|
||||
fn prepare_turn_context_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
source_nodes: &[StoryNode],
|
||||
expected_history_head_node_id: &str,
|
||||
checkpoint: Option<&NarrativeCheckpoint>,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnContextPreparation, ProviderError> {
|
||||
self.model.prepare_turn_context_with_control(
|
||||
request,
|
||||
state,
|
||||
source_nodes,
|
||||
expected_history_head_node_id,
|
||||
checkpoint,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_prospective_context_with_control(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
control: &TurnControl,
|
||||
) -> Result<(), ProviderError> {
|
||||
self.model
|
||||
.validate_prospective_context_with_control(state, node, control)
|
||||
}
|
||||
}
|
||||
|
||||
fn select_bound_actor<'a, T>(
|
||||
@@ -1231,7 +1319,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_distinct_checks_are_buffered_until_one_final_plan() {
|
||||
fn a_second_distinct_check_is_rejected_for_one_player_action() {
|
||||
let mut second = hidden_check("check_2");
|
||||
second.actor_id = "nana".into();
|
||||
second.skill = "Listen".into();
|
||||
@@ -1245,19 +1333,12 @@ mod tests {
|
||||
]);
|
||||
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog());
|
||||
|
||||
let planned = provider
|
||||
.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state())
|
||||
.expect("two checks then one plan");
|
||||
let ids = planned
|
||||
.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"]);
|
||||
assert!(matches!(
|
||||
provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
|
||||
Err(AdjudicationRunError::Rejected(
|
||||
AdjudicationError::TooManyHiddenChecks
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1531,7 +1612,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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(
|
||||
ScriptedModel::new([
|
||||
tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check(
|
||||
@@ -1542,12 +1623,12 @@ mod tests {
|
||||
))),
|
||||
]),
|
||||
catalog(),
|
||||
2,
|
||||
3,
|
||||
);
|
||||
assert!(matches!(
|
||||
provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
|
||||
Err(AdjudicationRunError::Rejected(
|
||||
AdjudicationError::StepBudgetExceeded
|
||||
AdjudicationError::TooManyHiddenChecks
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use nana_store::{
|
||||
CanonicalSha256, ContextCheckpointError, ContextCheckpointInput, StoredContextCheckpoint,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::context::{
|
||||
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NarrativeCheckpoint, NarrativeCheckpointSourceHash,
|
||||
NarrativeCheckpointSummary, StablePrefixHash, SummaryClassification,
|
||||
};
|
||||
|
||||
/// A strict runtime-to-storage mapping failure.
|
||||
///
|
||||
/// Loading is deliberately different: a malformed disposable cache row is a
|
||||
/// cache miss, not a story/storage failure. Saving a freshly produced runtime
|
||||
/// checkpoint must instead explain why it could not cross the storage
|
||||
/// boundary.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum CheckpointStoreMappingError {
|
||||
#[error("context checkpoint summary has the wrong classification")]
|
||||
InvalidSummaryClassification,
|
||||
#[error("context checkpoint summary is blank")]
|
||||
BlankSummary,
|
||||
#[error("context checkpoint summary exceeds the runtime byte limit")]
|
||||
SummaryTooLarge,
|
||||
#[error("context checkpoint summary could not be serialized")]
|
||||
SummarySerialization,
|
||||
#[error("context checkpoint stable-prefix hash is not canonical")]
|
||||
InvalidStablePrefixHash,
|
||||
#[error("context checkpoint source hash is not canonical")]
|
||||
InvalidSourceHash,
|
||||
#[error("context checkpoint could not be represented by the store: {0}")]
|
||||
InvalidStoredCheckpoint(ContextCheckpointError),
|
||||
}
|
||||
|
||||
/// Converts a structurally loaded storage record into a runtime checkpoint.
|
||||
///
|
||||
/// Checkpoints are only a disposable optimization. Any malformed JSON,
|
||||
/// classification, summary, or typed hash is therefore treated as a cache
|
||||
/// miss. In particular, this function never turns corrupt cache contents into
|
||||
/// a [`nana_store::StoreError`].
|
||||
#[must_use]
|
||||
pub fn runtime_checkpoint_from_stored(
|
||||
stored: &StoredContextCheckpoint,
|
||||
) -> Option<NarrativeCheckpoint> {
|
||||
runtime_checkpoint_from_parts(StoredCheckpointParts {
|
||||
story_id: stored.story_id(),
|
||||
at_node_id: stored.at_node_id(),
|
||||
covered_through_node_id: stored.covered_through_node_id(),
|
||||
retained_from_node_id: stored.retained_from_node_id(),
|
||||
checkpoint_schema_version: stored.checkpoint_schema_version(),
|
||||
prompt_schema_version: stored.prompt_schema_version(),
|
||||
stable_prefix_hash: stored.stable_prefix_hash().as_str(),
|
||||
summary_json: stored.summary_json(),
|
||||
source_hash: stored.source_hash().as_str(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a trusted runtime checkpoint into the store's checked cache type.
|
||||
///
|
||||
/// Only [`NarrativeCheckpointSummary`] is serialized into `summary_json`; range
|
||||
/// metadata and hashes stay in their dedicated store columns.
|
||||
pub fn stored_checkpoint_from_runtime(
|
||||
checkpoint: &NarrativeCheckpoint,
|
||||
) -> Result<StoredContextCheckpoint, CheckpointStoreMappingError> {
|
||||
validate_summary(&checkpoint.summary)?;
|
||||
|
||||
let summary_json = serde_json::to_string(&checkpoint.summary)
|
||||
.map_err(|_| CheckpointStoreMappingError::SummarySerialization)?;
|
||||
let stable_prefix_hash = CanonicalSha256::from_str(checkpoint.stable_prefix_hash.as_str())
|
||||
.map_err(|_| CheckpointStoreMappingError::InvalidStablePrefixHash)?;
|
||||
let source_hash = CanonicalSha256::from_str(checkpoint.source_hash.as_str())
|
||||
.map_err(|_| CheckpointStoreMappingError::InvalidSourceHash)?;
|
||||
|
||||
StoredContextCheckpoint::new(ContextCheckpointInput {
|
||||
story_id: checkpoint.story_id.clone(),
|
||||
at_node_id: checkpoint.at_node_id.clone(),
|
||||
covered_through_node_id: checkpoint.covered_through_node_id.clone(),
|
||||
retained_from_node_id: checkpoint.retained_from_node_id.clone(),
|
||||
checkpoint_schema_version: checkpoint.checkpoint_schema_version,
|
||||
prompt_schema_version: checkpoint.prompt_schema_version,
|
||||
stable_prefix_hash,
|
||||
summary_json,
|
||||
source_hash,
|
||||
})
|
||||
.map_err(CheckpointStoreMappingError::InvalidStoredCheckpoint)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct StoredCheckpointParts<'a> {
|
||||
story_id: &'a str,
|
||||
at_node_id: &'a str,
|
||||
covered_through_node_id: &'a str,
|
||||
retained_from_node_id: Option<&'a str>,
|
||||
checkpoint_schema_version: u32,
|
||||
prompt_schema_version: u32,
|
||||
stable_prefix_hash: &'a str,
|
||||
summary_json: &'a str,
|
||||
source_hash: &'a str,
|
||||
}
|
||||
|
||||
fn runtime_checkpoint_from_parts(parts: StoredCheckpointParts<'_>) -> Option<NarrativeCheckpoint> {
|
||||
let summary = serde_json::from_str::<NarrativeCheckpointSummary>(parts.summary_json).ok()?;
|
||||
validate_summary(&summary).ok()?;
|
||||
let stable_prefix_hash =
|
||||
StablePrefixHash::try_from(parts.stable_prefix_hash.to_owned()).ok()?;
|
||||
let source_hash = NarrativeCheckpointSourceHash::try_from(parts.source_hash.to_owned()).ok()?;
|
||||
|
||||
Some(NarrativeCheckpoint {
|
||||
story_id: parts.story_id.to_owned(),
|
||||
at_node_id: parts.at_node_id.to_owned(),
|
||||
covered_through_node_id: parts.covered_through_node_id.to_owned(),
|
||||
retained_from_node_id: parts.retained_from_node_id.map(str::to_owned),
|
||||
checkpoint_schema_version: parts.checkpoint_schema_version,
|
||||
prompt_schema_version: parts.prompt_schema_version,
|
||||
stable_prefix_hash,
|
||||
summary,
|
||||
source_hash,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_summary(
|
||||
summary: &NarrativeCheckpointSummary,
|
||||
) -> Result<(), CheckpointStoreMappingError> {
|
||||
if summary.classification != SummaryClassification::NonAuthoritativeNarrative {
|
||||
return Err(CheckpointStoreMappingError::InvalidSummaryClassification);
|
||||
}
|
||||
if summary.text.trim().is_empty() {
|
||||
return Err(CheckpointStoreMappingError::BlankSummary);
|
||||
}
|
||||
if summary.text.len() > MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES {
|
||||
return Err(CheckpointStoreMappingError::SummaryTooLarge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CheckpointStoreMappingError, StoredCheckpointParts, runtime_checkpoint_from_parts,
|
||||
runtime_checkpoint_from_stored, stored_checkpoint_from_runtime,
|
||||
};
|
||||
use crate::context::{
|
||||
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
|
||||
NarrativeCheckpoint, NarrativeCheckpointSourceHash, NarrativeCheckpointSummary,
|
||||
SCENE_PROMPT_SCHEMA_VERSION, StablePrefixHash, SummaryClassification,
|
||||
};
|
||||
|
||||
const HASH_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const HASH_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
fn runtime_checkpoint() -> NarrativeCheckpoint {
|
||||
NarrativeCheckpoint {
|
||||
story_id: "story_demo".to_owned(),
|
||||
at_node_id: "node_004".to_owned(),
|
||||
covered_through_node_id: "node_002".to_owned(),
|
||||
retained_from_node_id: Some("node_003".to_owned()),
|
||||
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
|
||||
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
|
||||
stable_prefix_hash: StablePrefixHash::try_from(HASH_A.to_owned())
|
||||
.expect("valid stable-prefix hash"),
|
||||
summary: NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: "娜娜记得玩家答应在天亮前回来。".to_owned(),
|
||||
},
|
||||
source_hash: NarrativeCheckpointSourceHash::try_from(HASH_B.to_owned())
|
||||
.expect("valid source hash"),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_parts(summary_json: &str) -> StoredCheckpointParts<'_> {
|
||||
StoredCheckpointParts {
|
||||
story_id: "story_demo",
|
||||
at_node_id: "node_004",
|
||||
covered_through_node_id: "node_002",
|
||||
retained_from_node_id: Some("node_003"),
|
||||
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
|
||||
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
|
||||
stable_prefix_hash: HASH_A,
|
||||
summary_json,
|
||||
source_hash: HASH_B,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_serializes_only_the_summary_payload() {
|
||||
let runtime = runtime_checkpoint();
|
||||
let stored =
|
||||
stored_checkpoint_from_runtime(&runtime).expect("runtime checkpoint should map");
|
||||
|
||||
let expected_summary =
|
||||
serde_json::to_string(&runtime.summary).expect("summary should serialize");
|
||||
assert_eq!(stored.summary_json(), expected_summary);
|
||||
assert!(!stored.summary_json().contains("story_demo"));
|
||||
assert!(!stored.summary_json().contains("node_004"));
|
||||
assert_eq!(runtime_checkpoint_from_stored(&stored), Some(runtime));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_summary_json_is_a_cache_miss() {
|
||||
assert_eq!(
|
||||
runtime_checkpoint_from_parts(valid_parts("{not-json")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_summary_classification_is_a_cache_miss() {
|
||||
let summary = r#"{"classification":"authoritative_fact","text":"should miss"}"#;
|
||||
assert_eq!(runtime_checkpoint_from_parts(valid_parts(summary)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_or_oversized_summary_is_a_cache_miss() {
|
||||
let blank = r#"{"classification":"non_authoritative_narrative","text":" \n\t "}"#;
|
||||
assert_eq!(runtime_checkpoint_from_parts(valid_parts(blank)), None);
|
||||
|
||||
let oversized_text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
|
||||
let oversized = serde_json::to_string(&NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: oversized_text,
|
||||
})
|
||||
.expect("summary should serialize");
|
||||
assert_eq!(runtime_checkpoint_from_parts(valid_parts(&oversized)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_typed_hashes_are_cache_misses() {
|
||||
let summary =
|
||||
serde_json::to_string(&runtime_checkpoint().summary).expect("summary should serialize");
|
||||
|
||||
let mut invalid_stable = valid_parts(&summary);
|
||||
invalid_stable.stable_prefix_hash =
|
||||
"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
assert_eq!(runtime_checkpoint_from_parts(invalid_stable), None);
|
||||
|
||||
let mut invalid_source = valid_parts(&summary);
|
||||
invalid_source.source_hash = "sha256:not-a-hash";
|
||||
assert_eq!(runtime_checkpoint_from_parts(invalid_source), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_to_store_rejects_bad_summary_before_persistence() {
|
||||
let mut checkpoint = runtime_checkpoint();
|
||||
checkpoint.summary.text = " \t ".to_owned();
|
||||
assert_eq!(
|
||||
stored_checkpoint_from_runtime(&checkpoint),
|
||||
Err(CheckpointStoreMappingError::BlankSummary)
|
||||
);
|
||||
|
||||
checkpoint.summary.text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
|
||||
assert_eq!(
|
||||
stored_checkpoint_from_runtime(&checkpoint),
|
||||
Err(CheckpointStoreMappingError::SummaryTooLarge)
|
||||
);
|
||||
}
|
||||
}
|
||||
+1127
-33
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+398
-19
@@ -5,36 +5,48 @@ use nana_domain::{
|
||||
TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry,
|
||||
};
|
||||
use nana_engine::{ReduceError, apply_delta};
|
||||
use nana_store::{ForkError, StoreError, StoryStore};
|
||||
use nana_store::{ForkError, StoreError, StoredContextCheckpoint, StoryStore};
|
||||
use thiserror::Error;
|
||||
|
||||
mod adjudication;
|
||||
mod checkpoint_store;
|
||||
mod context;
|
||||
mod lapp_provider;
|
||||
mod lifecycle;
|
||||
mod summary;
|
||||
|
||||
pub use adjudication::{
|
||||
AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationError, AdjudicationModel,
|
||||
AdjudicationModelInput, AdjudicationModelResponse, AdjudicationRunError, AdjudicationToolCall,
|
||||
CatalogError, DEFAULT_MAX_ADJUDICATION_STEPS, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest,
|
||||
QualitativeCheckOutcome, classify_roll, deterministic_roll,
|
||||
MAX_HIDDEN_CHECKS_PER_TURN, QualitativeCheckOutcome, classify_roll, deterministic_roll,
|
||||
};
|
||||
pub use checkpoint_store::{
|
||||
CheckpointStoreMappingError, runtime_checkpoint_from_stored, stored_checkpoint_from_runtime,
|
||||
};
|
||||
pub use context::{
|
||||
BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry, BranchHistoryProjection,
|
||||
BranchHistoryScene, CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard,
|
||||
BranchContext, BranchHistoryBeat, BranchHistoryCharacter, BranchHistoryEntry,
|
||||
BranchHistoryProjection, BranchHistoryScene, CharacterMemory, CheckpointDisposition,
|
||||
CheckpointMissReason, CompiledSceneContext, ContextBudget, ContextCharacterCard,
|
||||
ContextCheckOutcome, ContextCompileError, ContextInventoryItem, ContextJudgmentRule,
|
||||
ContextPersona, ContextPlotEvent, ContextPlotOutcome, ContextPlotPressure, ContextSkill,
|
||||
ContextStateMemory, ContextStatePosition, ContextSummary, ContextTurn, ContextWorldBookEntry,
|
||||
HiddenCheckTreatment, NARRATIVE_CHECKPOINT_SOURCE_SCHEMA_VERSION, NarrativeCheckpointHashError,
|
||||
NarrativeCheckpointSourceEntry, NarrativeCheckpointSourceHash,
|
||||
NarrativeCheckpointSourceProjection, NarrativeSafety, PlayerMemory, ResourceProvenance,
|
||||
ContextPersona, ContextPlotEvent, ContextPlotOutcome, ContextPlotPressure, ContextPreparation,
|
||||
ContextPreparationError, ContextSkill, ContextStateMemory, ContextStatePosition,
|
||||
ContextSummary, ContextTurn, ContextWorldBookEntry, HIDDEN_CHECK_CONTINUATION_RESERVE_TOKENS,
|
||||
HiddenCheckTreatment, MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, MESSAGE_FRAMING_RESERVE_TOKENS,
|
||||
NARRATIVE_CHECKPOINT_SCHEMA_VERSION, NARRATIVE_CHECKPOINT_SOURCE_SCHEMA_VERSION,
|
||||
NarrativeCheckpoint, NarrativeCheckpointHashError, NarrativeCheckpointSourceEntry,
|
||||
NarrativeCheckpointSourceHash, NarrativeCheckpointSourceProjection, NarrativeCheckpointSummary,
|
||||
NarrativeSafety, NeedsCompaction, PROMPT_HIGH_WATERMARK_PERCENT,
|
||||
PROMPT_LOWER_WATERMARK_PERCENT, PROMPT_SAFETY_RESERVE_TOKENS, PlayerMemory, PromptBudget,
|
||||
PromptBudgetError, PromptNarrativeCheckpoint, PromptReserves, ResourceProvenance,
|
||||
ResourceStringTreatment, SCENE_CONTEXT_SCHEMA_VERSION, SCENE_PROMPT_SCHEMA_VERSION,
|
||||
STABLE_PREFIX_HASH_SCHEMA_VERSION, SceneContext, SharedMemory, StablePrefixHash,
|
||||
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, narrative_checkpoint_source_hash,
|
||||
narrative_checkpoint_source_projection, stable_prefix_hash,
|
||||
STABLE_PREFIX_HASH_SCHEMA_VERSION, SYSTEM_PROMPT_RESERVE_TOKENS, SceneContext, SharedMemory,
|
||||
StablePrefixHash, SummaryClassification, SummaryMemory, SummaryTreatment,
|
||||
TOOL_SCHEMA_RESERVE_TOKENS, 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, narrative_checkpoint_source_hash,
|
||||
narrative_checkpoint_source_projection, prepare_compiled_scene_prompt, stable_prefix_hash,
|
||||
validate_latest_history_entry_fits,
|
||||
};
|
||||
pub use lapp_provider::{
|
||||
CONSERVATIVE_CONTEXT_WINDOW_TOKENS, CONSERVATIVE_MAX_OUTPUT_TOKENS, ChatExecutor,
|
||||
@@ -43,6 +55,10 @@ pub use lapp_provider::{
|
||||
TURN_PLAN_TOOL_NAME,
|
||||
};
|
||||
pub use lifecycle::{TurnControl, TurnInterruption};
|
||||
pub use summary::{
|
||||
ContextSummaryModel, MAX_CONTEXT_SUMMARY_BYTES, MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES,
|
||||
SummaryRequest, SummaryRequestError, SummaryResult, SummaryResultError,
|
||||
};
|
||||
|
||||
pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17";
|
||||
pub const MAX_WORLD_BOOK_ENTRIES: usize = 8;
|
||||
@@ -96,6 +112,18 @@ pub struct TurnPlan {
|
||||
pub delta: StateDelta,
|
||||
}
|
||||
|
||||
/// Result of a provider's optional checkpoint-aware prompt preparation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TurnContextPreparation {
|
||||
/// The provider does not use the runtime checkpoint protocol.
|
||||
Unmanaged,
|
||||
/// The exact prompt for this turn is staged inside the provider.
|
||||
Prepared {
|
||||
/// A newly generated disposable cache record for the engine to persist.
|
||||
checkpoint: Option<NarrativeCheckpoint>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Produces the uncommitted model plan for a turn.
|
||||
pub trait TurnPlanProvider {
|
||||
fn plan_turn(
|
||||
@@ -155,6 +183,57 @@ pub trait TurnPlanProvider {
|
||||
let _ = branch_history;
|
||||
self.plan_turn_with_control(request, state, control)
|
||||
}
|
||||
|
||||
/// Whether this provider participates in the runtime checkpoint protocol.
|
||||
#[must_use]
|
||||
fn uses_context_checkpoints(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Prepare and stage the exact prompt used by the subsequent plan call.
|
||||
///
|
||||
/// The engine owns persistence and supplies only the current trusted
|
||||
/// root-to-head source path. Implementations must not query a store.
|
||||
fn prepare_turn_context_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
source_nodes: &[StoryNode],
|
||||
expected_history_head_node_id: &str,
|
||||
checkpoint: Option<&NarrativeCheckpoint>,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnContextPreparation, ProviderError> {
|
||||
let _ = (
|
||||
request,
|
||||
state,
|
||||
source_nodes,
|
||||
expected_history_head_node_id,
|
||||
checkpoint,
|
||||
);
|
||||
if let Some(interruption) = control.interruption() {
|
||||
return Err(provider_interruption(interruption));
|
||||
}
|
||||
Ok(TurnContextPreparation::Unmanaged)
|
||||
}
|
||||
|
||||
/// Reject a generated node that could not be supplied as the mandatory
|
||||
/// newest raw history entry on a later turn.
|
||||
///
|
||||
/// Context-managed providers should use the same model budget and resource
|
||||
/// compiler as normal prompt preparation. The default keeps deterministic
|
||||
/// and legacy providers source-compatible.
|
||||
fn validate_prospective_context_with_control(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
control: &TurnControl,
|
||||
) -> Result<(), ProviderError> {
|
||||
let _ = (state, node);
|
||||
if let Some(interruption) = control.interruption() {
|
||||
return Err(provider_interruption(interruption));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
|
||||
@@ -193,6 +272,38 @@ impl<Provider: TurnPlanProvider + ?Sized> TurnPlanProvider for &mut Provider {
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
(**self).plan_turn_with_history_and_control(request, state, branch_history, control)
|
||||
}
|
||||
|
||||
fn uses_context_checkpoints(&self) -> bool {
|
||||
(**self).uses_context_checkpoints()
|
||||
}
|
||||
|
||||
fn prepare_turn_context_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
source_nodes: &[StoryNode],
|
||||
expected_history_head_node_id: &str,
|
||||
checkpoint: Option<&NarrativeCheckpoint>,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnContextPreparation, ProviderError> {
|
||||
(**self).prepare_turn_context_with_control(
|
||||
request,
|
||||
state,
|
||||
source_nodes,
|
||||
expected_history_head_node_id,
|
||||
checkpoint,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_prospective_context_with_control(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
control: &TurnControl,
|
||||
) -> Result<(), ProviderError> {
|
||||
(**self).validate_prospective_context_with_control(state, node, control)
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects only already-committed state into the player-safe read model.
|
||||
@@ -269,6 +380,13 @@ where
|
||||
.store
|
||||
.load_ancestor_chain(&request.story_id, ¤t.current_node)
|
||||
.map_err(|error| map_store_error(&error))?;
|
||||
let pending_checkpoint = self.prepare_provider_context(
|
||||
request,
|
||||
¤t,
|
||||
&ancestor_chain,
|
||||
¤t.current_node,
|
||||
control,
|
||||
)?;
|
||||
let branch_history = BranchHistoryProjection::from_committed_nodes(ancestor_chain.iter());
|
||||
let plan = self
|
||||
.provider
|
||||
@@ -292,6 +410,9 @@ where
|
||||
delta: plan.delta,
|
||||
state_hash,
|
||||
};
|
||||
self.provider
|
||||
.validate_prospective_context_with_control(&committed, &node, control)
|
||||
.map_err(|error| map_provider_error(&error))?;
|
||||
|
||||
control.begin_commit().map_err(|error| match error {
|
||||
lifecycle::BeginCommitError::Cancelled => cancelled_turn(),
|
||||
@@ -301,7 +422,7 @@ where
|
||||
}
|
||||
})?;
|
||||
self.store
|
||||
.append_node(&node, &committed)
|
||||
.append_node_with_checkpoint(&node, &committed, pending_checkpoint.as_ref())
|
||||
.map_err(|error| map_store_error(&error))?;
|
||||
|
||||
let mut player_view = self.projector.project_committed_turn(&committed, &node);
|
||||
@@ -355,6 +476,13 @@ where
|
||||
// never treats arbitrary request text as an edit.
|
||||
input: replaced_node.user_input.clone(),
|
||||
};
|
||||
let pending_checkpoint = self.prepare_provider_context(
|
||||
&provider_request,
|
||||
®eneration_state,
|
||||
&ancestor_chain,
|
||||
parent_id,
|
||||
control,
|
||||
)?;
|
||||
let plan = self
|
||||
.provider
|
||||
.plan_turn_with_history_and_control(
|
||||
@@ -400,6 +528,9 @@ where
|
||||
delta: replaced_node.delta,
|
||||
state_hash,
|
||||
};
|
||||
self.provider
|
||||
.validate_prospective_context_with_control(&committed, &node, control)
|
||||
.map_err(|error| map_provider_error(&error))?;
|
||||
|
||||
control.begin_commit().map_err(|error| match error {
|
||||
lifecycle::BeginCommitError::Cancelled => cancelled_turn(),
|
||||
@@ -409,11 +540,12 @@ where
|
||||
}
|
||||
})?;
|
||||
self.store
|
||||
.append_regenerated_node(
|
||||
.append_regenerated_node_with_checkpoint(
|
||||
&request.branch_id,
|
||||
&request.expected_node_id,
|
||||
&node,
|
||||
&committed,
|
||||
pending_checkpoint.as_ref(),
|
||||
)
|
||||
.map_err(|error| map_fork_error(&error))?;
|
||||
|
||||
@@ -427,6 +559,60 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_provider_context(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
source_nodes: &[StoryNode],
|
||||
expected_history_head_node_id: &str,
|
||||
control: &TurnControl,
|
||||
) -> Result<Option<StoredContextCheckpoint>, TurnFailure> {
|
||||
if !self.provider.uses_context_checkpoints() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stored = self
|
||||
.store
|
||||
.nearest_context_checkpoint(&request.story_id, expected_history_head_node_id)
|
||||
.map_err(|error| map_store_error(&error))?;
|
||||
let checkpoint = stored.as_ref().and_then(runtime_checkpoint_from_stored);
|
||||
let preparation = self
|
||||
.provider
|
||||
.prepare_turn_context_with_control(
|
||||
request,
|
||||
state,
|
||||
source_nodes,
|
||||
expected_history_head_node_id,
|
||||
checkpoint.as_ref(),
|
||||
control,
|
||||
)
|
||||
.map_err(|error| map_provider_error(&error))?;
|
||||
|
||||
let TurnContextPreparation::Prepared { checkpoint } = preparation else {
|
||||
return Err(internal_failure(
|
||||
"context-managed provider did not prepare the turn",
|
||||
));
|
||||
};
|
||||
let Some(checkpoint) = checkpoint else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(interruption) = control.interruption() {
|
||||
return Err(match interruption {
|
||||
TurnInterruption::Cancelled => cancelled_turn(),
|
||||
TurnInterruption::TimedOut => timed_out_turn(),
|
||||
});
|
||||
}
|
||||
let stored = stored_checkpoint_from_runtime(&checkpoint)
|
||||
.map_err(|_| internal_failure("context checkpoint could not be prepared"))?;
|
||||
if let Some(interruption) = control.interruption() {
|
||||
return Err(match interruption {
|
||||
TurnInterruption::Cancelled => cancelled_turn(),
|
||||
TurnInterruption::TimedOut => timed_out_turn(),
|
||||
});
|
||||
}
|
||||
Ok(Some(stored))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &Provider {
|
||||
&self.provider
|
||||
@@ -721,6 +907,7 @@ fn map_store_error(error: &StoreError) -> TurnFailure {
|
||||
StoreError::NodeAlreadyExists(_)
|
||||
| StoreError::ParentNotFound(_)
|
||||
| StoreError::StateMismatch(_)
|
||||
| StoreError::InvalidCheckpointRange(_)
|
||||
| StoreError::StateHashMismatch { .. }
|
||||
| StoreError::Sqlite(_)
|
||||
| StoreError::Serialization(_)
|
||||
@@ -1217,8 +1404,11 @@ mod persistent_turn_tests {
|
||||
use nana_store::{InMemoryStoryStore, SqliteStoryStore, StoryStore};
|
||||
|
||||
use super::{
|
||||
BranchHistoryProjection, ProviderError, TurnControl, TurnEngine, TurnPlan,
|
||||
TurnPlanProvider, TurnProjector, branch_id_for_regeneration, hash_runtime_state,
|
||||
BranchHistoryProjection, NARRATIVE_CHECKPOINT_SCHEMA_VERSION, NarrativeCheckpoint,
|
||||
NarrativeCheckpointSourceHash, NarrativeCheckpointSummary, ProviderError,
|
||||
SCENE_PROMPT_SCHEMA_VERSION, StablePrefixHash, SummaryClassification,
|
||||
TurnContextPreparation, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
|
||||
branch_id_for_regeneration, hash_runtime_state,
|
||||
};
|
||||
|
||||
struct RecordingPlanProvider {
|
||||
@@ -1273,6 +1463,81 @@ mod persistent_turn_tests {
|
||||
calls: usize,
|
||||
}
|
||||
|
||||
struct RejectingProspectiveProvider {
|
||||
response: TurnPlan,
|
||||
validations: usize,
|
||||
}
|
||||
|
||||
struct CheckpointingPlanProvider {
|
||||
response: TurnPlan,
|
||||
control: Option<TurnControl>,
|
||||
prepared: usize,
|
||||
}
|
||||
|
||||
impl TurnPlanProvider for CheckpointingPlanProvider {
|
||||
fn plan_turn(
|
||||
&mut self,
|
||||
_request: &TurnRequest,
|
||||
_state: &RuntimeState,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
if let Some(control) = &self.control {
|
||||
assert!(control.cancel());
|
||||
}
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
|
||||
fn uses_context_checkpoints(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn prepare_turn_context_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
_state: &RuntimeState,
|
||||
source_nodes: &[StoryNode],
|
||||
expected_history_head_node_id: &str,
|
||||
_checkpoint: Option<&NarrativeCheckpoint>,
|
||||
_control: &TurnControl,
|
||||
) -> Result<TurnContextPreparation, ProviderError> {
|
||||
self.prepared += 1;
|
||||
assert_eq!(
|
||||
source_nodes.last().map(|node| node.id.as_str()),
|
||||
Some(expected_history_head_node_id)
|
||||
);
|
||||
let covered = source_nodes
|
||||
.first()
|
||||
.expect("test checkpoint has a covered root");
|
||||
let retained = source_nodes
|
||||
.get(1)
|
||||
.expect("test checkpoint retains the current node");
|
||||
assert_eq!(retained.id, expected_history_head_node_id);
|
||||
Ok(TurnContextPreparation::Prepared {
|
||||
checkpoint: Some(NarrativeCheckpoint {
|
||||
story_id: request.story_id.clone(),
|
||||
at_node_id: retained.id.clone(),
|
||||
covered_through_node_id: covered.id.clone(),
|
||||
retained_from_node_id: Some(retained.id.clone()),
|
||||
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
|
||||
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
|
||||
stable_prefix_hash: StablePrefixHash::try_from(
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
.to_owned(),
|
||||
)
|
||||
.expect("hash"),
|
||||
summary: NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: "The root scene is safely summarized.".into(),
|
||||
},
|
||||
source_hash: NarrativeCheckpointSourceHash::try_from(
|
||||
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
.to_owned(),
|
||||
)
|
||||
.expect("hash"),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnPlanProvider for CancellingPlanProvider {
|
||||
fn plan_turn(
|
||||
&mut self,
|
||||
@@ -1285,6 +1550,28 @@ mod persistent_turn_tests {
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnPlanProvider for RejectingProspectiveProvider {
|
||||
fn plan_turn(
|
||||
&mut self,
|
||||
_request: &TurnRequest,
|
||||
_state: &RuntimeState,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
|
||||
fn validate_prospective_context_with_control(
|
||||
&mut self,
|
||||
_state: &RuntimeState,
|
||||
_node: &StoryNode,
|
||||
_control: &TurnControl,
|
||||
) -> Result<(), ProviderError> {
|
||||
self.validations += 1;
|
||||
Err(ProviderError::InvalidModelOutput {
|
||||
kind: super::InvalidModelOutputKind::InvalidPlan,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingProjector<'store, Store> {
|
||||
store: &'store Store,
|
||||
calls: usize,
|
||||
@@ -1372,6 +1659,17 @@ mod persistent_turn_tests {
|
||||
store
|
||||
}
|
||||
|
||||
fn store_at_second_node() -> InMemoryStoryStore {
|
||||
let store = seeded_store();
|
||||
store
|
||||
.append_node(
|
||||
&node("node_2", Some("node_1"), "branch_main"),
|
||||
&state("node_2", "branch_main"),
|
||||
)
|
||||
.expect("seed second node");
|
||||
store
|
||||
}
|
||||
|
||||
fn request(expected_node_id: &str) -> TurnRequest {
|
||||
TurnRequest {
|
||||
story_id: "story_1".into(),
|
||||
@@ -1598,6 +1896,29 @@ mod persistent_turn_tests {
|
||||
assert_eq!(committed.world_flags.get("promise_spoken"), Some(&true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prospective_context_failure_rejects_the_plan_before_commit() {
|
||||
let store = seeded_store();
|
||||
let provider = RejectingProspectiveProvider {
|
||||
response: plan("node_2", StateDelta { ops: Vec::new() }),
|
||||
validations: 0,
|
||||
};
|
||||
let mut engine = TurnEngine::new(&store, provider, projector(&store));
|
||||
|
||||
let failure = engine
|
||||
.submit_turn(&request("node_1"))
|
||||
.expect_err("unplayable next context must fail closed");
|
||||
|
||||
assert_eq!(failure.code, TurnFailureCode::InvalidModelOutput);
|
||||
assert_eq!(engine.provider().validations, 1);
|
||||
assert_eq!(engine.projector().calls, 0);
|
||||
assert_eq!(
|
||||
store.branch_head("story_1", "branch_main").expect("head"),
|
||||
Some("node_1".into())
|
||||
);
|
||||
assert!(store.load_node("story_1", "node_2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_regeneration_uses_parent_context_and_reuses_authoritative_state() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
@@ -1695,6 +2016,64 @@ mod persistent_turn_tests {
|
||||
assert!(store.load_node("story_1", "node_2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_is_committed_atomically_with_the_final_story_node() {
|
||||
let store = store_at_second_node();
|
||||
let provider = CheckpointingPlanProvider {
|
||||
response: plan("node_3", StateDelta { ops: Vec::new() }),
|
||||
control: None,
|
||||
prepared: 0,
|
||||
};
|
||||
let mut engine = TurnEngine::new(&store, provider, projector(&store));
|
||||
|
||||
engine
|
||||
.submit_turn(&request("node_2"))
|
||||
.expect("node and checkpoint commit");
|
||||
|
||||
assert_eq!(engine.provider().prepared, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.nearest_context_checkpoint("story_1", "node_3")
|
||||
.expect("checkpoint lookup")
|
||||
.map(|checkpoint| checkpoint.at_node_id().to_owned()),
|
||||
Some("node_2".into())
|
||||
);
|
||||
assert_eq!(
|
||||
store.branch_head("story_1", "branch_main").expect("head"),
|
||||
Some("node_3".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_after_preparation_discards_pending_checkpoint_and_story_node() {
|
||||
let store = store_at_second_node();
|
||||
let control = TurnControl::new();
|
||||
let provider = CheckpointingPlanProvider {
|
||||
response: plan("node_3", StateDelta { ops: Vec::new() }),
|
||||
control: Some(control.clone()),
|
||||
prepared: 0,
|
||||
};
|
||||
let mut engine = TurnEngine::new(&store, provider, projector(&store));
|
||||
|
||||
let failure = engine
|
||||
.submit_turn_with_control(&request("node_2"), &control)
|
||||
.expect_err("cancelled final plan");
|
||||
|
||||
assert_eq!(failure.code, TurnFailureCode::Cancelled);
|
||||
assert_eq!(engine.provider().prepared, 1);
|
||||
assert!(
|
||||
store
|
||||
.nearest_context_checkpoint("story_1", "node_2")
|
||||
.expect("checkpoint lookup")
|
||||
.is_none()
|
||||
);
|
||||
assert!(store.load_node("story_1", "node_3").is_err());
|
||||
assert_eq!(
|
||||
store.branch_head("story_1", "branch_main").expect("head"),
|
||||
Some("node_2".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_deadline_does_not_call_provider_or_move_the_branch() {
|
||||
let store = seeded_store();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
NarrativeCheckpointSourceEntry, NarrativeCheckpointSummary, ProviderError,
|
||||
SummaryClassification, TurnControl,
|
||||
};
|
||||
|
||||
pub const MAX_CONTEXT_SUMMARY_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES: usize = 512;
|
||||
|
||||
/// Player-safe, contiguous narrative material selected by the context planner.
|
||||
///
|
||||
/// It has no representation for runtime state, state deltas, hidden checks,
|
||||
/// private inventory, credentials, or provider responses. Source identities
|
||||
/// are retained for host validation and are not delegated to the model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SummaryRequest {
|
||||
prior_summary: Option<NarrativeCheckpointSummary>,
|
||||
prior_covered_through_node_id: Option<String>,
|
||||
entries: Vec<NarrativeCheckpointSourceEntry>,
|
||||
max_summary_bytes: usize,
|
||||
}
|
||||
|
||||
impl SummaryRequest {
|
||||
pub fn new(
|
||||
prior_summary: Option<NarrativeCheckpointSummary>,
|
||||
prior_covered_through_node_id: Option<String>,
|
||||
entries: Vec<NarrativeCheckpointSourceEntry>,
|
||||
max_summary_bytes: usize,
|
||||
) -> Result<Self, SummaryRequestError> {
|
||||
if max_summary_bytes == 0 || max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES {
|
||||
return Err(SummaryRequestError::InvalidSummaryLimit);
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(SummaryRequestError::EmptyEntries);
|
||||
}
|
||||
if entries.len() > MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES {
|
||||
return Err(SummaryRequestError::TooManyEntries);
|
||||
}
|
||||
if prior_summary.is_some() != prior_covered_through_node_id.is_some() {
|
||||
return Err(SummaryRequestError::IncompletePriorSummary);
|
||||
}
|
||||
if prior_summary.as_ref().is_some_and(|summary| {
|
||||
summary.classification != SummaryClassification::NonAuthoritativeNarrative
|
||||
}) {
|
||||
return Err(SummaryRequestError::InvalidPriorClassification);
|
||||
}
|
||||
if prior_summary
|
||||
.as_ref()
|
||||
.is_some_and(|summary| summary.text.trim().is_empty())
|
||||
{
|
||||
return Err(SummaryRequestError::EmptyPriorSummary);
|
||||
}
|
||||
if prior_summary
|
||||
.as_ref()
|
||||
.is_some_and(|summary| summary.text.len() > MAX_CONTEXT_SUMMARY_BYTES)
|
||||
{
|
||||
return Err(SummaryRequestError::PriorSummaryTooLarge);
|
||||
}
|
||||
|
||||
let mut node_ids = BTreeSet::new();
|
||||
for node_id in entries.iter().map(|entry| &entry.node_id) {
|
||||
if node_id.trim().is_empty() {
|
||||
return Err(SummaryRequestError::EmptyNodeId);
|
||||
}
|
||||
if !node_ids.insert(node_id.clone()) {
|
||||
return Err(SummaryRequestError::DuplicateNodeId(node_id.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(prior_covered) = prior_covered_through_node_id.as_deref() {
|
||||
if prior_covered.trim().is_empty() {
|
||||
return Err(SummaryRequestError::EmptyNodeId);
|
||||
}
|
||||
if entries[0].parent_id.as_deref() != Some(prior_covered) {
|
||||
return Err(SummaryRequestError::NonContiguousEntries);
|
||||
}
|
||||
} else if entries[0].parent_id.is_some() {
|
||||
return Err(SummaryRequestError::NonContiguousEntries);
|
||||
}
|
||||
if entries
|
||||
.windows(2)
|
||||
.any(|pair| pair[1].parent_id.as_deref() != Some(pair[0].node_id.as_str()))
|
||||
{
|
||||
return Err(SummaryRequestError::NonContiguousEntries);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
prior_summary,
|
||||
prior_covered_through_node_id,
|
||||
entries,
|
||||
max_summary_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn prior_summary(&self) -> Option<&NarrativeCheckpointSummary> {
|
||||
self.prior_summary.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn prior_covered_through_node_id(&self) -> Option<&str> {
|
||||
self.prior_covered_through_node_id.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[NarrativeCheckpointSourceEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn max_summary_bytes(&self) -> usize {
|
||||
self.max_summary_bytes
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn covered_through_node_id(&self) -> &str {
|
||||
self.entries
|
||||
.last()
|
||||
.map_or("", |entry| entry.node_id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum SummaryRequestError {
|
||||
#[error("a summary request requires at least one complete narrative entry")]
|
||||
EmptyEntries,
|
||||
#[error("a summary request contains too many narrative entries")]
|
||||
TooManyEntries,
|
||||
#[error("a summary request contains an empty source node id")]
|
||||
EmptyNodeId,
|
||||
#[error("a summary request repeats source node id `{0}`")]
|
||||
DuplicateNodeId(String),
|
||||
#[error("a summary request must provide both prior summary and covered node, or neither")]
|
||||
IncompletePriorSummary,
|
||||
#[error("a prior summary has an unsupported classification")]
|
||||
InvalidPriorClassification,
|
||||
#[error("summary source entries are not one contiguous path")]
|
||||
NonContiguousEntries,
|
||||
#[error("a prior narrative summary cannot be empty")]
|
||||
EmptyPriorSummary,
|
||||
#[error("a prior narrative summary exceeds the summary size limit")]
|
||||
PriorSummaryTooLarge,
|
||||
#[error("the requested narrative summary size limit is invalid")]
|
||||
InvalidSummaryLimit,
|
||||
}
|
||||
|
||||
/// The only model-authored value accepted from a summary call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SummaryResult {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl SummaryResult {
|
||||
pub fn new(text: String, max_summary_bytes: usize) -> Result<Self, SummaryResultError> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(SummaryResultError::Empty);
|
||||
}
|
||||
if max_summary_bytes == 0
|
||||
|| max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES
|
||||
|| text.len() > max_summary_bytes
|
||||
{
|
||||
return Err(SummaryResultError::TooLarge);
|
||||
}
|
||||
Ok(Self { text })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_checkpoint_summary(self) -> NarrativeCheckpointSummary {
|
||||
NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: self.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SummaryResultError {
|
||||
#[error("a narrative summary cannot be empty")]
|
||||
Empty,
|
||||
#[error("a narrative summary exceeds the summary size limit")]
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// Provider-neutral seam for one same-model narrative compaction call.
|
||||
///
|
||||
/// Implementations must use only [`SummaryRequest`], observe the existing turn
|
||||
/// control, and return no persistence metadata. The runtime stamps hashes,
|
||||
/// schema versions, and covered ranges after validating the result.
|
||||
pub trait ContextSummaryModel {
|
||||
fn summarize_with_control(
|
||||
&mut self,
|
||||
request: &SummaryRequest,
|
||||
control: &TurnControl,
|
||||
) -> Result<SummaryResult, ProviderError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nana_domain::{BeatKind, PresentationBeat, VisualDirective};
|
||||
|
||||
use super::{
|
||||
MAX_CONTEXT_SUMMARY_BYTES, SummaryRequest, SummaryRequestError, SummaryResult,
|
||||
SummaryResultError,
|
||||
};
|
||||
use crate::{
|
||||
BranchHistoryCharacter, BranchHistoryScene, NarrativeCheckpointSourceEntry,
|
||||
NarrativeCheckpointSummary, SummaryClassification,
|
||||
};
|
||||
|
||||
fn entry(node_id: &str, parent_id: Option<&str>) -> NarrativeCheckpointSourceEntry {
|
||||
NarrativeCheckpointSourceEntry {
|
||||
node_id: node_id.into(),
|
||||
parent_id: parent_id.map(str::to_owned),
|
||||
user_input: "Wait here.".into(),
|
||||
scene: BranchHistoryScene {
|
||||
id: "station".into(),
|
||||
title: "Station".into(),
|
||||
},
|
||||
character: BranchHistoryCharacter {
|
||||
id: "nana".into(),
|
||||
name: "Nana".into(),
|
||||
expression: Some("guarded".into()),
|
||||
pose: None,
|
||||
},
|
||||
beats: vec![PresentationBeat {
|
||||
id: format!("beat_{node_id}"),
|
||||
kind: BeatKind::Dialogue,
|
||||
speaker: Some("Nana".into()),
|
||||
text: "I will wait.".into(),
|
||||
visual: Some(VisualDirective {
|
||||
character: Some("nana".into()),
|
||||
expression: Some("guarded".into()),
|
||||
pose: None,
|
||||
scene: None,
|
||||
}),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_request_owns_an_exact_non_repeating_source_sequence() {
|
||||
let request = SummaryRequest::new(
|
||||
Some(NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: "Earlier events.".into(),
|
||||
}),
|
||||
Some("node_root".into()),
|
||||
vec![
|
||||
entry("node_1", Some("node_root")),
|
||||
entry("node_2", Some("node_1")),
|
||||
],
|
||||
1_024,
|
||||
)
|
||||
.expect("valid request");
|
||||
|
||||
assert_eq!(request.covered_through_node_id(), "node_2");
|
||||
let result = SummaryResult::new("Self-contained recap.".into(), 1_024).expect("summary");
|
||||
let summary = result.into_checkpoint_summary();
|
||||
assert_eq!(
|
||||
summary.classification,
|
||||
SummaryClassification::NonAuthoritativeNarrative
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_request_rejects_empty_duplicate_and_oversized_values() {
|
||||
assert_eq!(
|
||||
SummaryRequest::new(None, None, Vec::new(), 1_024),
|
||||
Err(SummaryRequestError::EmptyEntries)
|
||||
);
|
||||
assert!(matches!(
|
||||
SummaryRequest::new(
|
||||
Some(NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: "Earlier.".into(),
|
||||
}),
|
||||
Some("node_1".into()),
|
||||
vec![entry("node_2", Some("node_other"))],
|
||||
1_024,
|
||||
),
|
||||
Err(SummaryRequestError::NonContiguousEntries)
|
||||
));
|
||||
assert_eq!(
|
||||
SummaryResult::new(String::new(), 1_024),
|
||||
Err(SummaryResultError::Empty)
|
||||
);
|
||||
assert_eq!(
|
||||
SummaryResult::new(
|
||||
"x".repeat(MAX_CONTEXT_SUMMARY_BYTES + 1),
|
||||
MAX_CONTEXT_SUMMARY_BYTES,
|
||||
),
|
||||
Err(SummaryResultError::TooLarge)
|
||||
);
|
||||
assert_eq!(
|
||||
SummaryRequest::new(None, None, vec![entry("node_1", None)], 0),
|
||||
Err(SummaryRequestError::InvalidSummaryLimit)
|
||||
);
|
||||
assert_eq!(
|
||||
SummaryResult::new("12345".into(), 4),
|
||||
Err(SummaryResultError::TooLarge)
|
||||
);
|
||||
}
|
||||
}
|
||||
+1563
-38
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user