From ea209c9b69b655000621381bc0f0d8fe36926740 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 13:05:33 +0800 Subject: [PATCH] feat(runtime): add deterministic turn seam --- crates/nana-runtime/src/lib.rs | 469 ++++++++++++++++++++++++++++++++- 1 file changed, 466 insertions(+), 3 deletions(-) diff --git a/crates/nana-runtime/src/lib.rs b/crates/nana-runtime/src/lib.rs index 9489587..82b73e2 100644 --- a/crates/nana-runtime/src/lib.rs +++ b/crates/nana-runtime/src/lib.rs @@ -1,15 +1,18 @@ -use std::collections::VecDeque; +use std::collections::{BTreeSet, VecDeque}; -use nana_domain::{TurnFailure, TurnFailureCode, TurnRequest, TurnResult}; +use nana_domain::{ + TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry, +}; use thiserror::Error; pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17"; +pub const MAX_WORLD_BOOK_ENTRIES: usize = 8; #[derive(Debug, Error)] pub enum ProviderError { #[error("no recorded response remains")] FixtureExhausted, - #[error("LAPP profile could not be loaded: {0}")] + #[error("LAPP profile could not be loaded")] Profile(String), } @@ -39,6 +42,99 @@ impl TurnProvider for FakeProvider { } } +/// Validate the parts of a turn request that do not require persisted story state. +/// +/// Stale-node detection belongs to the store boundary. This validation deliberately +/// does not infer any semantics for regenerate or pushed-check turns. +pub fn validate_turn_request(request: &TurnRequest) -> Result<(), TurnFailure> { + validate_required_text("story_id", &request.story_id)?; + validate_required_text("branch_id", &request.branch_id)?; + validate_required_text("expected_node_id", &request.expected_node_id)?; + validate_required_text("action_id", &request.action_id)?; + + if matches!(&request.intent, TurnIntent::SpeakOrAct) && request.input.trim().is_empty() { + return Err(invalid_input( + "input must not be empty for a speak_or_act turn", + )); + } + + Ok(()) +} + +/// Execute one provider turn without persistence or network policy. +/// +/// This is the deterministic seam used by fixtures and later by the store-backed +/// runtime: request validation happens before provider invocation and the provider +/// result is checked before it can cross into another layer. +pub fn execute_turn( + provider: &mut impl TurnProvider, + request: &TurnRequest, +) -> Result { + validate_turn_request(request)?; + let result = provider + .complete_turn(request) + .map_err(|_| provider_unavailable())?; + validate_turn_result(request, &result)?; + Ok(result) +} + +/// Select world-book entries using deterministic lexical triggers. +/// +/// Entries retain their input order. Duplicate ids keep the first matching entry. +/// Required flags are an all-of gate; an entry then needs either a matching tag or +/// a non-empty keyword contained in the turn input. +#[must_use] +pub fn select_world_book_entries( + entries: &[WorldBookEntry], + active_flags: &BTreeSet, + trigger_tags: &BTreeSet, + input: &str, + limit: usize, +) -> Vec { + let effective_limit = limit.min(MAX_WORLD_BOOK_ENTRIES); + if effective_limit == 0 { + return Vec::new(); + } + + let normalized_tags = trigger_tags + .iter() + .map(|tag| normalize_trigger(tag)) + .filter(|tag| !tag.is_empty()) + .collect::>(); + let normalized_input = input.to_lowercase(); + let mut selected_ids = BTreeSet::new(); + let mut selected = Vec::new(); + + for entry in entries { + if selected.len() == effective_limit { + break; + } + if !entry + .required_flags + .iter() + .all(|flag| active_flags.contains(flag)) + { + continue; + } + + let tag_matches = entry + .tags + .iter() + .map(|tag| normalize_trigger(tag)) + .any(|tag| !tag.is_empty() && normalized_tags.contains(&tag)); + let keyword_matches = entry.keywords.iter().any(|keyword| { + let keyword = keyword.trim().to_lowercase(); + !keyword.is_empty() && normalized_input.contains(&keyword) + }); + + if (tag_matches || keyword_matches) && selected_ids.insert(entry.id.clone()) { + selected.push(entry.clone()); + } + } + + selected +} + pub fn load_default_lapp_profile() -> Result { openlapp::load_default_profile().map_err(|error| ProviderError::Profile(error.to_string())) } @@ -51,3 +147,370 @@ pub fn provider_failure(message: impl Into) -> TurnFailure { retryable: true, } } + +fn validate_turn_result(request: &TurnRequest, result: &TurnResult) -> Result<(), TurnFailure> { + if result.committed_node_id.trim().is_empty() { + return Err(invalid_model_output("committed node id is empty")); + } + if result.committed_node_id == request.expected_node_id { + return Err(invalid_model_output( + "provider returned the uncommitted expected node", + )); + } + if result.player_view.story_id != request.story_id { + return Err(invalid_model_output( + "player view story does not match the request", + )); + } + if result.player_view.branch_id != request.branch_id { + return Err(invalid_model_output( + "player view branch does not match the request", + )); + } + if result.player_view.node_id != result.committed_node_id { + return Err(invalid_model_output( + "player view node does not match the committed node", + )); + } + Ok(()) +} + +fn validate_required_text(field: &str, value: &str) -> Result<(), TurnFailure> { + if value.trim().is_empty() { + Err(invalid_input(format!("{field} must not be empty"))) + } else { + Ok(()) + } +} + +fn invalid_input(message: impl Into) -> TurnFailure { + TurnFailure { + code: TurnFailureCode::InvalidInput, + message: message.into(), + retryable: false, + } +} + +fn invalid_model_output(message: impl Into) -> TurnFailure { + TurnFailure { + code: TurnFailureCode::InvalidModelOutput, + message: message.into(), + retryable: true, + } +} + +fn provider_unavailable() -> TurnFailure { + provider_failure("turn provider is unavailable") +} + +fn normalize_trigger(value: &str) -> String { + value.trim().to_lowercase() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use nana_domain::{ + PlayerView, RelationshipBand, RelationshipView, TurnFailureCode, TurnIntent, TurnRequest, + TurnResult, WorldBookEntry, + }; + + use super::{ + FakeProvider, MAX_WORLD_BOOK_ENTRIES, ProviderError, TurnProvider, execute_turn, + select_world_book_entries, validate_turn_request, + }; + + fn request(intent: TurnIntent, input: &str) -> TurnRequest { + TurnRequest { + story_id: "story_1".into(), + branch_id: "branch_main".into(), + expected_node_id: "node_1".into(), + action_id: "action_1".into(), + intent, + input: input.into(), + } + } + + fn player_view(node_id: &str) -> PlayerView { + PlayerView { + story_id: "story_1".into(), + node_id: node_id.into(), + branch_id: "branch_main".into(), + scene_id: "station".into(), + scene_title: "Station".into(), + character_name: "Nana".into(), + beats: Vec::new(), + suggestions: Vec::new(), + inventory: Vec::new(), + knowledge: Vec::new(), + promises: Vec::new(), + relationship: RelationshipView { + affinity: RelationshipBand::Warming, + trust: RelationshipBand::Guarded, + hope: RelationshipBand::Guarded, + respect: RelationshipBand::Warming, + intimacy: RelationshipBand::Guarded, + attachment: RelationshipBand::Warming, + updated_at_node: None, + }, + history: Vec::new(), + can_continue: true, + } + } + + fn result(node_id: &str) -> TurnResult { + TurnResult { + committed_node_id: node_id.into(), + player_view: player_view(node_id), + } + } + + fn entry( + id: &str, + keywords: &[&str], + tags: &[&str], + required_flags: &[&str], + ) -> WorldBookEntry { + WorldBookEntry { + id: id.into(), + title: id.into(), + content: format!("content for {id}"), + keywords: keywords.iter().map(ToString::to_string).collect(), + tags: tags.iter().map(ToString::to_string).collect(), + required_flags: required_flags.iter().map(ToString::to_string).collect(), + } + } + + #[test] + fn continue_allows_empty_input() { + assert!(validate_turn_request(&request(TurnIntent::Continue, "")).is_ok()); + } + + #[test] + fn speak_or_act_rejects_empty_input() { + let failure = validate_turn_request(&request(TurnIntent::SpeakOrAct, " \n ")) + .expect_err("blank player input must be rejected"); + + assert_eq!(failure.code, TurnFailureCode::InvalidInput); + assert!(!failure.retryable); + assert_eq!( + failure.message, + "input must not be empty for a speak_or_act turn" + ); + } + + #[test] + fn required_request_identifiers_must_not_be_blank() { + 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(), + "branch_id" => request.branch_id = " ".into(), + "expected_node_id" => request.expected_node_id = " ".into(), + "action_id" => request.action_id = " ".into(), + _ => unreachable!(), + } + + let failure = + validate_turn_request(&request).expect_err("blank identifier must be rejected"); + assert_eq!(failure.code, TurnFailureCode::InvalidInput); + assert_eq!(failure.message, format!("{field} must not be empty")); + } + } + + #[test] + fn regenerate_and_push_check_remain_valid_without_extra_rules() { + assert!(validate_turn_request(&request(TurnIntent::Regenerate, "")).is_ok()); + assert!(validate_turn_request(&request(TurnIntent::PushCheck, "")).is_ok()); + } + + #[test] + fn fake_provider_returns_fixtures_fifo() { + let mut provider = FakeProvider::new([result("node_2"), result("node_3")]); + let request = request(TurnIntent::Continue, ""); + + assert_eq!( + provider + .complete_turn(&request) + .expect("first response") + .committed_node_id, + "node_2" + ); + assert_eq!( + provider + .complete_turn(&request) + .expect("second response") + .committed_node_id, + "node_3" + ); + assert!(matches!( + provider.complete_turn(&request), + Err(ProviderError::FixtureExhausted) + )); + } + + #[test] + fn execution_validates_before_consuming_the_provider() { + let mut provider = FakeProvider::new([result("node_2")]); + let invalid = request(TurnIntent::SpeakOrAct, ""); + + assert_eq!( + execute_turn(&mut provider, &invalid) + .expect_err("invalid request must fail before the provider") + .code, + TurnFailureCode::InvalidInput + ); + + let committed = execute_turn( + &mut provider, + &request(TurnIntent::SpeakOrAct, "I will return."), + ) + .expect("the queued response must still be available"); + assert_eq!(committed.committed_node_id, "node_2"); + } + + #[test] + fn execution_rejects_an_uncommitted_or_inconsistent_result() { + let request = request(TurnIntent::Continue, ""); + + 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 + ); + + let mut wrong_node = result("node_2"); + wrong_node.player_view.node_id = "node_other".into(); + let mut provider = FakeProvider::new([wrong_node]); + let failure = + execute_turn(&mut provider, &request).expect_err("view node must match committed node"); + assert_eq!(failure.code, TurnFailureCode::InvalidModelOutput); + + let mut wrong_story = result("node_2"); + wrong_story.player_view.story_id = "story_other".into(); + let mut provider = FakeProvider::new([wrong_story]); + assert_eq!( + execute_turn(&mut provider, &request) + .expect_err("view story must match request") + .code, + TurnFailureCode::InvalidModelOutput + ); + + let mut wrong_branch = result("node_2"); + wrong_branch.player_view.branch_id = "branch_other".into(); + let mut provider = FakeProvider::new([wrong_branch]); + assert_eq!( + execute_turn(&mut provider, &request) + .expect_err("view branch must match request") + .code, + TurnFailureCode::InvalidModelOutput + ); + } + + #[test] + fn execution_redacts_provider_details() { + struct FailingProvider; + + impl TurnProvider for FailingProvider { + fn complete_turn( + &mut self, + _request: &TurnRequest, + ) -> Result { + Err(ProviderError::Profile( + "secret upstream endpoint and token".into(), + )) + } + } + + let upstream = ProviderError::Profile("secret upstream endpoint and token".into()); + assert!(!upstream.to_string().contains("secret")); + + 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"); + assert!(!failure.message.contains("secret")); + assert!(failure.retryable); + } + + #[test] + fn world_book_selection_applies_flags_keywords_and_tags() { + let entries = vec![ + entry("public_station", &["station"], &[], &[]), + entry("flagged_station", &["station"], &[], &["clock_seen"]), + entry("rain_lore", &[], &[" Weather "], &[]), + entry("not_triggered", &["forest"], &["family"], &[]), + ]; + let flags = BTreeSet::new(); + let tags = BTreeSet::from([String::from("weather")]); + + let selected = + select_world_book_entries(&entries, &flags, &tags, "Return to the STATION", 8); + assert_eq!( + selected + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + ["public_station", "rain_lore"] + ); + + let flags = BTreeSet::from([String::from("clock_seen")]); + let selected = + select_world_book_entries(&entries, &flags, &tags, "Return to the station", 8); + assert_eq!( + selected + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + ["public_station", "flagged_station", "rain_lore"] + ); + } + + #[test] + fn world_book_selection_is_stable_deduplicated_and_limited() { + let mut entries = vec![ + entry("first", &["rain"], &[], &[]), + entry("duplicate", &["rain"], &[], &[]), + entry("duplicate", &["rain"], &[], &[]), + ]; + entries.extend( + (0..MAX_WORLD_BOOK_ENTRIES + 3) + .map(|index| entry(&format!("extra_{index}"), &["rain"], &[], &[])), + ); + + let selected = + select_world_book_entries(&entries, &BTreeSet::new(), &BTreeSet::new(), "rain", 3); + assert_eq!( + selected + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + ["first", "duplicate", "extra_0"] + ); + + let capped = select_world_book_entries( + &entries, + &BTreeSet::new(), + &BTreeSet::new(), + "rain", + usize::MAX, + ); + assert_eq!(capped.len(), MAX_WORLD_BOOK_ENTRIES); + + let repeated = + select_world_book_entries(&entries, &BTreeSet::new(), &BTreeSet::new(), "rain", 3); + assert_eq!(selected, repeated); + } +}