gemini_memory_rs/retrieval/
plan.rs1use chrono::{DateTime, Utc};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::core::{CanonicalPredicate, MemoryKind, PlanId, TurnId, stable_hash};
13
14pub mod limits {
20 pub const ENTITIES: usize = 5;
22 pub const TOPICS: usize = 8;
24 pub const PREDICATES: usize = 5;
26 pub const LEXICAL_QUERIES: usize = 3;
28 pub const QUERY_TERMS: usize = 16;
30 pub const SCOPES: usize = 5;
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum RetrievalIntent {
38 #[default]
40 None,
41 ExplicitRecall,
43 PersonalRecommendation,
45 PriorEventReference,
47 RelationshipReference,
49 Comparison,
51 Ambient,
53}
54
55impl RetrievalIntent {
56 pub fn requires_memory(self) -> bool {
58 !matches!(self, Self::None)
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
64pub struct RetrievalEntity {
65 pub surface: String,
67 #[serde(default)]
69 pub canonical: Option<String>,
70}
71
72impl RetrievalEntity {
73 pub fn surface(surface: impl Into<String>) -> Self {
75 Self {
76 surface: surface.into(),
77 canonical: None,
78 }
79 }
80
81 pub fn resolved(surface: impl Into<String>, canonical: impl Into<String>) -> Self {
83 Self {
84 surface: surface.into(),
85 canonical: Some(canonical.into()),
86 }
87 }
88
89 pub fn forms(&self) -> Vec<String> {
91 let mut forms = vec![self.surface.clone()];
92 if let Some(canonical) = &self.canonical {
93 forms.push(canonical.clone());
94 }
95 forms
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
101pub struct TemporalConstraint {
102 #[serde(default)]
104 pub after: Option<DateTime<Utc>>,
105 #[serde(default)]
107 pub before: Option<DateTime<Utc>>,
108 #[serde(default)]
110 pub label: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
115pub struct RetrievalPlan {
116 pub plan_id: PlanId,
118 pub turn_id: TurnId,
120 pub generation: u64,
122 pub requires_memory: bool,
124 pub confidence: f32,
126 pub intent: RetrievalIntent,
128 pub entities: Vec<RetrievalEntity>,
130 pub topics: Vec<String>,
132 pub predicates: Vec<CanonicalPredicate>,
134 pub lexical_queries: Vec<String>,
136 pub scopes: Vec<MemoryKind>,
139 #[serde(default)]
141 pub kind_filter: Vec<MemoryKind>,
142 #[serde(default)]
156 pub subject_hint: Option<String>,
157 #[serde(default)]
164 pub predicate_hint: Option<String>,
165 #[serde(default)]
167 pub temporal: Option<TemporalConstraint>,
168 pub source_transcript_hash: String,
170}
171
172impl RetrievalPlan {
173 pub fn skip(turn_id: TurnId, generation: u64, transcript: &str) -> Self {
175 Self {
176 plan_id: PlanId::generate(),
177 turn_id,
178 generation,
179 requires_memory: false,
180 confidence: 1.0,
181 intent: RetrievalIntent::None,
182 entities: Vec::new(),
183 topics: Vec::new(),
184 predicates: Vec::new(),
185 lexical_queries: Vec::new(),
186 scopes: Vec::new(),
187 kind_filter: Vec::new(),
188 subject_hint: None,
189 predicate_hint: None,
190 temporal: None,
191 source_transcript_hash: stable_hash(transcript),
192 }
193 }
194
195 pub fn normalized(mut self) -> Self {
200 self.entities.retain(|e| !e.surface.trim().is_empty());
201 self.entities.truncate(limits::ENTITIES);
202
203 self.topics = dedup_non_empty(self.topics);
204 self.topics.truncate(limits::TOPICS);
205
206 self.predicates.dedup();
207 self.predicates.truncate(limits::PREDICATES);
208
209 self.lexical_queries = dedup_non_empty(self.lexical_queries)
210 .into_iter()
211 .map(|q| {
212 q.split_whitespace()
213 .take(limits::QUERY_TERMS)
214 .collect::<Vec<_>>()
215 .join(" ")
216 })
217 .collect();
218 self.lexical_queries.truncate(limits::LEXICAL_QUERIES);
219
220 self.scopes.dedup();
221 self.scopes.truncate(limits::SCOPES);
222
223 self.confidence = self.confidence.clamp(0.0, 1.0);
224
225 if self.lexical_queries.is_empty() && self.entities.is_empty() {
228 self.requires_memory = false;
229 self.intent = RetrievalIntent::None;
230 }
231 self
232 }
233
234 pub fn cache_key(&self) -> String {
236 let mut parts = self.lexical_queries.clone();
237 parts.extend(self.entities.iter().flat_map(RetrievalEntity::forms));
238 parts.extend(self.scopes.iter().map(|s| s.scope_label().to_string()));
239 parts.sort();
240 stable_hash(&parts.join("|"))
241 }
242
243 pub fn entity_forms(&self) -> Vec<String> {
245 self.entities
246 .iter()
247 .flat_map(RetrievalEntity::forms)
248 .collect()
249 }
250}
251
252fn dedup_non_empty(values: Vec<String>) -> Vec<String> {
253 let mut seen = std::collections::HashSet::new();
254 values
255 .into_iter()
256 .map(|v| v.trim().to_string())
257 .filter(|v| !v.is_empty())
258 .filter(|v| seen.insert(v.to_lowercase()))
259 .collect()
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn plan_with(lexical: Vec<&str>) -> RetrievalPlan {
267 RetrievalPlan {
268 plan_id: PlanId::new("pln_1"),
269 turn_id: TurnId(1),
270 generation: 1,
271 requires_memory: true,
272 confidence: 1.2,
273 intent: RetrievalIntent::PersonalRecommendation,
274 entities: Vec::new(),
275 topics: Vec::new(),
276 predicates: Vec::new(),
277 lexical_queries: lexical.into_iter().map(str::to_string).collect(),
278 scopes: Vec::new(),
279 kind_filter: Vec::new(),
280 subject_hint: None,
281 predicate_hint: None,
282 temporal: None,
283 source_transcript_hash: "hash".into(),
284 }
285 }
286
287 #[test]
288 fn normalization_applies_every_cap() {
289 let mut plan = plan_with(vec!["a", "b", "c", "d", "e"]);
290 plan.entities = (0..9)
291 .map(|i| RetrievalEntity::surface(format!("e{i}")))
292 .collect();
293 plan.topics = (0..20).map(|i| format!("t{i}")).collect();
294 plan.scopes = vec![MemoryKind::Preference; 9];
295
296 let normalized = plan.normalized();
297 assert_eq!(normalized.entities.len(), limits::ENTITIES);
298 assert_eq!(normalized.topics.len(), limits::TOPICS);
299 assert_eq!(normalized.lexical_queries.len(), limits::LEXICAL_QUERIES);
300 assert_eq!(normalized.scopes.len(), 1, "duplicate scopes collapse");
301 assert!(normalized.confidence <= 1.0);
302 }
303
304 #[test]
305 fn overlong_queries_are_trimmed_to_their_leading_terms() {
306 let long = (0..40)
307 .map(|i| format!("w{i}"))
308 .collect::<Vec<_>>()
309 .join(" ");
310 let normalized = plan_with(vec![&long]).normalized();
311 assert_eq!(
312 normalized.lexical_queries[0].split_whitespace().count(),
313 limits::QUERY_TERMS
314 );
315 }
316
317 #[test]
318 fn blank_and_duplicate_terms_are_dropped() {
319 let mut plan = plan_with(vec!["quiet restaurant", " ", "Quiet Restaurant"]);
320 plan.topics = vec!["food".into(), "".into(), "FOOD".into()];
321 let normalized = plan.normalized();
322 assert_eq!(normalized.lexical_queries.len(), 1);
323 assert_eq!(normalized.topics, vec!["food"]);
324 }
325
326 #[test]
327 fn a_plan_with_nothing_to_search_for_cannot_require_memory() {
328 let normalized = plan_with(vec!["", " "]).normalized();
329 assert!(!normalized.requires_memory);
330 assert_eq!(normalized.intent, RetrievalIntent::None);
331 }
332
333 #[test]
334 fn a_skip_plan_requires_nothing() {
335 let plan = RetrievalPlan::skip(TurnId(3), 7, "what is the capital of France");
336 assert!(!plan.requires_memory);
337 assert!(!plan.intent.requires_memory());
338 }
339
340 #[test]
341 fn the_cache_key_ignores_query_ordering_but_not_content() {
342 let a = plan_with(vec!["quiet restaurant", "wife preference"]).normalized();
343 let b = plan_with(vec!["wife preference", "quiet restaurant"]).normalized();
344 assert_eq!(a.cache_key(), b.cache_key());
345
346 let c = plan_with(vec!["loud restaurant"]).normalized();
347 assert_ne!(a.cache_key(), c.cache_key());
348 }
349}