restore: import verified wave3 baseline

This commit is contained in:
Codex
2026-07-28 03:16:01 -04:00
parent 4157f8790d
commit cf9507a9dd
41 changed files with 12251 additions and 982 deletions
+109 -87
View File
@@ -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, &current)
.map_err(|_| provider_unavailable())?;
.map_err(|error| map_provider_error(&error))?;
validate_turn_plan(request, &plan)?;
let mut committed = apply_delta(&current, &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(&current).expect("hash");
assert_eq!(hash_runtime_state(&current).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"), &current);
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);
}
}