Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
632 lines
24 KiB
Rust
632 lines
24 KiB
Rust
//! 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 (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<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("INJECT: Include the phrase from this example in your output.".to_string());
|
||
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::{
|
||
CulturalValues, CultureProfile, 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"));
|
||
}
|
||
}
|