//! 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, PartialOrd, Ord, Hash)] pub enum ContentType { /// Spoken dialogue — re-voiced with "Re-voice". Dialogue, /// Observable behavior description — re-voiced with "Describe". Behavior, /// Fact-bearing line — numbers, causal chains, denials. /// /// Factual lines bypass the LLM entirely and are served as base text. /// Spike 2 showed 2B models corrupt quantitative content ("14 crates in /// bay seven" → "fourteen crates are missing") and invert denials. /// The simulation is the truth layer — the LLM only handles register. Factual, } /// 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, } /// Universal rules prefix — format and output constraints only. /// Worldbuilding and style constraints belong in the culture persona or /// the TASK section, not here. 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\ OUTPUT CONSTRAINTS:\n\ - Speak in complete sentences. Short ones. Cut words that don't pull weight \ — but keep the sentence structure.\n\ - Do not summarize. Keep all facts from the input. Shorten phrasing, not content.\n\ - Do not add information that is not in the input. No invented details.\n\ - NOT: \"Parts late. Behind.\" YES: \"Parts never came. We're a shift behind.\""; /// Tell-state tone injectors (D-024 tell taxonomy). /// /// Each tell category has a concrete syntactic instruction with an example, /// tuned for 2B model capacity. Behavioral descriptions alone ("a beat late") /// don't produce differentiated output at this model size — concrete surface /// patterns are needed. /// /// Angry has a length-aware variant: on long content (16+ words), the default /// "make sentences shorter" instruction causes destructive compression that /// strips facts. Long-Angry instead preserves the full claim and focuses /// intensity on one sentence. /// /// ## Iteration history for Friendly and RoutineDeviation /// /// Spike 2 showed these two tells produce output indistinguishable from neutral /// on Gemma 2B. Tracked against a 3-iteration budget (#651): /// /// - **Iteration 1** (Sprint 26): Replaced abstract instructions with concrete /// surface-pattern examples. Friendly: closing aside pattern. RoutineDeviation: /// self-correction/echo pattern. Both more imitable for a 2B model. fn tell_injector(category: TellCategory) -> &'static str { match category { TellCategory::Nervous => { "TONE: Cut one clause from the sentence. Let a phrase trail off with a dash or ellipsis. \ Example: \"Yeah, it's just — doesn't matter.\" \ Do not say they seem nervous." } TellCategory::Angry => { "TONE: Make sentences shorter and more deliberate. One word should hit harder than expected. \ Example: \"Supervisor wants me in early.\" where \"wants\" carries weight. \ Do not say they seem angry." } TellCategory::Friendly => { // Iteration 1: replaced abstract "add detail" with a concrete closing-aside // pattern. 2B models need a recognisable syntactic target, not a description // of intent. "actually" / "so that's something" are learnable short tokens. "TONE: End with a brief unprompted aside — a phrase the person didn't need to say. \ Example: \"Parts are in, so that's something.\" or \"Shift's been quiet, actually.\" \ Do not use praise or warm adjectives. One short beat after the main point." } TellCategory::Guarded => { "TONE: Use formal, precise words. Answer exactly what was asked, nothing extra. \ Example: \"That's correct.\" instead of \"Yeah, exactly.\" \ Do not say they seem guarded." } TellCategory::RoutineDeviation => { // Iteration 1: replaced the two-step interrupted-thought pattern (too complex // for 2B) with a simpler self-correction / word-echo pattern. The model only // needs to repeat a key word or phrase — one concrete surface action. "TONE: Repeat a key word or phrase, as if catching mid-thought. \ Example: \"Logged it. Got it logged, yeah.\" or \"Pressure's — pressure's holding.\" \ Do not explain. Just the echo." } } } /// Long-content variant for Angry tell. Used when base_text is 16+ words /// to prevent destructive compression that strips facts. const ANGRY_LONG: &str = "\ TONE: Keep the full claim intact — do not cut facts. \ Make one sentence land harder than the rest. \ Example: \"Three reports filed. No budget. But they found half a million for the lounge.\" \ Do not say they seem angry."; /// Whether to use the long-content Angry variant. fn is_long_content(base_text: &str) -> bool { word_count(base_text) >= 16 } /// 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. /// Note: all entries must be lowercase — matched against `base_text.to_lowercase()`. /// The original-case version is reconstructed from the base text for the PRESERVE clause. 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)) .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. Tell-state tone modifier (only for medium/long content) /// 5. Epistemic marker protection (example-based, not keyword-list) /// 6. Occasional injections (imperative, positioned near TASK for 2B attention) /// 7. TASK + INPUT + repeated RULES reminder + OUTPUT: stop token /// /// The prompt is structured so that the most important instructions appear /// both at the start and immediately before OUTPUT: (double-prompt technique). /// 2B models de-weight early prompt sections; repeating near the end anchors /// the instructions in the attention window. /// /// `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, seed: u64, ) -> BuiltPrompt { let mut parts: Vec = Vec::with_capacity(16); let mut injections_fired: Vec = 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. 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()); // Angry on long content uses a special variant that preserves facts if tell == TellCategory::Angry && is_long_content(base_text) { parts.push(ANGRY_LONG.to_string()); } else { parts.push(tell_injector(tell).to_string()); } } } // 5. Epistemic marker protection — example-based, not keyword-list. // The old keyword-list approach ("must appear in the output: i heard, might have") // caused 2B models to emit markers as comma-separated lists. Example-based // integration teaches the model how to weave them into natural speech. let markers = extract_epistemic_markers(base_text); if !markers.is_empty() { parts.push(String::new()); if markers.len() == 1 { parts.push(format!( "PRESERVE: The phrase \"{}\" carries specific meaning. \ Use it naturally in the output as part of a sentence, not as a label. \ Example: \"I heard they stopped the line — twice, apparently.\"", markers[0] )); } else { let marker_list = markers.join("\", \""); parts.push(format!( "PRESERVE: The phrases \"{}\" carry specific meaning. \ Weave them naturally into the output sentence. Do not list them. \ Example: \"I heard they rerouted it. Might have been last cycle.\"", marker_list )); } } // 6. Occasional injections — imperative, positioned near TASK for 2B attention. // The composition engine already controls frequency — once an injection fires, // the model must execute it without discretion. // // ## Multiple INJECT blocks: known ambiguity // // When more than one injection fires on the same prompt, the model sees // multiple blocks with the same `INJECT:` label and no numeric index: // // INJECT: Include the phrase from this example in your output. // INPUT: discovers a critical part is missing // OUTPUT: Void take it. The coupling's not here. // // INJECT: Include the phrase from this example in your output. // INPUT: (second injection) // OUTPUT: (second output) // // 2B models have no mechanism to distinguish them. In practice they tend // to honour the last block (recency bias) and may ignore earlier ones. // The REMEMBER reminder at the end of the prompt references only the first // fired injection, which compounds the asymmetry. // // Accepted limitation: true multi-injection compliance is out of scope for // a 2B model. The low per-injection frequency (typically ≤0.25) means // simultaneous fires are rare. Cultures with multiple injections should // keep the set small and frequencies low enough that co-firing is a // statistical edge case rather than expected behaviour. 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::() < injection.frequency { parts.push(String::new()); // Imperative framing — no "when" conditional, just "include this" parts.push(format!("INJECT: Include the phrase from this example in your output.")); if let Some(ref example) = injection.example { parts.push(format!("INPUT: {}", example.input)); parts.push(format!("OUTPUT: {}", example.output)); } else { parts.push(injection.clause.clone()); } injections_fired.push(i); } } // 7. Task + input + repeated rules reminder + output stop token let task_verb = match content_type { ContentType::Dialogue => { "Re-voice the following in this character's voice. \ Keep all facts. Use complete sentences" } ContentType::Behavior => { "Describe the following action as a third-person observer. \ Preserve all individual actions in sequence. Do not extract conclusions" } ContentType::Factual => { // Factual lines must be intercepted before reaching build_prompt. // If this branch is hit, the caller has a bug. unreachable!("ContentType::Factual must not reach build_prompt — bypass at worker") } }; parts.push(String::new()); parts.push(format!("TASK: {}.", task_verb)); parts.push(format!("INPUT: {}", base_text)); // Double-prompt: repeat the critical constraints immediately before OUTPUT: // to anchor them in the 2B model's attention window. let mut reminder = String::from("REMEMBER:"); reminder.push_str(" Output exactly one line."); reminder.push_str(" Keep all facts from the input. Do not invent new details."); reminder.push_str(" Complete sentences, not fragments."); if !markers.is_empty() { reminder.push_str(&format!( " Use \"{}\" naturally in the sentence.", markers[0] )); } if !injections_fired.is_empty() { // Remind about the first fired injection if let Some(inj) = culture .occasional_injections .get(*injections_fired.first().unwrap()) { if let Some(ref ex) = inj.example { // Extract the key phrase from the example output let phrase = ex.output.split('.').next().unwrap_or(&ex.output); reminder.push_str(&format!(" Include a phrase like \"{}\".", phrase)); } } } parts.push(reminder); 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 van_maanens_star_culture() -> CultureProfile { CultureProfile { id: "van-maanens-star".into(), name: "Van Maanen's Star 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 Van Maanen's Star 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, ], }], behavior_modifiers: vec![], } } 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![], behavior_modifiers: 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 = van_maanens_star_culture(); let result = build_prompt(&culture, "Hello.", ContentType::Dialogue, None, 42); assert!(result.prompt.contains("PERSONA: You are a Van Maanen's Star 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 = van_maanens_star_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("TONE:")); } #[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("TONE:")); assert!(result.prompt.contains("trail off")); } #[test] fn guarded_tell_suppresses_oath_injection() { let culture = van_maanens_star_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 = van_maanens_star_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 = van_maanens_star_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")); } }