feat(runtime): add deterministic turn seam
This commit is contained in:
@@ -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<TurnResult, TurnFailure> {
|
||||
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<String>,
|
||||
trigger_tags: &BTreeSet<String>,
|
||||
input: &str,
|
||||
limit: usize,
|
||||
) -> Vec<WorldBookEntry> {
|
||||
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::<BTreeSet<_>>();
|
||||
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::Profile, ProviderError> {
|
||||
openlapp::load_default_profile().map_err(|error| ProviderError::Profile(error.to_string()))
|
||||
}
|
||||
@@ -51,3 +147,370 @@ pub fn provider_failure(message: impl Into<String>) -> 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<String>) -> TurnFailure {
|
||||
TurnFailure {
|
||||
code: TurnFailureCode::InvalidInput,
|
||||
message: message.into(),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_model_output(message: impl Into<String>) -> 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<TurnResult, ProviderError> {
|
||||
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::<Vec<_>>(),
|
||||
["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::<Vec<_>>(),
|
||||
["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::<Vec<_>>(),
|
||||
["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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user