Files
settled-reach/server/src/voice/cache.rs
T
jpmschweitzerandClaude Opus 4.6 e93a9e8b70 fix(voice): address PR review findings — 3 critical, 5 warning, 4 suggestion
Critical fixes:
- Pause mechanism: workers now hold requests during pause instead of
  dropping them. Queue and worker pool share the same AtomicBool flag
  via VoiceQueue::paused_flag(). Submit() rejects while paused.
- Seed type: sr-voice accepts u64 seeds over IPC (explicit u32 truncation
  for llama.cpp sampler, documented).

Warning fixes:
- HashMap → BTreeMap in cache.rs and worker.rs (D-010 determinism mandate).
  Added Ord derives to CacheKey, ContentType, TellCategory.
- VoicePipe::generate() watchdog kills child after 120s timeout to prevent
  indefinite blocking on read_line.
- VoiceCacheStore Drop impl calls save_all() on shutdown.
- trait-modifiers.ron: fixed 3 wrong trait names (Impulsive→Compassionate,
  Methodical→Incurious, Stubborn→Ruthless) to match PersonalityTrait enum.

Suggestion fixes:
- Worker spawn: log error + reduce pool instead of panic on thread failure.
- on_battery(): added macOS detection via pmset.
- Epistemic markers: lowercased constants, removed redundant to_lowercase().
- cache.rs: documented non-atomic write tradeoff.
- queue.rs: reprioritize() bypasses pause check (it runs during pause).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:11:46 +01:00

358 lines
12 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.
//! MessagePack voice cache (D-138, Spike 2).
//!
//! Stores re-voiced text keyed by (npc, content, tell state, culture).
//! Length-gated variant count: short lines cache neutral only, medium lines
//! cache 3 variants, long lines cache all applicable tells.
//!
//! Baked content is just pre-populated cache — `make voice-bake` writes to
//! the same directory. No separate baked path.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::PathBuf;
use crate::npc::tell_state::TellCategory;
use crate::voice::prompt_builder::ContentType;
/// Cache key for a single voiced line.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CacheKey {
pub culture_id: String,
pub npc_stable_id: u64,
pub content_type: ContentType,
pub content_index: u16,
/// Tell state variant. `None` = neutral (used for short content).
pub tell_state: Option<TellCategory>,
}
/// A single zone's voice cache — maps cache keys to voiced text.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ZoneVoiceCache {
/// Model version hash — cache miss if this doesn't match.
pub model_version: String,
/// Injector version hash — cache miss if this doesn't match.
pub injector_version: String,
/// Cached voiced lines.
pub entries: BTreeMap<CacheKey, String>,
}
impl ZoneVoiceCache {
pub fn new(model_version: String, injector_version: String) -> Self {
Self {
model_version,
injector_version,
entries: BTreeMap::new(),
}
}
/// Look up a cached voiced line. Returns `None` on miss.
pub fn lookup(&self, key: &CacheKey) -> Option<&str> {
self.entries.get(key).map(|s| s.as_str())
}
/// Store a voiced line in the cache.
pub fn store(&mut self, key: CacheKey, text: String) {
self.entries.insert(key, text);
}
/// Number of cached entries.
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// Manages voice caches across zones with disk persistence.
#[derive(Debug)]
pub struct VoiceCacheStore {
/// Base directory for cache files.
base_dir: PathBuf,
/// World seed — part of the directory path.
world_seed: u64,
/// Current model version hash.
model_version: String,
/// Current injector version hash.
injector_version: String,
/// Loaded zone caches.
zones: BTreeMap<u32, ZoneVoiceCache>,
}
impl VoiceCacheStore {
/// Create a new cache store. Does not load any zones yet.
pub fn new(
base_dir: PathBuf,
world_seed: u64,
model_version: String,
injector_version: String,
) -> Self {
Self {
base_dir,
world_seed,
model_version,
injector_version,
zones: BTreeMap::new(),
}
}
/// Get or load the cache for a zone.
pub fn zone_cache(&mut self, zone_id: u32) -> &mut ZoneVoiceCache {
if !self.zones.contains_key(&zone_id) {
let cache = self.load_zone(zone_id).unwrap_or_else(|| {
ZoneVoiceCache::new(
self.model_version.clone(),
self.injector_version.clone(),
)
});
self.zones.insert(zone_id, cache);
}
self.zones.get_mut(&zone_id).unwrap()
}
/// Look up a voiced line across the right zone cache.
pub fn lookup(&mut self, zone_id: u32, key: &CacheKey) -> Option<String> {
let cache = self.zone_cache(zone_id);
cache.lookup(key).map(|s| s.to_string())
}
/// Store a voiced line and return the stored text.
pub fn store(&mut self, zone_id: u32, key: CacheKey, text: String) {
let cache = self.zone_cache(zone_id);
cache.store(key, text);
}
/// Persist a zone's cache to disk as MessagePack.
///
/// Not atomic (no rename-into-place) — a crash mid-write can produce a
/// partial file. This is acceptable: worst case is a cache miss on next
/// load, triggering re-inference or base text fallback.
pub fn save_zone(&self, zone_id: u32) -> io::Result<()> {
let Some(cache) = self.zones.get(&zone_id) else {
return Ok(());
};
let dir = self.zone_dir();
fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.msgpack", zone_id));
let data = rmp_serde::to_vec(cache)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(path, data)
}
/// Save all loaded zone caches to disk.
pub fn save_all(&self) -> io::Result<()> {
for &zone_id in self.zones.keys() {
self.save_zone(zone_id)?;
}
Ok(())
}
/// Load a zone cache from disk. Returns `None` if file doesn't exist
/// or version mismatch (cache invalidation).
fn load_zone(&self, zone_id: u32) -> Option<ZoneVoiceCache> {
let path = self.zone_dir().join(format!("{}.msgpack", zone_id));
let data = fs::read(&path).ok()?;
let cache: ZoneVoiceCache = rmp_serde::from_slice(&data).ok()?;
// Version check — invalidate on mismatch
if cache.model_version != self.model_version
|| cache.injector_version != self.injector_version
{
tracing::info!(
zone_id,
"voice cache version mismatch — invalidating"
);
return None;
}
tracing::debug!(zone_id, entries = cache.entries.len(), "loaded voice cache");
Some(cache)
}
fn zone_dir(&self) -> PathBuf {
self.base_dir.join(format!("{}", self.world_seed))
}
}
impl Drop for VoiceCacheStore {
fn drop(&mut self) {
if let Err(e) = self.save_all() {
tracing::warn!(error = %e, "failed to save voice cache on shutdown");
}
}
}
/// Determine which tell states should be cached for a given base text.
///
/// Length-gated variant count (Spike 1 finding):
/// - Short (≤7 words): neutral only — 2B model can't differentiate
/// - Medium (815 words): neutral + Angry + Guarded (3 variants)
/// - Long (16+ words): all 5 tells + neutral (6 variants)
pub fn cacheable_tells(base_text: &str) -> Vec<Option<TellCategory>> {
let words = base_text.split_whitespace().count();
if words <= 7 {
vec![None] // neutral only
} else if words <= 15 {
vec![None, Some(TellCategory::Angry), Some(TellCategory::Guarded)]
} else {
vec![
None,
Some(TellCategory::Nervous),
Some(TellCategory::Angry),
Some(TellCategory::Friendly),
Some(TellCategory::Guarded),
Some(TellCategory::RoutineDeviation),
]
}
}
// ---------------------------------------------------------------------------
// Make ContentType serializable for cache keys
// ---------------------------------------------------------------------------
impl Serialize for ContentType {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
ContentType::Dialogue => serializer.serialize_u8(0),
ContentType::Behavior => serializer.serialize_u8(1),
}
}
}
impl<'de> Deserialize<'de> for ContentType {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let v = u8::deserialize(deserializer)?;
match v {
0 => Ok(ContentType::Dialogue),
1 => Ok(ContentType::Behavior),
_ => Err(serde::de::Error::custom("invalid ContentType")),
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zone_cache_store_and_lookup() {
let mut cache = ZoneVoiceCache::new("v1".into(), "i1".into());
let key = CacheKey {
culture_id: "krenn".into(),
npc_stable_id: 42,
content_type: ContentType::Dialogue,
content_index: 0,
tell_state: None,
};
cache.store(key.clone(), "Look, that's not mine to say.".into());
assert_eq!(cache.lookup(&key), Some("Look, that's not mine to say."));
}
#[test]
fn zone_cache_miss_returns_none() {
let cache = ZoneVoiceCache::new("v1".into(), "i1".into());
let key = CacheKey {
culture_id: "krenn".into(),
npc_stable_id: 42,
content_type: ContentType::Dialogue,
content_index: 0,
tell_state: None,
};
assert_eq!(cache.lookup(&key), None);
}
#[test]
fn zone_cache_round_trips_through_msgpack() {
let mut cache = ZoneVoiceCache::new("v1".into(), "i1".into());
let key = CacheKey {
culture_id: "krenn".into(),
npc_stable_id: 42,
content_type: ContentType::Behavior,
content_index: 3,
tell_state: Some(TellCategory::Nervous),
};
cache.store(key.clone(), "Hands are steady. Eyes aren't.".into());
let data = rmp_serde::to_vec(&cache).unwrap();
let restored: ZoneVoiceCache = rmp_serde::from_slice(&data).unwrap();
assert_eq!(restored.lookup(&key), Some("Hands are steady. Eyes aren't."));
assert_eq!(restored.model_version, "v1");
}
#[test]
fn cache_store_persists_and_loads() {
let dir = std::env::temp_dir().join("sr-voice-cache-test");
let _ = fs::remove_dir_all(&dir);
let mut store = VoiceCacheStore::new(dir.clone(), 12345, "v1".into(), "i1".into());
let key = CacheKey {
culture_id: "krenn".into(),
npc_stable_id: 1,
content_type: ContentType::Dialogue,
content_index: 0,
tell_state: None,
};
store.store(100, key.clone(), "Hey.".into());
store.save_zone(100).unwrap();
// New store instance — loads from disk
let mut store2 = VoiceCacheStore::new(dir.clone(), 12345, "v1".into(), "i1".into());
assert_eq!(store2.lookup(100, &key), Some("Hey.".into()));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn cache_invalidation_on_version_mismatch() {
let dir = std::env::temp_dir().join("sr-voice-cache-invalidation-test");
let _ = fs::remove_dir_all(&dir);
let mut store = VoiceCacheStore::new(dir.clone(), 42, "v1".into(), "i1".into());
let key = CacheKey {
culture_id: "krenn".into(),
npc_stable_id: 1,
content_type: ContentType::Dialogue,
content_index: 0,
tell_state: None,
};
store.store(1, key.clone(), "Old text.".into());
store.save_zone(1).unwrap();
// Different model version — should invalidate
let mut store2 = VoiceCacheStore::new(dir.clone(), 42, "v2".into(), "i1".into());
assert_eq!(store2.lookup(1, &key), None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn cacheable_tells_short() {
let tells = cacheable_tells("Hello there.");
assert_eq!(tells.len(), 1);
assert_eq!(tells[0], None);
}
#[test]
fn cacheable_tells_medium() {
let tells = cacheable_tells("The overnight delivery came in clean and it was logged");
assert_eq!(tells.len(), 3);
}
#[test]
fn cacheable_tells_long() {
let tells = cacheable_tells(
"I heard the night crew had to stop the line twice because the coupling was faulty and nobody had flagged it"
);
assert_eq!(tells.len(), 6);
}
}