Files
settled-reach/server/src/voice/cache.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
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>
2026-03-17 10:33:15 +01:00

356 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(io::Error::other)?;
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),
ContentType::Factual => serializer.serialize_u8(2),
}
}
}
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),
2 => Ok(ContentType::Factual),
_ => 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: "van-maanens-star".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: "van-maanens-star".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: "van-maanens-star".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: "van-maanens-star".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: "van-maanens-star".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);
}
}