feat(engine): voice pipeline Phase 1 — composition engine and data model
Add the voice pipeline composition engine (D-138 Spike 2, Phase 1): - voice/prompt_builder.rs: full prompt assembly from culture profile, tell state, and base text. Handles occasional injection gating, epistemic marker extraction, content-length-gated tell injection. 16 unit tests. - blueprint.rs: CultureProfile gains voice_persona, voice_examples, occasional_injections fields. NpcBlueprint gains tell_behaviors. OccasionalInjection struct with kind discriminator (oath/faith/ hesitancy/etc), frequency, and tell-suppression gating. - culture-krenn.ron: v2 voice injector from Spike 1 — persona block, 3 examples, oath injection at 0.25 frequency. - D-138 amended: Phi-3 dropped entirely, exact Gemma 2B provenance documented. Model file renamed to gemma2.gguf. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -100,4 +100,42 @@
|
||||
// Deceptive: betrayal of trust is the worst thing you can do here.
|
||||
disfavored_traits: [Reclusive, Deceptive],
|
||||
),
|
||||
|
||||
// Voice pipeline: persona block, examples, and occasional injections.
|
||||
// These feed the composition engine (D-138) — the LLM sees exactly what's here.
|
||||
// v2 injector validated in Spike 1 (59 prompts, 16.6 t/s CPU).
|
||||
voice_persona: Some(
|
||||
"PERSONA: You are a Krenn station worker.\n1. Be direct. No pleasantries. Everyone is short on time.\n2. You're working-class and pragmatic. Competence earns respect, not rank.\n3. You're suspicious of distant authority — management that hasn't worked a shift.\n4. You're economical with language. You don't express what the situation doesn't call for.\n5. You use first names. Family names belong on contracts.\n6. Loyalty runs narrow and deep. Your crew, your shift, your street.\n7. You greet briefly: \"hey\", \"morning\", \"shift treating you alright?\"\n8. You're not rude — you're honest. If something's wrong, you say so."
|
||||
),
|
||||
|
||||
voice_examples: [
|
||||
(
|
||||
input: "declines to answer a question about the overnight run",
|
||||
output: "Look, that's not mine to say.",
|
||||
),
|
||||
(
|
||||
input: "acknowledges a colleague's greeting while continuing to work",
|
||||
output: "Hey. Yeah. Catch you at shift end.",
|
||||
),
|
||||
(
|
||||
input: "thanks a colleague for covering a shift",
|
||||
output: "Appreciated. See you at handoff.",
|
||||
),
|
||||
],
|
||||
|
||||
occasional_injections: [
|
||||
// Oath vocabulary — void-adjacent exclamations. Krenn swear by what kills you:
|
||||
// vacuum, void, stars. Rolled at 25% frequency by the composition engine.
|
||||
// Gated off for suppressive tells to avoid conflicting instructions (Spike 1 finding).
|
||||
(
|
||||
kind: "oath",
|
||||
clause: "When something genuinely surprises or frustrates you, expressions like \"void take it,\" \"stars,\" \"cold vacuum,\" or \"blood and void\" come naturally. Use one in this line.",
|
||||
example: Some((
|
||||
input: "discovers a critical part is missing from a shipment",
|
||||
output: "Void take it. The coupling's not here.",
|
||||
)),
|
||||
frequency: 0.25,
|
||||
suppress_on_tells: [Guarded, RoutineDeviation, Friendly],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -411,7 +411,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Date:** 2026-03-07
|
||||
- **Decision:** NPC observable behaviors and dialogue are processed through an LLM re-voicing pipeline that translates culture-neutral semantic base text into character-voiced output. The pipeline is a background runtime enhancement, not a live generation system. Tell behaviors are base-text passthrough — always. Active tell state influences the re-voicing prompt for surrounding content (tells are read-only inputs to the LLM, never LLM outputs). The game is complete and functional without the pipeline; it is an enhancement that elevates voice quality for players with sufficient hardware.
|
||||
- **Architecture:**
|
||||
- **Model:** Gemma 2 2B (Q4_K_M, ~1.5GB), bundled with game. Phi-3 (MIT) as fallback. No Chinese-origin models.
|
||||
- **Model:** Gemma 2 2B IT Q4_K_M (~1.6GB), bundled as `server/models/gemma2.gguf`. No fallback model. *(Amended 2026-03-07: Phi-3 dropped entirely after Spike 1 — Gemma 2B produces superior culturally-differentiated output at the same quantization. Original GGUF: `gemma-2-2b-it-Q4_K_M.gguf` from Hugging Face bartowski/gemma-2-2b-it-GGUF.)*
|
||||
- **Runtime:** `llama-cpp-rs` with GGUF format. Separate inference thread pool at below-normal priority.
|
||||
- **Content tiers:** Baked (hub zones, build-time, human-reviewed) → Pre-voiced (background queue, priority-ordered) → Base text fallback (always present).
|
||||
- **Tell treatment:** Passthrough always. Tell state flows into re-voicing prompts as universal tone injectors. Cultural flavor is conditional and additive — humans are humans first; micro-expressions and body language must remain universally recognizable. Per-culture tell-tone tables are optional enrichment, not a launch requirement.
|
||||
|
||||
@@ -245,6 +245,9 @@ fn hardcoded_krenn_culture() -> CultureProfile {
|
||||
favored_traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest, PersonalityTrait::Curious],
|
||||
disfavored_traits: vec![PersonalityTrait::Reclusive, PersonalityTrait::Deceptive],
|
||||
},
|
||||
voice_persona: None,
|
||||
voice_examples: vec![],
|
||||
occasional_injections: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +582,7 @@ fn generate_npc_blueprint(
|
||||
observable_behaviors,
|
||||
cultural_markers,
|
||||
relationships: vec![],
|
||||
tell_behaviors: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod npc;
|
||||
pub mod perception;
|
||||
pub mod simulation;
|
||||
pub mod storyteller;
|
||||
pub mod voice;
|
||||
// test_world::reset is always compiled (used by simulation::input).
|
||||
// Room definitions, constants, and setup_gauntlet are gated behind
|
||||
// the "gauntlet" feature (default-on) to allow stripping from release builds.
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::PersonalityTrait;
|
||||
use crate::npc::tell_state::TellCategory;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zone identity specification (input — filled by copy team, ticket #609)
|
||||
@@ -105,6 +106,16 @@ pub struct CultureProfile {
|
||||
pub speech: SpeechPatterns,
|
||||
/// Cultural values that bias personality trait selection.
|
||||
pub values: CulturalValues,
|
||||
/// Full persona block for voice pipeline LLM prompts (may be absent).
|
||||
#[serde(default)]
|
||||
pub voice_persona: Option<String>,
|
||||
/// Example input/output pairs for voice pipeline prompts.
|
||||
#[serde(default)]
|
||||
pub voice_examples: Vec<VoiceExample>,
|
||||
/// Occasional prompt injections rolled per-prompt by the composition engine.
|
||||
/// Some cultures have none, others several. No cap on count.
|
||||
#[serde(default)]
|
||||
pub occasional_injections: Vec<OccasionalInjection>,
|
||||
}
|
||||
|
||||
/// Naming conventions for NPC name generation.
|
||||
@@ -146,6 +157,47 @@ pub struct CulturalValues {
|
||||
pub disfavored_traits: Vec<PersonalityTrait>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice pipeline types (D-138, Spike 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Example input/output pair for voice pipeline prompts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VoiceExample {
|
||||
/// The input scenario description.
|
||||
pub input: String,
|
||||
/// The expected voiced output.
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// Occasional prompt injection rolled per-prompt by the composition engine.
|
||||
///
|
||||
/// The model never decides injection frequency — the composition engine rolls
|
||||
/// a random check per prompt and either includes the clause or doesn't.
|
||||
/// This solves the fundamental problem that small LLMs can't self-gate
|
||||
/// vocabulary frequency across independent inference calls.
|
||||
///
|
||||
/// Different `kind` values represent different categories of injection:
|
||||
/// oaths ("void take it"), faith expressions ("God help us"),
|
||||
/// verbal hesitancy, greetings ("hey"), etc. The composition engine
|
||||
/// treats them uniformly — `kind` exists for human readability and
|
||||
/// future filtering.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OccasionalInjection {
|
||||
/// Injection category (e.g. "oath", "faith", "hesitancy", "greeting").
|
||||
pub kind: String,
|
||||
/// LLM instruction text to inject into the prompt.
|
||||
pub clause: String,
|
||||
/// Optional example pair demonstrating the injection in use.
|
||||
#[serde(default)]
|
||||
pub example: Option<VoiceExample>,
|
||||
/// Probability of inclusion per prompt (0.0–1.0).
|
||||
pub frequency: f32,
|
||||
/// Tell categories that suppress this injection to avoid conflicting instructions.
|
||||
#[serde(default)]
|
||||
pub suppress_on_tells: Vec<TellCategory>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NPC blueprint (output — generator produces these)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -169,6 +221,20 @@ pub struct NpcBlueprint {
|
||||
pub cultural_markers: CulturalMarkers,
|
||||
/// Relationship slots (0-3 per D-024).
|
||||
pub relationships: Vec<BlueprintRelationship>,
|
||||
/// Tell-specific behaviors — always passed through verbatim, never re-voiced.
|
||||
#[serde(default)]
|
||||
pub tell_behaviors: Vec<TellBehavior>,
|
||||
}
|
||||
|
||||
/// A tell-specific behavior string that is passed through verbatim.
|
||||
/// Tell behaviors are never re-voiced by the voice pipeline — they are
|
||||
/// authored text that plays exactly as written.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TellBehavior {
|
||||
/// Which tell category triggers this behavior.
|
||||
pub category: TellCategory,
|
||||
/// The behavior text shown to the player.
|
||||
pub base_text: String,
|
||||
}
|
||||
|
||||
/// Cultural markers attached to a generated NPC.
|
||||
@@ -285,6 +351,9 @@ mod tests {
|
||||
favored_traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest],
|
||||
disfavored_traits: vec![PersonalityTrait::Reclusive],
|
||||
},
|
||||
voice_persona: None,
|
||||
voice_examples: vec![],
|
||||
occasional_injections: vec![],
|
||||
};
|
||||
|
||||
let ron_str =
|
||||
@@ -312,6 +381,7 @@ mod tests {
|
||||
relationship_type: "colleague".into(),
|
||||
valence: RelationshipValence::Positive,
|
||||
}],
|
||||
tell_behaviors: vec![],
|
||||
};
|
||||
|
||||
let ron_str =
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Voice pipeline integration (D-138, Spike 2).
|
||||
//!
|
||||
//! Translates culture-neutral semantic base text into character-voiced output
|
||||
//! via an LLM re-voicing pipeline. The pipeline is a background runtime
|
||||
//! enhancement — the game is complete and functional without it.
|
||||
//!
|
||||
//! ## Components
|
||||
//!
|
||||
//! - `prompt_builder` — composition engine: NPC data + culture + tell state → prompt string
|
||||
//! - `cache` — MessagePack voice cache (store/retrieve, length-gated variants)
|
||||
//! - `queue` — crossbeam work queue with priority + backpressure
|
||||
//! - `worker` — inference worker pool (dynamic scaling, owns sr-voice HTTP clients)
|
||||
//! - `hardware` — hardware detection + dynamic sr-voice instance management
|
||||
|
||||
pub mod prompt_builder;
|
||||
@@ -0,0 +1,491 @@
|
||||
//! Composition engine for the voice pipeline (D-138, Spike 2).
|
||||
//!
|
||||
//! Assembles LLM prompts from NPC data, culture profile, tell state, and
|
||||
//! base text. Handles occasional injection gating, epistemic marker extraction,
|
||||
//! and tell-state tone modification.
|
||||
//!
|
||||
//! The composition engine controls what goes into each prompt — the model never
|
||||
//! decides frequency of cultural markers. It either receives the clause or it
|
||||
//! doesn't.
|
||||
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
use crate::npc::blueprint::CultureProfile;
|
||||
use crate::npc::tell_state::TellCategory;
|
||||
|
||||
/// Content type determines the task verb in the prompt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ContentType {
|
||||
/// Spoken dialogue — re-voiced with "Re-voice".
|
||||
Dialogue,
|
||||
/// Observable behavior description — re-voiced with "Describe".
|
||||
Behavior,
|
||||
}
|
||||
|
||||
/// Result of prompt building, including which injections fired.
|
||||
#[derive(Debug)]
|
||||
pub struct BuiltPrompt {
|
||||
/// The assembled prompt string ready for LLM inference.
|
||||
pub prompt: String,
|
||||
/// Which occasional injections were included (by index into culture's list).
|
||||
pub injections_fired: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Universal rules prefix — format constraints and negative injectors.
|
||||
const RULES: &str = "\
|
||||
RULES: Output exactly one line of voiced text. \
|
||||
No explanation. No options. No markdown. No labels. Stop after one line.\n\n\
|
||||
CONSTRAINTS:\n\
|
||||
- Use occupational titles (shift lead, supervisor, foreman), not military ranks.\n\
|
||||
- Technology: insert (neural implant), span gate (FTL transit), \
|
||||
horizon gate (alien gate), the Reach (settled systems).\n\
|
||||
- No wit, quips, or wordplay. Humor is dry and rare.\n\
|
||||
- Do not reference Earth as a current place. Cultural heritage markers are natural.";
|
||||
|
||||
/// Tell-state tone injectors (D-024 tell taxonomy).
|
||||
///
|
||||
/// Each tell category has a carefully worded tone modifier that influences
|
||||
/// the LLM output without naming the emotion. The model shows, not tells.
|
||||
fn tell_injector(category: TellCategory) -> &'static str {
|
||||
match category {
|
||||
TellCategory::Nervous => {
|
||||
"TELL-STATE: This character's words come slightly faster than usual, briefer. \
|
||||
They don't elaborate. A phrase drops off before it's finished. \
|
||||
Do not say they seem nervous or afraid."
|
||||
}
|
||||
TellCategory::Angry => {
|
||||
"TELL-STATE: This character's words are measured and deliberate — not shouting, containing. \
|
||||
A word hits harder than the context requires. Do not say they seem angry."
|
||||
}
|
||||
TellCategory::Friendly => {
|
||||
"TELL-STATE: This character offers slightly more than asked. \
|
||||
A word of genuine warmth lands casually. They don't perform friendliness — it just shows. \
|
||||
Do not add compliments or over-warmth."
|
||||
}
|
||||
TellCategory::Guarded => {
|
||||
"TELL-STATE: This character chooses each word with a half-second more care than normal. \
|
||||
They answer what was asked, no more. There is nothing wrong here. \
|
||||
Do not say they seem guarded or evasive."
|
||||
}
|
||||
TellCategory::RoutineDeviation => {
|
||||
"TELL-STATE: This character is elsewhere in their mind. \
|
||||
They are present but preoccupied — answers are on track but land a beat late. \
|
||||
Do not explain why or name what they're thinking about."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Known epistemic markers that must be preserved through re-voicing.
|
||||
///
|
||||
/// When the base text contains these phrases, the LLM is instructed to
|
||||
/// preserve them. Without this, 2B models strip hedges and evidentials,
|
||||
/// converting "I heard the night crew stopped the line" to
|
||||
/// "Line tripped twice. What's the plan?" — losing the epistemic framing
|
||||
/// that is semantically load-bearing for the perception system.
|
||||
const EPISTEMIC_MARKERS: &[&str] = &[
|
||||
"I heard",
|
||||
"I think",
|
||||
"I saw",
|
||||
"I noticed",
|
||||
"someone told me",
|
||||
"they say",
|
||||
"apparently",
|
||||
"supposedly",
|
||||
"might have",
|
||||
"could have",
|
||||
"seems like",
|
||||
"looks like",
|
||||
];
|
||||
|
||||
/// Count words in a string (whitespace-delimited).
|
||||
fn word_count(s: &str) -> usize {
|
||||
s.split_whitespace().count()
|
||||
}
|
||||
|
||||
/// Determine the length tier for tell-variant gating.
|
||||
///
|
||||
/// - Short (≤7 words): neutral only — 2B model produces identical output
|
||||
/// - Medium (8–15 words): 3 variants (neutral, high-affect, guarded)
|
||||
/// - Long (16+ words): all applicable tells
|
||||
fn is_short_content(base_text: &str) -> bool {
|
||||
word_count(base_text) <= 7
|
||||
}
|
||||
|
||||
/// Extract epistemic markers present in the base text.
|
||||
fn extract_epistemic_markers(base_text: &str) -> Vec<&'static str> {
|
||||
let lower = base_text.to_lowercase();
|
||||
EPISTEMIC_MARKERS
|
||||
.iter()
|
||||
.filter(|marker| lower.contains(&marker.to_lowercase()))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a complete LLM prompt for re-voicing.
|
||||
///
|
||||
/// The composition engine assembles the prompt from:
|
||||
/// 1. Universal RULES prefix (format constraints, negative injectors)
|
||||
/// 2. Culture-specific PERSONA block (from `culture.voice_persona`)
|
||||
/// 3. Culture-specific examples
|
||||
/// 4. Occasional injections (rolled per-prompt via seeded RNG)
|
||||
/// 5. Tell-state tone modifier (only for medium/long content)
|
||||
/// 6. Epistemic marker protection
|
||||
/// 7. TASK + INPUT + OUTPUT: stop token
|
||||
///
|
||||
/// `seed` should be deterministic per (npc_id, content_index, world_seed)
|
||||
/// so that the same prompt produces the same injection pattern on re-run.
|
||||
pub fn build_prompt(
|
||||
culture: &CultureProfile,
|
||||
base_text: &str,
|
||||
content_type: ContentType,
|
||||
tell_state: Option<TellCategory>,
|
||||
seed: u64,
|
||||
) -> BuiltPrompt {
|
||||
let mut parts: Vec<String> = Vec::with_capacity(10);
|
||||
let mut injections_fired: Vec<usize> = Vec::new();
|
||||
|
||||
// 1. Universal rules
|
||||
parts.push(RULES.to_string());
|
||||
|
||||
// 2. Culture persona
|
||||
if let Some(ref persona) = culture.voice_persona {
|
||||
parts.push(String::new());
|
||||
parts.push(persona.clone());
|
||||
}
|
||||
|
||||
// 3. Culture examples
|
||||
if !culture.voice_examples.is_empty() {
|
||||
parts.push(String::new());
|
||||
parts.push("EXAMPLES:".to_string());
|
||||
for ex in &culture.voice_examples {
|
||||
parts.push(format!("INPUT: {}", ex.input));
|
||||
parts.push(format!("OUTPUT: {}", ex.output));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Occasional injections — rolled by composition engine, not model
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(seed);
|
||||
for (i, injection) in culture.occasional_injections.iter().enumerate() {
|
||||
// Gate off for suppressive tells
|
||||
if let Some(tell) = tell_state {
|
||||
if injection.suppress_on_tells.contains(&tell) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if rng.random::<f32>() < injection.frequency {
|
||||
parts.push(String::new());
|
||||
parts.push(injection.clause.clone());
|
||||
if let Some(ref example) = injection.example {
|
||||
parts.push(format!("INPUT: {}", example.input));
|
||||
parts.push(format!("OUTPUT: {}", example.output));
|
||||
}
|
||||
injections_fired.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Tell-state tone modifier (skip for short content — 2B model can't differentiate)
|
||||
if !is_short_content(base_text) {
|
||||
if let Some(tell) = tell_state {
|
||||
parts.push(String::new());
|
||||
parts.push(tell_injector(tell).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Epistemic marker protection
|
||||
let markers = extract_epistemic_markers(base_text);
|
||||
if !markers.is_empty() {
|
||||
parts.push(String::new());
|
||||
let marker_list = markers.join(", ");
|
||||
parts.push(format!(
|
||||
"PRESERVE: The following phrases must appear in the output: {}",
|
||||
marker_list
|
||||
));
|
||||
}
|
||||
|
||||
// 7. Task + input + output stop token
|
||||
let task_verb = match content_type {
|
||||
ContentType::Dialogue => "Re-voice",
|
||||
ContentType::Behavior => "Describe",
|
||||
};
|
||||
parts.push(String::new());
|
||||
parts.push(format!("TASK: {} the following in this character's voice.", task_verb));
|
||||
parts.push(format!("INPUT: {}", base_text));
|
||||
parts.push("OUTPUT:".to_string());
|
||||
|
||||
BuiltPrompt {
|
||||
prompt: parts.join("\n"),
|
||||
injections_fired,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::blueprint::{
|
||||
CultureProfile, CulturalValues, NamingConventions, OccasionalInjection, SpeechPatterns,
|
||||
VoiceExample,
|
||||
};
|
||||
use crate::npc::PersonalityTrait;
|
||||
|
||||
fn krenn_culture() -> CultureProfile {
|
||||
CultureProfile {
|
||||
id: "krenn".into(),
|
||||
name: "Krenn System Culture".into(),
|
||||
description: "Working-class pragmatic".into(),
|
||||
naming: NamingConventions {
|
||||
style: "compact".into(),
|
||||
given_names: vec!["Kael".into()],
|
||||
family_names: vec!["Davan".into()],
|
||||
family_name_used_socially: false,
|
||||
},
|
||||
speech: SpeechPatterns {
|
||||
register: "direct".into(),
|
||||
filler_words: vec!["look".into()],
|
||||
greetings: vec!["hey".into()],
|
||||
farewells: vec!["shift's calling".into()],
|
||||
exclamations: vec!["void take it".into()],
|
||||
},
|
||||
values: CulturalValues {
|
||||
description: "Pragmatic".into(),
|
||||
favored_traits: vec![PersonalityTrait::Bold],
|
||||
disfavored_traits: vec![PersonalityTrait::Reclusive],
|
||||
},
|
||||
voice_persona: Some(
|
||||
"PERSONA: You are a Krenn station worker.\n\
|
||||
1. Be direct. No pleasantries.\n\
|
||||
2. You're working-class and pragmatic."
|
||||
.into(),
|
||||
),
|
||||
voice_examples: vec![
|
||||
VoiceExample {
|
||||
input: "declines to answer a question".into(),
|
||||
output: "Look, that's not mine to say.".into(),
|
||||
},
|
||||
],
|
||||
occasional_injections: vec![OccasionalInjection {
|
||||
kind: "oath".into(),
|
||||
clause: "When something surprises you, use an oath like \"void take it.\"".into(),
|
||||
example: Some(VoiceExample {
|
||||
input: "discovers a critical part is missing".into(),
|
||||
output: "Void take it. The coupling's not here.".into(),
|
||||
}),
|
||||
frequency: 0.25,
|
||||
suppress_on_tells: vec![
|
||||
TellCategory::Guarded,
|
||||
TellCategory::RoutineDeviation,
|
||||
TellCategory::Friendly,
|
||||
],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn bare_culture() -> CultureProfile {
|
||||
CultureProfile {
|
||||
id: "bare".into(),
|
||||
name: "Bare Culture".into(),
|
||||
description: "No voice data".into(),
|
||||
naming: NamingConventions {
|
||||
style: "plain".into(),
|
||||
given_names: vec![],
|
||||
family_names: vec![],
|
||||
family_name_used_socially: true,
|
||||
},
|
||||
speech: SpeechPatterns {
|
||||
register: "neutral".into(),
|
||||
filler_words: vec![],
|
||||
greetings: vec![],
|
||||
farewells: vec![],
|
||||
exclamations: vec![],
|
||||
},
|
||||
values: CulturalValues {
|
||||
description: "Neutral".into(),
|
||||
favored_traits: vec![],
|
||||
disfavored_traits: vec![],
|
||||
},
|
||||
voice_persona: None,
|
||||
voice_examples: vec![],
|
||||
occasional_injections: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_contains_rules_prefix() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(&culture, "Hello.", ContentType::Dialogue, None, 42);
|
||||
assert!(result.prompt.contains("RULES:"));
|
||||
assert!(result.prompt.contains("No explanation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_contains_persona_when_present() {
|
||||
let culture = krenn_culture();
|
||||
let result = build_prompt(&culture, "Hello.", ContentType::Dialogue, None, 42);
|
||||
assert!(result.prompt.contains("PERSONA: You are a Krenn station worker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_omits_persona_when_absent() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(&culture, "Hello.", ContentType::Dialogue, None, 42);
|
||||
assert!(!result.prompt.contains("PERSONA:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_contains_examples_when_present() {
|
||||
let culture = krenn_culture();
|
||||
let result = build_prompt(&culture, "Hello.", ContentType::Dialogue, None, 42);
|
||||
assert!(result.prompt.contains("EXAMPLES:"));
|
||||
assert!(result.prompt.contains("Look, that's not mine to say."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogue_uses_re_voice_verb() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(&culture, "Test line.", ContentType::Dialogue, None, 42);
|
||||
assert!(result.prompt.contains("TASK: Re-voice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavior_uses_describe_verb() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(&culture, "walks away", ContentType::Behavior, None, 42);
|
||||
assert!(result.prompt.contains("TASK: Describe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_ends_with_output_stop_token() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(&culture, "Test.", ContentType::Dialogue, None, 42);
|
||||
assert!(result.prompt.ends_with("OUTPUT:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_content_skips_tell_injector() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(
|
||||
&culture,
|
||||
"Inspection's next week.",
|
||||
ContentType::Dialogue,
|
||||
Some(TellCategory::Nervous),
|
||||
42,
|
||||
);
|
||||
// 3 words — should skip tell injector
|
||||
assert!(!result.prompt.contains("TELL-STATE:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_content_includes_tell_injector() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(
|
||||
&culture,
|
||||
"The overnight delivery came in clean and we logged everything properly this time around",
|
||||
ContentType::Dialogue,
|
||||
Some(TellCategory::Nervous),
|
||||
42,
|
||||
);
|
||||
assert!(result.prompt.contains("TELL-STATE:"));
|
||||
assert!(result.prompt.contains("slightly faster than usual"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_tell_suppresses_oath_injection() {
|
||||
let culture = krenn_culture();
|
||||
// Run many seeds — none should fire oath with Guarded tell
|
||||
for seed in 0..100 {
|
||||
let result = build_prompt(
|
||||
&culture,
|
||||
"Something went wrong with the shipment.",
|
||||
ContentType::Dialogue,
|
||||
Some(TellCategory::Guarded),
|
||||
seed,
|
||||
);
|
||||
assert!(
|
||||
result.injections_fired.is_empty(),
|
||||
"Oath injection fired with Guarded tell at seed {}",
|
||||
seed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_tell_allows_oath_injection() {
|
||||
let culture = krenn_culture();
|
||||
// With enough seeds, at least one should fire (frequency 0.25)
|
||||
let fired_count = (0..100)
|
||||
.filter(|&seed| {
|
||||
let result = build_prompt(
|
||||
&culture,
|
||||
"Something went wrong.",
|
||||
ContentType::Dialogue,
|
||||
None,
|
||||
seed,
|
||||
);
|
||||
!result.injections_fired.is_empty()
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
fired_count > 0,
|
||||
"Expected at least 1 oath injection in 100 seeds"
|
||||
);
|
||||
assert!(
|
||||
fired_count < 50,
|
||||
"Expected fewer than 50 oath injections in 100 seeds (freq=0.25), got {}",
|
||||
fired_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_injection_for_same_seed() {
|
||||
let culture = krenn_culture();
|
||||
let result1 = build_prompt(&culture, "Test.", ContentType::Dialogue, None, 42);
|
||||
let result2 = build_prompt(&culture, "Test.", ContentType::Dialogue, None, 42);
|
||||
assert_eq!(result1.prompt, result2.prompt);
|
||||
assert_eq!(result1.injections_fired, result2.injections_fired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epistemic_marker_preserved() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(
|
||||
&culture,
|
||||
"I heard the night crew had to stop the line twice.",
|
||||
ContentType::Dialogue,
|
||||
None,
|
||||
42,
|
||||
);
|
||||
assert!(result.prompt.contains("PRESERVE:"));
|
||||
assert!(result.prompt.contains("I heard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_epistemic_marker_no_preserve() {
|
||||
let culture = bare_culture();
|
||||
let result = build_prompt(
|
||||
&culture,
|
||||
"The parts arrived yesterday.",
|
||||
ContentType::Dialogue,
|
||||
None,
|
||||
42,
|
||||
);
|
||||
assert!(!result.prompt.contains("PRESERVE:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_short() {
|
||||
assert!(is_short_content("Hello there."));
|
||||
assert!(is_short_content("Inspection's next week."));
|
||||
assert!(is_short_content("One two three four five six seven"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_medium() {
|
||||
assert!(!is_short_content("One two three four five six seven eight"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user