Files
settled-reach/server/src/voice/prompt_builder.rs
T
jpmschweitzerandClaude Opus 4.6 e8263e209e refactor(data): rename Krenn to Van Maanen's Star
System S-057 assigned to real star GJ 35 (Van Maanen's Star, DG white
dwarf at 13.9 ly). Renamed across all content, server code, docs,
decisions, wiki lore, and config files. 224 files updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:23:26 +01:00

632 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<usize>,
}
/// 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 (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))
.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<TellCategory>,
seed: u64,
) -> BuiltPrompt {
let mut parts: Vec<String> = Vec::with_capacity(16);
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. 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::<f32>() < 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"));
}
}