feat(voice): complete Spike 2 voice pipeline with quality-tested prompt engine

Spike 2 delivers the full voice pipeline: queue → worker pool → sr-voice
child process (stdio JSONL) → cache → disk. Three rounds of quality testing
with Paula, Mellanie, and Gestalt produced iterative prompt improvements.

Prompt engine (prompt_builder.rs):
- Example-based epistemic marker integration (not keyword lists)
- Length-aware Angry tell variant (preserves facts on long content)
- Double-prompt technique: REMEMBER block repeats constraints near OUTPUT:
- Imperative injection framing (composition engine controls frequency)
- Anti-invention constraint ("do not add information not in the input")
- Universal RULES cleaned: worldbuilding moved to culture personas

Worker pool (worker.rs):
- Output post-processor strips after first newline (prevents prompt leakage)
- Watchdog poll loop (1s ticks) replaces blocking sleep for cancel
- Child health check before writing (try_wait)

Test infrastructure:
- voice_pipeline.rs: end-to-end test, auto-detects real sr-voice or mock
- voice_quality_batch.rs: 39 edge-case prompts for quality review
- mock-stdio.sh: Python JSONL mock for CI (no model needed)
- Makefile targets: test-voice-mock, test-voice-real

Quality results (Gemma 2B Q4_K_M, CPU ~13 t/s):
- Epistemic markers: naturally integrated (round 1 comma-lists fixed)
- Tell differentiation: 3/5 working (Nervous, Guarded, Angry)
- Information preservation: ~90% (up from ~70%)
- Prompt leakage: eliminated
- Open: Friendly/RoutineDeviation tells inert (#651), Factual bypass (#650)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 19:20:06 +01:00
co-authored by Claude Opus 4.6
parent e93a9e8b70
commit 9f34d030d7
6 changed files with 1201 additions and 65 deletions
+141 -56
View File
@@ -33,50 +33,73 @@ pub struct BuiltPrompt {
pub injections_fired: Vec<usize>,
}
/// Universal rules prefix — format constraints and negative injectors.
/// 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\
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.";
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 carefully worded tone modifier that influences
/// the LLM output without naming the emotion. The model shows, not tells.
/// 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.
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."
"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 => {
"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."
"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 => {
"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."
"TONE: Add one small extra detail or aside that wasn't strictly necessary. \
Example: \"Inspection's tomorrow — should be fine, though.\" \
Do not add compliments or forced 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."
"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 => {
"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."
"TONE: Start the sentence on topic, then add a brief unfinished thought about something else. \
Example: \"Pressure's fine. I was going to — anyway, it's logged.\" \
Do not explain what they were thinking about."
}
}
}
/// 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
@@ -131,10 +154,15 @@ fn extract_epistemic_markers(base_text: &str) -> Vec<&'static str> {
/// 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
/// 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.
@@ -145,7 +173,7 @@ pub fn build_prompt(
tell_state: Option<TellCategory>,
seed: u64,
) -> BuiltPrompt {
let mut parts: Vec<String> = Vec::with_capacity(10);
let mut parts: Vec<String> = Vec::with_capacity(16);
let mut injections_fired: Vec<usize> = Vec::new();
// 1. Universal rules
@@ -167,7 +195,47 @@ pub fn build_prompt(
}
}
// 4. Occasional injections — rolled by composition engine, not model
// 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.
let mut rng = ChaCha8Rng::seed_from_u64(seed);
for (i, injection) in culture.occasional_injections.iter().enumerate() {
// Gate off for suppressive tells
@@ -179,42 +247,59 @@ pub fn build_prompt(
if rng.random::<f32>() < injection.frequency {
parts.push(String::new());
parts.push(injection.clause.clone());
// 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);
}
}
// 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
// 7. Task + input + repeated rules reminder + output stop token
let task_verb = match content_type {
ContentType::Dialogue => "Re-voice",
ContentType::Behavior => "Describe",
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"
}
};
parts.push(String::new());
parts.push(format!("TASK: {} the following in this character's voice.", task_verb));
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 {
@@ -379,7 +464,7 @@ mod tests {
42,
);
// 3 words — should skip tell injector
assert!(!result.prompt.contains("TELL-STATE:"));
assert!(!result.prompt.contains("TONE:"));
}
#[test]
@@ -392,8 +477,8 @@ mod tests {
Some(TellCategory::Nervous),
42,
);
assert!(result.prompt.contains("TELL-STATE:"));
assert!(result.prompt.contains("slightly faster than usual"));
assert!(result.prompt.contains("TONE:"));
assert!(result.prompt.contains("trail off"));
}
#[test]
+22 -8
View File
@@ -205,18 +205,23 @@ impl VoicePipe {
// Watchdog: kill the child if it doesn't respond within the timeout.
// This unblocks the read_line below (stdout closes → read returns empty).
// Uses a poll loop (1s ticks) so the watchdog exits promptly on cancel.
let child_id = self.child.id();
let cancel = Arc::new(AtomicBool::new(false));
let cancel_clone = Arc::clone(&cancel);
let timeout_secs = INFERENCE_TIMEOUT.as_secs();
let watchdog = std::thread::spawn(move || {
std::thread::sleep(INFERENCE_TIMEOUT);
if !cancel_clone.load(Ordering::Relaxed) {
tracing::warn!(pid = child_id, "sr-voice inference timeout — killing child");
let _ = Command::new("kill")
.arg("-9")
.arg(child_id.to_string())
.output();
for _ in 0..timeout_secs {
std::thread::sleep(Duration::from_secs(1));
if cancel_clone.load(Ordering::Relaxed) {
return;
}
}
tracing::warn!(pid = child_id, "sr-voice inference timeout — killing child");
let _ = Command::new("kill")
.arg("-9")
.arg(child_id.to_string())
.output();
});
let mut response_line = String::new();
@@ -241,7 +246,16 @@ impl VoicePipe {
body["text"]
.as_str()
.map(|s| s.trim().to_string())
.map(|s| {
// Post-processor: strip everything after the first newline.
// Prevents prompt leakage (survey bleed, example continuation)
// that 2B models sometimes produce after the first valid line.
let trimmed = s.trim();
match trimmed.find('\n') {
Some(pos) => trimmed[..pos].trim().to_string(),
None => trimmed.to_string(),
}
})
.ok_or_else(|| "response missing 'text' field".to_string())
}
}