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:
@@ -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