1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
//! Semantic cache for prompt-response deduplication using cosine similarity.
//!
//! Prompts are embedded as bag-of-words TF vectors in a 256-dimensional space
//! (word → hash % 256). Cache hits are returned when cosine similarity is at or
//! above the configured threshold.
use std::time::Instant;
// ── Embedding ─────────────────────────────────────────────────────────────────
/// A dense 256-dimensional embedding vector.
#[derive(Debug, Clone)]
pub struct Embedding {
/// Fixed-length 256-element vector.
pub dims: Vec<f64>,
}
impl Embedding {
/// Cosine similarity between two embeddings.
/// Returns `0.0` if either vector is zero-length.
pub fn cosine_similarity(a: &Embedding, b: &Embedding) -> f64 {
let dot: f64 = a.dims.iter().zip(b.dims.iter()).map(|(x, y)| x * y).sum();
let norm_a: f64 = a.dims.iter().map(|x| x * x).sum::<f64>().sqrt();
let norm_b: f64 = b.dims.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm_a < 1e-12 || norm_b < 1e-12 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
}
/// Build a bag-of-words TF embedding: count word frequencies, normalize by L2 norm.
/// Dimension = hash(word) % 256.
pub fn from_text(text: &str) -> Embedding {
let mut dims = vec![0.0f64; 256];
for word in text.split_whitespace() {
let w = word.to_lowercase();
let w = w.trim_matches(|c: char| !c.is_alphabetic());
if w.is_empty() {
continue;
}
let idx = simple_hash(w) % 256;
dims[idx] += 1.0;
}
// L2 normalize.
let norm: f64 = dims.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 1e-12 {
for d in &mut dims {
*d /= norm;
}
}
Embedding { dims }
}
}
/// FNV-1a-inspired hash for a string slice, returning a usize.
fn simple_hash(s: &str) -> usize {
let mut h: usize = 0xcbf29ce484222325usize;
for b in s.bytes() {
h ^= b as usize;
h = h.wrapping_mul(0x100000001b3);
}
h
}
// ── CacheEntry ────────────────────────────────────────────────────────────────
/// A single entry in the semantic cache.
pub struct CacheEntry {
/// Original prompt text.
pub prompt: String,
/// Cached response.
pub response: String,
/// Embedding of the prompt.
pub embedding: Embedding,
/// Wall-clock time when this entry was inserted.
pub created_at: Instant,
/// Number of times this entry has been returned as a hit.
pub hits: u32,
}
// ── CacheStats ────────────────────────────────────────────────────────────────
/// Aggregate statistics for a [`SemanticCache`].
#[derive(Debug, Clone)]
pub struct CacheStats {
/// Current number of entries.
pub size: usize,
/// Total number of `lookup` calls.
pub total_lookups: u64,
/// Number of cache hits.
pub hits: u64,
/// `hits / total_lookups`, or `0.0` if no lookups yet.
pub hit_rate: f64,
/// Mean cosine similarity on hit calls (0 if no hits).
pub avg_similarity_on_hit: f64,
}
// ── SemanticCache ─────────────────────────────────────────────────────────────
/// Semantic prompt-response cache backed by cosine-similarity search.
pub struct SemanticCache {
/// Stored entries, newest last.
pub entries: Vec<CacheEntry>,
/// Minimum cosine similarity required for a cache hit.
pub threshold: f64,
/// Maximum number of entries before LRU eviction.
pub max_size: usize,
// Internal stats.
total_lookups: u64,
total_hits: u64,
similarity_sum_on_hit: f64,
}
impl SemanticCache {
/// Create a new cache with the given threshold and capacity.
pub fn new(threshold: f64, max_size: usize) -> Self {
SemanticCache {
entries: Vec::new(),
threshold,
max_size,
total_lookups: 0,
total_hits: 0,
similarity_sum_on_hit: 0.0,
}
}
/// Look up a prompt. Returns a reference to the cached response if a match
/// with cosine similarity ≥ threshold exists; otherwise `None`.
pub fn lookup(&mut self, prompt: &str) -> Option<&str> {
self.total_lookups += 1;
let query = Embedding::from_text(prompt);
// Find best match.
let mut best_idx: Option<usize> = None;
let mut best_sim = -1.0f64;
for (i, entry) in self.entries.iter().enumerate() {
let sim = Embedding::cosine_similarity(&query, &entry.embedding);
if sim >= self.threshold && sim > best_sim {
best_sim = sim;
best_idx = Some(i);
}
}
if let Some(idx) = best_idx {
self.entries[idx].hits += 1;
self.total_hits += 1;
self.similarity_sum_on_hit += best_sim;
// Return reference — Rust requires we re-borrow after the mutable ops.
return Some(self.entries[idx].response.as_str());
}
None
}
/// Insert a new prompt-response pair. Evicts if at capacity.
pub fn insert(&mut self, prompt: &str, response: &str) {
if self.entries.len() >= self.max_size {
self.evict_lru();
}
let embedding = Embedding::from_text(prompt);
self.entries.push(CacheEntry {
prompt: prompt.to_string(),
response: response.to_string(),
embedding,
created_at: Instant::now(),
hits: 0,
});
}
/// Remove the entry with the lowest composite score: hits + age_weight.
/// Age weight = max_age_secs / age_secs so newer entries are preferred.
pub fn evict_lru(&mut self) {
if self.entries.is_empty() {
return;
}
let now = Instant::now();
// Score = hits. Tie-break: older = lower score.
// We want to evict lowest hits, and among equal-hits the oldest.
let evict_idx = self
.entries
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
let sa = a.hits as f64;
let sb = b.hits as f64;
// Lower hits first; if equal, older (larger elapsed) first.
sa.partial_cmp(&sb)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
let age_a = now.duration_since(a.created_at).as_secs_f64();
let age_b = now.duration_since(b.created_at).as_secs_f64();
// larger age = evict first (reverse order)
age_b.partial_cmp(&age_a).unwrap_or(std::cmp::Ordering::Equal)
})
})
.map(|(i, _)| i)
.unwrap_or(0);
self.entries.remove(evict_idx);
}
/// Return aggregate cache statistics.
pub fn stats(&self) -> CacheStats {
let hit_rate = if self.total_lookups > 0 {
self.total_hits as f64 / self.total_lookups as f64
} else {
0.0
};
let avg_similarity_on_hit = if self.total_hits > 0 {
self.similarity_sum_on_hit / self.total_hits as f64
} else {
0.0
};
CacheStats {
size: self.entries.len(),
total_lookups: self.total_lookups,
hits: self.total_hits,
hit_rate,
avg_similarity_on_hit,
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match_hits_with_cosine_one() {
let mut cache = SemanticCache::new(0.95, 10);
cache.insert("What is Rust?", "A systems language.");
let result = cache.lookup("What is Rust?");
assert_eq!(result, Some("A systems language."));
}
#[test]
fn below_threshold_misses() {
let mut cache = SemanticCache::new(0.99, 10);
cache.insert("What is Rust?", "A systems language.");
// Completely unrelated prompt.
let result = cache.lookup("Tell me about quantum physics.");
assert_eq!(result, None);
}
#[test]
fn hit_counter_increments() {
let mut cache = SemanticCache::new(0.95, 10);
cache.insert("Hello world", "Hi there.");
cache.lookup("Hello world");
cache.lookup("Hello world");
assert_eq!(cache.entries[0].hits, 2);
}
#[test]
fn lru_eviction_removes_lowest_hits() {
let mut cache = SemanticCache::new(0.95, 2);
cache.insert("prompt one", "response one");
cache.insert("prompt two", "response two");
// Hit "prompt one" twice so it survives eviction.
cache.lookup("prompt one");
cache.lookup("prompt one");
// Inserting a third entry should evict "prompt two" (0 hits).
cache.insert("prompt three", "response three");
assert_eq!(cache.entries.len(), 2);
let prompts: Vec<&str> = cache.entries.iter().map(|e| e.prompt.as_str()).collect();
assert!(!prompts.contains(&"prompt two"), "prompt two should have been evicted");
}
#[test]
fn different_text_misses() {
let mut cache = SemanticCache::new(0.99, 10);
cache.insert("apple banana cherry", "fruit response");
let result = cache.lookup("dog cat fish");
assert_eq!(result, None);
}
#[test]
fn stats_track_correctly() {
let mut cache = SemanticCache::new(0.95, 10);
cache.insert("hello world", "hi");
cache.lookup("hello world"); // hit
cache.lookup("something unrelated and completely different xyz"); // miss
let stats = cache.stats();
assert_eq!(stats.total_lookups, 2);
assert_eq!(stats.hits, 1);
assert!((stats.hit_rate - 0.5).abs() < 1e-9);
}
}