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:
2026-03-07 16:35:39 +01:00
co-authored by Claude Opus 4.6
parent 2d03776365
commit 33030fcc58
7 changed files with 620 additions and 1 deletions
+4
View File
@@ -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![],
}
}
+1
View File
@@ -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.
+70
View File
@@ -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.01.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 =
+15
View File
@@ -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;
+491
View File
@@ -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 (815 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"));
}
}