use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, VecDeque};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::models::{SystemPrompt, Tool};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrefixFingerprint {
pub system_sha256: String,
pub tools_sha256: String,
pub combined_sha256: String,
}
impl PrefixFingerprint {
#[cfg(test)]
pub fn compute(system_text: &str, tools: Option<&[Tool]>) -> Self {
let mut cache = ToolCatalogCache::new();
Self::compute_with_tool_cache(system_text, tools, &mut cache)
}
pub fn compute_with_tool_cache(
system_text: &str,
tools: Option<&[Tool]>,
cache: &mut ToolCatalogCache,
) -> Self {
let system_sha256 = sha256_hex(system_text.as_bytes());
let tools_sha256 = match tools {
Some(tools) if !tools.is_empty() => {
cache.fingerprint_for(tools).sha256_hex
}
_ => sha256_hex(b""),
};
let combined = format!("{system_sha256}:{tools_sha256}");
let combined_sha256 = sha256_hex(combined.as_bytes());
Self {
system_sha256,
tools_sha256,
combined_sha256,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrefixChange {
pub old: PrefixFingerprint,
pub new: PrefixFingerprint,
pub system_changed: bool,
pub tools_changed: bool,
}
#[allow(dead_code)]
impl PrefixChange {
pub fn description(&self) -> String {
let mut parts = Vec::new();
if self.system_changed {
parts.push("system prompt");
}
if self.tools_changed {
parts.push("tool set");
}
if parts.is_empty() {
return "unknown (fingerprint mismatch but no component detected)".to_string();
}
format!("prefix cache invalidated: {} changed", parts.join(" and "))
}
pub fn label(&self) -> &'static str {
if self.system_changed && self.tools_changed {
"sys+tools"
} else if self.system_changed {
"sys"
} else if self.tools_changed {
"tools"
} else {
"prefix"
}
}
}
#[derive(Debug, Clone)]
pub struct PrefixStabilityManager {
pinned: Option<PrefixFingerprint>,
current: Option<PrefixFingerprint>,
last_change: Option<PrefixChange>,
change_count: u64,
check_count: u64,
tool_catalog_cache: ToolCatalogCache,
}
const TOOL_CATALOG_CACHE_CAPACITY: usize = 8;
#[derive(Debug, Default, Clone)]
pub struct ToolCatalogCache {
by_identity: HashMap<u64, CachedCatalog>,
insertion_order: VecDeque<u64>,
capacity: usize,
}
#[derive(Debug, Clone)]
pub struct CachedCatalog {
#[allow(dead_code)] pub joined: Arc<String>,
pub sha256_hex: String,
}
impl ToolCatalogCache {
#[must_use]
pub fn new() -> Self {
Self::with_capacity(TOOL_CATALOG_CACHE_CAPACITY)
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
let cap = capacity.max(1);
Self {
by_identity: HashMap::with_capacity(cap),
insertion_order: VecDeque::with_capacity(cap),
capacity: cap,
}
}
pub fn fingerprint_for(&mut self, tools: &[Tool]) -> CachedCatalog {
let identity = tool_set_identity(tools);
if let Some(cached) = self.by_identity.get(&identity) {
return cached.clone();
}
let mut serialized: Vec<String> = tools.iter().filter_map(tool_to_api_json).collect();
serialized.sort();
let joined = Arc::new(serialized.join("\n"));
let sha256_hex = sha256_hex(joined.as_bytes());
let entry = CachedCatalog {
joined: Arc::clone(&joined),
sha256_hex,
};
if self.by_identity.len() >= self.capacity
&& let Some(oldest) = self.insertion_order.pop_front()
{
self.by_identity.remove(&oldest);
}
self.by_identity.insert(identity, entry.clone());
self.insertion_order.push_back(identity);
entry
}
#[allow(dead_code)] pub fn invalidate(&mut self) {
self.by_identity.clear();
self.insertion_order.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.by_identity.len()
}
#[allow(dead_code)] #[must_use]
pub fn is_empty(&self) -> bool {
self.by_identity.is_empty()
}
#[allow(dead_code)] #[must_use]
pub fn stats(&self) -> (usize, usize) {
(self.len(), self.capacity)
}
}
fn tool_set_identity(tools: &[Tool]) -> u64 {
let mut hasher = DefaultHasher::new();
tools.len().hash(&mut hasher);
for tool in tools {
tool.name.hash(&mut hasher);
tool.description.hash(&mut hasher);
tool.strict.hash(&mut hasher);
hash_json_value(&tool.input_schema, &mut hasher);
}
hasher.finish()
}
fn hash_json_value<H: Hasher>(value: &serde_json::Value, state: &mut H) {
match value {
serde_json::Value::Null => 0u8.hash(state),
serde_json::Value::Bool(b) => {
1u8.hash(state);
b.hash(state);
}
serde_json::Value::Number(n) => {
2u8.hash(state);
if let Some(i) = n.as_i64() {
i.hash(state);
} else if let Some(u) = n.as_u64() {
u.hash(state);
} else if let Some(f) = n.as_f64() {
f.to_bits().hash(state);
}
}
serde_json::Value::String(s) => {
3u8.hash(state);
s.hash(state);
}
serde_json::Value::Array(arr) => {
4u8.hash(state);
arr.len().hash(state);
for v in arr {
hash_json_value(v, state);
}
}
serde_json::Value::Object(obj) => {
5u8.hash(state);
obj.len().hash(state);
let mut entries: Vec<(&String, &serde_json::Value)> = obj.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in entries {
k.hash(state);
hash_json_value(v, state);
}
}
}
}
#[allow(dead_code)]
impl PrefixStabilityManager {
pub fn new(system_text: &str, tools: Option<&[Tool]>) -> Self {
let mut cache = ToolCatalogCache::new();
let fp = PrefixFingerprint::compute_with_tool_cache(system_text, tools, &mut cache);
Self {
pinned: Some(fp.clone()),
current: Some(fp),
last_change: None,
change_count: 0,
check_count: 0,
tool_catalog_cache: cache,
}
}
pub fn new_unpinned() -> Self {
Self {
pinned: None,
current: None,
last_change: None,
change_count: 0,
check_count: 0,
tool_catalog_cache: ToolCatalogCache::new(),
}
}
pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool {
let fp = PrefixFingerprint::compute_with_tool_cache(
system_text,
tools,
&mut self.tool_catalog_cache,
);
let was_unpinned = self.pinned.is_none();
self.pinned = Some(fp.clone());
self.current = Some(fp);
was_unpinned
}
pub fn check_and_update(
&mut self,
system_text: &str,
tools: Option<&[Tool]>,
) -> Result<bool, Box<PrefixChange>> {
let fp = PrefixFingerprint::compute_with_tool_cache(
system_text,
tools,
&mut self.tool_catalog_cache,
);
let old_fp = self.current.replace(fp.clone());
self.check_count += 1;
let pinned = match &self.pinned {
Some(p) => p,
None => {
self.pinned = Some(fp);
self.last_change = None;
return Ok(true);
}
};
if fp.combined_sha256 == pinned.combined_sha256 {
Ok(true)
} else {
let old = old_fp.unwrap_or_else(|| pinned.clone());
let system_changed = fp.system_sha256 != pinned.system_sha256;
let tools_changed = fp.tools_sha256 != pinned.tools_sha256;
let change = PrefixChange {
old,
new: fp.clone(),
system_changed,
tools_changed,
};
self.last_change = Some(change.clone());
self.change_count += 1;
self.pinned = Some(fp);
Err(Box::new(change))
}
}
pub fn last_change(&self) -> Option<&PrefixChange> {
self.last_change.as_ref()
}
pub fn pinned_fingerprint(&self) -> Option<&PrefixFingerprint> {
self.pinned.as_ref()
}
pub fn current_fingerprint(&self) -> Option<&PrefixFingerprint> {
self.current.as_ref()
}
pub fn change_count(&self) -> u64 {
self.change_count
}
pub fn check_count(&self) -> u64 {
self.check_count
}
pub fn stability_ratio(&self) -> f64 {
if self.check_count == 0 {
1.0
} else {
let stable_checks = self.check_count - self.change_count;
stable_checks as f64 / self.check_count as f64
}
}
pub fn summary(&self) -> String {
let pct = self.stability_ratio() * 100.0;
let pinned_short = self
.pinned
.as_ref()
.map(|fp| {
if fp.combined_sha256.len() >= 12 {
&fp.combined_sha256[..12]
} else {
&fp.combined_sha256
}
})
.unwrap_or("none");
format!(
"Prefix stability: {pct:.1}% ({stable}/{total} checks stable) | fingerprint: {pinned_short} | changes: {changes}",
pct = pct,
stable = self.check_count.saturating_sub(self.change_count),
total = self.check_count,
pinned_short = pinned_short,
changes = self.change_count,
)
}
}
fn tool_to_api_json(tool: &Tool) -> Option<String> {
let mut value = serde_json::json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
}
});
if let Some(strict) = tool.strict
&& let Some(function) = value.get_mut("function")
{
function["strict"] = serde_json::json!(strict);
}
serde_json::to_string(&value).ok()
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
pub fn system_prompt_text(system: Option<&SystemPrompt>) -> String {
match system {
Some(SystemPrompt::Text(text)) => text.clone(),
Some(SystemPrompt::Blocks(blocks)) => {
let mut text = String::new();
for block in blocks {
text.push_str(&block.text);
text.push('\n');
}
text
}
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(name: &str) -> Tool {
Tool {
name: name.to_string(),
description: String::new(),
input_schema: serde_json::Value::Null,
tool_type: None,
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
}
}
#[test]
fn same_prefix_produces_same_fingerprint() {
let a = PrefixFingerprint::compute("hello world", None);
let b = PrefixFingerprint::compute("hello world", None);
assert_eq!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn different_system_produces_different_fingerprint() {
let a = PrefixFingerprint::compute("hello", None);
let b = PrefixFingerprint::compute("world", None);
assert_ne!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn tool_order_does_not_affect_fingerprint() {
let tools_a = vec![make_tool("read_file"), make_tool("write_file")];
let tools_b = vec![make_tool("write_file"), make_tool("read_file")];
let a = PrefixFingerprint::compute("system", Some(&tools_a));
let b = PrefixFingerprint::compute("system", Some(&tools_b));
assert_eq!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn different_tools_produce_different_fingerprint() {
let tools_a = vec![make_tool("read_file")];
let tools_b = vec![make_tool("write_file")];
let a = PrefixFingerprint::compute("system", Some(&tools_a));
let b = PrefixFingerprint::compute("system", Some(&tools_b));
assert_ne!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn manager_starts_stable() {
let mut mgr = PrefixStabilityManager::new("system prompt", None);
assert!(mgr.check_and_update("system prompt", None).unwrap());
assert_eq!(mgr.change_count(), 0);
assert_eq!(mgr.check_count(), 1);
}
#[test]
fn manager_detects_change() {
let mut mgr = PrefixStabilityManager::new("system prompt", None);
let result = mgr.check_and_update("different prompt", None);
assert!(result.is_err());
assert_eq!(mgr.change_count(), 1);
let change = mgr.last_change().unwrap();
assert!(change.system_changed);
assert!(!change.tools_changed);
}
#[test]
fn manager_detects_tool_change() {
let tools_a = vec![make_tool("read_file")];
let tools_b = vec![make_tool("write_file")];
let mut mgr = PrefixStabilityManager::new("system", Some(&tools_a));
let result = mgr.check_and_update("system", Some(&tools_b));
assert!(result.is_err());
let change = mgr.last_change().unwrap();
assert!(!change.system_changed);
assert!(change.tools_changed);
}
#[test]
fn manager_re_pins_after_change() {
let mut mgr = PrefixStabilityManager::new("old", None);
let _ = mgr.check_and_update("new", None);
assert!(mgr.check_and_update("new", None).unwrap());
assert_eq!(mgr.change_count(), 1);
}
#[test]
fn stability_ratio_is_one_for_no_changes() {
let mut mgr = PrefixStabilityManager::new("hello", None);
mgr.check_and_update("hello", None).unwrap();
mgr.check_and_update("hello", None).unwrap();
assert!((mgr.stability_ratio() - 1.0).abs() < f64::EPSILON);
assert_eq!(mgr.check_count(), 2);
assert_eq!(mgr.change_count(), 0);
}
#[test]
fn stability_ratio_reflects_change_rate() {
let mut mgr = PrefixStabilityManager::new("hello", None);
mgr.check_and_update("hello", None).unwrap(); let _ = mgr.check_and_update("world", None); mgr.check_and_update("world", None).unwrap(); assert!((mgr.stability_ratio() - 2.0 / 3.0).abs() < 0.01);
assert_eq!(mgr.check_count(), 3);
assert_eq!(mgr.change_count(), 1);
}
#[test]
fn empty_tools_and_none_tools_produce_same_hash() {
let empty = PrefixFingerprint::compute("system", Some(&[]));
let none = PrefixFingerprint::compute("system", None);
assert_eq!(empty.tools_sha256, none.tools_sha256);
}
#[test]
fn empty_system_produces_sha256_of_empty_string() {
let fp = PrefixFingerprint::compute("", None);
let expected = sha256_hex(b"");
assert_eq!(fp.system_sha256, expected);
}
#[test]
fn prefix_change_description_is_informative() {
let old = PrefixFingerprint::compute("old", None);
let new = PrefixFingerprint::compute("new", None);
let change = PrefixChange {
old,
new,
system_changed: true,
tools_changed: false,
};
assert_eq!(
change.description(),
"prefix cache invalidated: system prompt changed"
);
assert_eq!(change.label(), "sys");
}
#[test]
fn new_unpinned_has_no_change_history() {
let mut mgr = PrefixStabilityManager::new_unpinned();
assert!(mgr.pinned_fingerprint().is_none());
assert!(mgr.current_fingerprint().is_none());
assert!(mgr.last_change().is_none());
assert_eq!(mgr.change_count(), 0);
assert_eq!(mgr.check_count(), 0);
assert!(mgr.check_and_update("hello", None).unwrap());
assert!(mgr.pinned_fingerprint().is_some());
assert_eq!(mgr.check_count(), 1);
}
#[test]
fn fingerprint_detects_schema_change_not_just_name_change() {
let tool_a = make_tool("my_tool");
let mut tool_a_v2 = make_tool("my_tool");
tool_a_v2.description = "updated description".to_string();
let a = PrefixFingerprint::compute("system", Some(&[tool_a]));
let b = PrefixFingerprint::compute("system", Some(&[tool_a_v2]));
assert_ne!(a.tools_sha256, b.tools_sha256);
assert_ne!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn system_prompt_text_returns_empty_for_none() {
assert_eq!(system_prompt_text(None), "");
}
#[test]
fn tool_catalog_cache_miss_then_hit_returns_same_arc() {
let mut cache = ToolCatalogCache::new();
let tools = vec![make_tool("read_file"), make_tool("write_file")];
let first = cache.fingerprint_for(&tools);
assert_eq!(cache.len(), 1);
let second = cache.fingerprint_for(&tools);
assert_eq!(cache.len(), 1, "second call should be a cache hit");
assert!(Arc::ptr_eq(&first.joined, &second.joined));
assert_eq!(first.sha256_hex, second.sha256_hex);
}
#[test]
fn tool_catalog_cache_different_tool_sets_dont_collide() {
let mut cache = ToolCatalogCache::new();
let a = vec![make_tool("read_file")];
let b = vec![make_tool("write_file")];
let entry_a = cache.fingerprint_for(&a);
let entry_b = cache.fingerprint_for(&b);
assert_eq!(cache.len(), 2);
assert_ne!(entry_a.sha256_hex, entry_b.sha256_hex);
assert!(!Arc::ptr_eq(&entry_a.joined, &entry_b.joined));
}
#[test]
fn tool_catalog_cache_pinned_by_input_order() {
let mut cache = ToolCatalogCache::new();
let a = vec![make_tool("read_file"), make_tool("write_file")];
let b = vec![make_tool("write_file"), make_tool("read_file")];
let entry_a = cache.fingerprint_for(&a);
let entry_b = cache.fingerprint_for(&b);
assert_eq!(entry_a.joined.as_str(), entry_b.joined.as_str());
assert_eq!(cache.len(), 2);
}
#[test]
fn tool_catalog_cache_detects_schema_change() {
let mut cache = ToolCatalogCache::new();
let tool_v1 = make_tool("t");
let mut tool_v2 = make_tool("t");
tool_v2.description = "updated".to_string();
let entry_v1 = cache.fingerprint_for(&[tool_v1]);
let entry_v2 = cache.fingerprint_for(&[tool_v2]);
assert_ne!(entry_v1.sha256_hex, entry_v2.sha256_hex);
assert_eq!(cache.len(), 2);
}
#[test]
fn tool_catalog_cache_respects_capacity() {
let mut cache = ToolCatalogCache::with_capacity(2);
cache.fingerprint_for(&[make_tool("a")]);
cache.fingerprint_for(&[make_tool("b")]);
cache.fingerprint_for(&[make_tool("c")]);
assert_eq!(cache.len(), 2);
let re_entry = cache.fingerprint_for(&[make_tool("a")]);
assert_eq!(cache.len(), 2);
let fresh = cache.fingerprint_for(&[make_tool("a")]);
assert!(Arc::ptr_eq(&re_entry.joined, &fresh.joined));
}
#[test]
fn tool_catalog_cache_invalidate_clears_all() {
let mut cache = ToolCatalogCache::new();
cache.fingerprint_for(&[make_tool("a")]);
cache.fingerprint_for(&[make_tool("b")]);
cache.invalidate();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[test]
fn tool_catalog_cache_empty_slice_uses_zero_capacity_path() {
let mut cache = ToolCatalogCache::new();
let entry = cache.fingerprint_for(&[]);
assert!(!entry.sha256_hex.is_empty());
let again = cache.fingerprint_for(&[]);
assert!(Arc::ptr_eq(&entry.joined, &again.joined));
}
#[test]
fn compute_with_tool_cache_matches_compute_uncached() {
let mut cache = ToolCatalogCache::new();
let tools = vec![make_tool("alpha"), make_tool("beta")];
let cached = PrefixFingerprint::compute_with_tool_cache("sys", Some(&tools), &mut cache);
let uncached = PrefixFingerprint::compute("sys", Some(&tools));
assert_eq!(cached.combined_sha256, uncached.combined_sha256);
assert_eq!(cached.tools_sha256, uncached.tools_sha256);
}
#[test]
fn manager_check_and_update_uses_cached_tool_fingerprint() {
let tools = vec![make_tool("t1")];
let mut mgr = PrefixStabilityManager::new("sys", Some(&tools));
assert!(mgr.check_and_update("sys", Some(&tools)).is_ok());
assert!(mgr.check_and_update("sys", Some(&tools)).is_ok());
assert_eq!(mgr.change_count(), 0);
}
}