restore: import verified wave3 baseline
This commit is contained in:
+109
-87
@@ -1,22 +1,44 @@
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
|
||||
use nana_domain::{
|
||||
ActionSuggestion, PlayerView, PresentationBeat, RuntimeState, StateDelta, StoryNode,
|
||||
TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry,
|
||||
PlayerView, PresentationSnapshot, RuntimeState, StateDelta, StoryNode, TurnFailure,
|
||||
TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry,
|
||||
};
|
||||
use nana_engine::{ReduceError, apply_delta};
|
||||
use nana_store::{StoreError, StoryStore};
|
||||
use thiserror::Error;
|
||||
|
||||
mod lapp_provider;
|
||||
|
||||
pub use lapp_provider::{
|
||||
ChatExecutor, LappTurnPlanProvider, OpenLappChatExecutor, TURN_PLAN_TOOL_NAME,
|
||||
};
|
||||
|
||||
pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17";
|
||||
pub const MAX_WORLD_BOOK_ENTRIES: usize = 8;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InvalidModelOutputKind {
|
||||
InvalidJson,
|
||||
InvalidSchema,
|
||||
InvalidShape,
|
||||
InvalidPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProviderError {
|
||||
#[error("no recorded response remains")]
|
||||
FixtureExhausted,
|
||||
#[error("LAPP profile could not be loaded")]
|
||||
Profile(String),
|
||||
Profile { code: openlapp::ErrorCode },
|
||||
#[error("LAPP chat client could not be configured")]
|
||||
Configuration { code: Option<openlapp::ErrorCode> },
|
||||
#[error("LAPP chat request failed")]
|
||||
Upstream { code: Option<openlapp::ErrorCode> },
|
||||
#[error("model returned an invalid turn plan")]
|
||||
InvalidModelOutput { kind: InvalidModelOutputKind },
|
||||
#[error("turn context could not be encoded")]
|
||||
ContextEncoding,
|
||||
}
|
||||
|
||||
pub trait TurnProvider {
|
||||
@@ -25,15 +47,14 @@ pub trait TurnProvider {
|
||||
|
||||
/// Non-view model output used by the persistent turn engine.
|
||||
///
|
||||
/// The provider can propose narrative beats and state changes, but it cannot
|
||||
/// construct the final [`PlayerView`]. That view is derived from committed state
|
||||
/// by a separate trusted projection boundary.
|
||||
/// The provider can propose player-facing presentation and state changes, but it
|
||||
/// cannot construct the final [`PlayerView`]. That view is derived from committed
|
||||
/// state by a separate trusted projection boundary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TurnPlan {
|
||||
pub committed_node_id: String,
|
||||
pub beats: Vec<PresentationBeat>,
|
||||
pub presentation: PresentationSnapshot,
|
||||
pub delta: StateDelta,
|
||||
pub suggestions: Vec<ActionSuggestion>,
|
||||
}
|
||||
|
||||
/// Produces the uncommitted model plan for a turn.
|
||||
@@ -52,12 +73,7 @@ pub trait TurnPlanProvider {
|
||||
/// post-commit error window where the branch advances but the turn reports a
|
||||
/// failure.
|
||||
pub trait TurnProjector {
|
||||
fn project_committed_turn(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
suggestions: &[ActionSuggestion],
|
||||
) -> PlayerView;
|
||||
fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView;
|
||||
}
|
||||
|
||||
/// Store-backed single-turn coordinator.
|
||||
@@ -92,7 +108,7 @@ where
|
||||
let current = self
|
||||
.store
|
||||
.load_state(&request.story_id, &request.branch_id)
|
||||
.map_err(map_store_error)?;
|
||||
.map_err(|error| map_store_error(&error))?;
|
||||
if current.current_node != request.expected_node_id {
|
||||
return Err(stale_node());
|
||||
}
|
||||
@@ -100,7 +116,7 @@ where
|
||||
let plan = self
|
||||
.provider
|
||||
.plan_turn(request, ¤t)
|
||||
.map_err(|_| provider_unavailable())?;
|
||||
.map_err(|error| map_provider_error(&error))?;
|
||||
validate_turn_plan(request, &plan)?;
|
||||
|
||||
let mut committed = apply_delta(¤t, &plan.delta).map_err(map_reduce_error)?;
|
||||
@@ -115,18 +131,16 @@ where
|
||||
parent_id: Some(current.current_node),
|
||||
action_id: request.action_id.clone(),
|
||||
user_input: request.input.clone(),
|
||||
beats: plan.beats,
|
||||
presentation: plan.presentation,
|
||||
delta: plan.delta,
|
||||
state_hash,
|
||||
};
|
||||
|
||||
self.store
|
||||
.append_node(&node, &committed)
|
||||
.map_err(map_store_error)?;
|
||||
.map_err(|error| map_store_error(&error))?;
|
||||
|
||||
let mut player_view =
|
||||
self.projector
|
||||
.project_committed_turn(&committed, &node, &plan.suggestions);
|
||||
let mut player_view = self.projector.project_committed_turn(&committed, &node);
|
||||
// Identity comes from the committed state, never from projection input.
|
||||
// Normalizing these fields keeps even a defensive fallback projector
|
||||
// aligned with the commit it represents.
|
||||
@@ -204,7 +218,7 @@ pub fn execute_turn(
|
||||
validate_turn_request(request)?;
|
||||
let result = provider
|
||||
.complete_turn(request)
|
||||
.map_err(|_| provider_unavailable())?;
|
||||
.map_err(|error| map_provider_error(&error))?;
|
||||
validate_turn_result(request, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -267,7 +281,7 @@ pub fn select_world_book_entries(
|
||||
}
|
||||
|
||||
pub fn load_default_lapp_profile() -> Result<openlapp::Profile, ProviderError> {
|
||||
openlapp::load_default_profile().map_err(|error| ProviderError::Profile(error.to_string()))
|
||||
openlapp::load_default_profile().map_err(|error| ProviderError::Profile { code: error.code() })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -328,7 +342,20 @@ fn map_reduce_error(_error: ReduceError) -> TurnFailure {
|
||||
invalid_model_output("turn plan could not be applied")
|
||||
}
|
||||
|
||||
fn map_store_error(error: StoreError) -> TurnFailure {
|
||||
fn map_provider_error(error: &ProviderError) -> TurnFailure {
|
||||
match error {
|
||||
ProviderError::InvalidModelOutput { .. } => {
|
||||
invalid_model_output("model returned an invalid turn plan")
|
||||
}
|
||||
ProviderError::FixtureExhausted
|
||||
| ProviderError::Profile { .. }
|
||||
| ProviderError::Configuration { .. }
|
||||
| ProviderError::Upstream { .. }
|
||||
| ProviderError::ContextEncoding => provider_unavailable(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_store_error(error: &StoreError) -> TurnFailure {
|
||||
match error {
|
||||
StoreError::StaleBranchHead { .. } => stale_node(),
|
||||
StoreError::StoryNotFound(_) | StoreError::BranchNotFound { .. } => TurnFailure {
|
||||
@@ -427,6 +454,8 @@ mod tests {
|
||||
scene_id: "station".into(),
|
||||
scene_title: "Station".into(),
|
||||
character_name: "Nana".into(),
|
||||
character_expression: None,
|
||||
character_pose: None,
|
||||
beats: Vec::new(),
|
||||
suggestions: Vec::new(),
|
||||
inventory: Vec::new(),
|
||||
@@ -489,12 +518,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn required_request_identifiers_must_not_be_blank() {
|
||||
for field in [
|
||||
"story_id",
|
||||
"branch_id",
|
||||
"expected_node_id",
|
||||
"action_id",
|
||||
] {
|
||||
for field in ["story_id", "branch_id", "expected_node_id", "action_id"] {
|
||||
let mut request = request(TurnIntent::Continue, "");
|
||||
match field {
|
||||
"story_id" => request.story_id = " ".into(),
|
||||
@@ -569,10 +593,7 @@ mod tests {
|
||||
let mut stale = FakeProvider::new([result("node_1")]);
|
||||
let stale_failure =
|
||||
execute_turn(&mut stale, &request).expect_err("old expected node is not a commit");
|
||||
assert_eq!(
|
||||
stale_failure.code,
|
||||
TurnFailureCode::InvalidModelOutput
|
||||
);
|
||||
assert_eq!(stale_failure.code, TurnFailureCode::InvalidModelOutput);
|
||||
|
||||
let mut wrong_node = result("node_2");
|
||||
wrong_node.player_view.node_id = "node_other".into();
|
||||
@@ -611,20 +632,19 @@ mod tests {
|
||||
&mut self,
|
||||
_request: &TurnRequest,
|
||||
) -> Result<TurnResult, ProviderError> {
|
||||
Err(ProviderError::Profile(
|
||||
"secret upstream endpoint and token".into(),
|
||||
))
|
||||
Err(ProviderError::Upstream {
|
||||
code: Some(openlapp::ErrorCode::HttpStatus),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let upstream = ProviderError::Profile("secret upstream endpoint and token".into());
|
||||
assert!(!upstream.to_string().contains("secret"));
|
||||
let upstream = ProviderError::Upstream {
|
||||
code: Some(openlapp::ErrorCode::HttpStatus),
|
||||
};
|
||||
assert_eq!(upstream.to_string(), "LAPP chat request failed");
|
||||
|
||||
let failure = execute_turn(
|
||||
&mut FailingProvider,
|
||||
&request(TurnIntent::Continue, ""),
|
||||
)
|
||||
.expect_err("provider failure should be mapped");
|
||||
let failure = execute_turn(&mut FailingProvider, &request(TurnIntent::Continue, ""))
|
||||
.expect_err("provider failure should be mapped");
|
||||
|
||||
assert_eq!(failure.code, TurnFailureCode::ProviderUnavailable);
|
||||
assert_eq!(failure.message, "turn provider is unavailable");
|
||||
@@ -707,9 +727,10 @@ mod persistent_turn_tests {
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
use nana_domain::{
|
||||
ActionSuggestion, BeatKind, PlayerView, PresentationBeat, RelationshipAdjustment,
|
||||
RelationshipBand, RelationshipDimension, RelationshipView, RuntimeState, StateDelta,
|
||||
StateOp, StoryNode, TurnFailureCode, TurnIntent, TurnRequest,
|
||||
ActionSuggestion, BeatKind, PlayerView, PresentationBeat, PresentationCharacter,
|
||||
PresentationScene, PresentationSnapshot, RelationshipAdjustment, RelationshipBand,
|
||||
RelationshipDimension, RelationshipView, RuntimeState, StateDelta, StateOp, StoryNode,
|
||||
TurnFailureCode, TurnIntent, TurnRequest,
|
||||
};
|
||||
use nana_store::{InMemoryStoryStore, StoryStore};
|
||||
|
||||
@@ -750,12 +771,7 @@ mod persistent_turn_tests {
|
||||
}
|
||||
|
||||
impl TurnProjector for RecordingProjector<'_> {
|
||||
fn project_committed_turn(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
suggestions: &[ActionSuggestion],
|
||||
) -> PlayerView {
|
||||
fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView {
|
||||
self.calls += 1;
|
||||
|
||||
let stored = self
|
||||
@@ -773,8 +789,10 @@ mod persistent_turn_tests {
|
||||
scene_id: "station".into(),
|
||||
scene_title: "Station".into(),
|
||||
character_name: "Nana".into(),
|
||||
beats: node.beats.clone(),
|
||||
suggestions: suggestions.to_vec(),
|
||||
character_expression: node.presentation.character.expression.clone(),
|
||||
character_pose: node.presentation.character.pose.clone(),
|
||||
beats: node.presentation.beats.clone(),
|
||||
suggestions: node.presentation.suggestions.clone(),
|
||||
inventory: Vec::new(),
|
||||
knowledge: Vec::new(),
|
||||
promises: Vec::new(),
|
||||
@@ -817,10 +835,9 @@ mod persistent_turn_tests {
|
||||
parent_id: parent_id.map(Into::into),
|
||||
action_id: format!("action_{id}"),
|
||||
user_input: String::new(),
|
||||
beats: Vec::new(),
|
||||
presentation: PresentationSnapshot::default(),
|
||||
delta: StateDelta { ops: Vec::new() },
|
||||
state_hash: hash_runtime_state(&state(id, branch))
|
||||
.expect("serializable test state"),
|
||||
state_hash: hash_runtime_state(&state(id, branch)).expect("serializable test state"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,19 +866,32 @@ mod persistent_turn_tests {
|
||||
fn plan(node_id: &str, delta: StateDelta) -> TurnPlan {
|
||||
TurnPlan {
|
||||
committed_node_id: node_id.into(),
|
||||
beats: vec![PresentationBeat {
|
||||
id: "beat_1".into(),
|
||||
kind: BeatKind::Dialogue,
|
||||
speaker: Some("Nana".into()),
|
||||
text: "Then I will wait.".into(),
|
||||
visual: None,
|
||||
}],
|
||||
presentation: PresentationSnapshot {
|
||||
scene: PresentationScene {
|
||||
id: "station".into(),
|
||||
title: "Station".into(),
|
||||
},
|
||||
character: PresentationCharacter {
|
||||
id: "nana".into(),
|
||||
name: "Nana".into(),
|
||||
expression: Some("guarded".into()),
|
||||
pose: Some("holding_coat".into()),
|
||||
},
|
||||
beats: vec![PresentationBeat {
|
||||
id: "beat_1".into(),
|
||||
kind: BeatKind::Dialogue,
|
||||
speaker: Some("Nana".into()),
|
||||
text: "Then I will wait.".into(),
|
||||
visual: None,
|
||||
}],
|
||||
suggestions: vec![ActionSuggestion {
|
||||
id: "suggestion_1".into(),
|
||||
label: "Promise".into(),
|
||||
draft: "I promise.".into(),
|
||||
}],
|
||||
can_continue: true,
|
||||
},
|
||||
delta,
|
||||
suggestions: vec![ActionSuggestion {
|
||||
id: "suggestion_1".into(),
|
||||
label: "Promise".into(),
|
||||
draft: "I promise.".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,10 +936,7 @@ mod persistent_turn_tests {
|
||||
let store = seeded_store();
|
||||
let mut engine = TurnEngine::new(
|
||||
&store,
|
||||
RecordingPlanProvider::new(Ok(plan(
|
||||
"node_2",
|
||||
StateDelta { ops: Vec::new() },
|
||||
))),
|
||||
RecordingPlanProvider::new(Ok(plan("node_2", StateDelta { ops: Vec::new() }))),
|
||||
projector(&store),
|
||||
);
|
||||
|
||||
@@ -968,9 +995,9 @@ mod persistent_turn_tests {
|
||||
let store = seeded_store();
|
||||
let mut engine = TurnEngine::new(
|
||||
&store,
|
||||
RecordingPlanProvider::new(Err(ProviderError::Profile(
|
||||
"secret upstream endpoint and token".into(),
|
||||
))),
|
||||
RecordingPlanProvider::new(Err(ProviderError::Upstream {
|
||||
code: Some(openlapp::ErrorCode::HttpStatus),
|
||||
})),
|
||||
projector(&store),
|
||||
);
|
||||
|
||||
@@ -999,10 +1026,7 @@ mod persistent_turn_tests {
|
||||
.expect("seed duplicate id on another branch");
|
||||
let mut engine = TurnEngine::new(
|
||||
&store,
|
||||
RecordingPlanProvider::new(Ok(plan(
|
||||
"node_duplicate",
|
||||
StateDelta { ops: Vec::new() },
|
||||
))),
|
||||
RecordingPlanProvider::new(Ok(plan("node_duplicate", StateDelta { ops: Vec::new() }))),
|
||||
projector(&store),
|
||||
);
|
||||
|
||||
@@ -1033,13 +1057,11 @@ mod persistent_turn_tests {
|
||||
let expected = hash_runtime_state(¤t).expect("hash");
|
||||
assert_eq!(hash_runtime_state(¤t).expect("repeat hash"), expected);
|
||||
|
||||
let mut provider = RecordingPlanProvider::new(Ok(plan(
|
||||
"node_2",
|
||||
StateDelta { ops: Vec::new() },
|
||||
)));
|
||||
let mut provider =
|
||||
RecordingPlanProvider::new(Ok(plan("node_2", StateDelta { ops: Vec::new() })));
|
||||
let output = provider_output(&mut provider, &request("node_1"), ¤t);
|
||||
assert_eq!(output.committed_node_id, "node_2");
|
||||
assert_eq!(output.beats.len(), 1);
|
||||
assert_eq!(output.suggestions.len(), 1);
|
||||
assert_eq!(output.presentation.beats.len(), 1);
|
||||
assert_eq!(output.presentation.suggestions.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user