feat(runtime): add deterministic turn seam

This commit is contained in:
Codex
2026-07-28 13:05:58 +08:00
parent 5e1c0e74b4
commit ea209c9b69
+466 -3
View File
@@ -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);
}
}