1use std::collections::{BTreeMap, HashMap};
71use std::sync::{Arc, RwLock};
72use std::time::{SystemTime, UNIX_EPOCH};
73
74use serde::{Deserialize, Serialize};
75
76use crate::query::recall::ScoredMemory;
77
78pub const DEFAULT_TOKEN_BUDGET: u32 = 512;
80
81pub const GLOBAL_NAMESPACE: &str = "__global__";
83
84#[inline]
86fn estimate_tokens(s: &str) -> u32 {
87 (s.len().div_ceil(4)).max(1) as u32
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct OrientationCacheConfig {
94 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub namespace: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub token_budget: Option<u32>,
105 #[serde(default = "default_true")]
110 pub include_in_response: bool,
111 #[serde(default = "default_true")]
115 pub distill: bool,
116}
117
118fn default_true() -> bool {
119 true
120}
121
122impl OrientationCacheConfig {
123 pub fn new() -> Self {
124 Self {
125 namespace: None,
126 token_budget: None,
127 include_in_response: true,
128 distill: true,
129 }
130 }
131 pub fn with_namespace<S: Into<String>>(mut self, ns: S) -> Self {
132 self.namespace = Some(ns.into());
133 self
134 }
135 pub fn with_token_budget(mut self, b: u32) -> Self {
136 self.token_budget = Some(b);
137 self
138 }
139 pub fn read_only(mut self) -> Self {
140 self.distill = false;
141 self
142 }
143}
144
145impl Default for OrientationCacheConfig {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151#[derive(Debug, Clone)]
153pub struct Entry {
154 pub value: String,
155 pub freq: u32,
156 pub last_seen_unix: u64,
157 pub token_estimate: u32,
158}
159
160#[derive(Debug, Default, Clone)]
162pub struct ContextMap {
163 pub entities: BTreeMap<String, Entry>,
164 pub constants: BTreeMap<String, Entry>,
165 pub schemas: BTreeMap<String, Entry>,
166 pub hit_count: u64,
167}
168
169#[derive(Debug, Default)]
171pub struct OrientationCacheStore {
172 inner: RwLock<HashMap<String, ContextMap>>,
173}
174
175impl OrientationCacheStore {
176 pub fn new() -> Arc<Self> {
177 Arc::new(Self::default())
178 }
179
180 pub fn snapshot(&self, namespace: &str) -> ContextMap {
182 self.inner
183 .read()
184 .ok()
185 .and_then(|guard| guard.get(namespace).cloned())
186 .unwrap_or_default()
187 }
188
189 pub fn namespace_count(&self) -> usize {
191 self.inner.read().map(|g| g.len()).unwrap_or(0)
192 }
193
194 fn with_namespace_mut<F, R>(&self, namespace: &str, f: F) -> R
195 where
196 F: FnOnce(&mut ContextMap) -> R,
197 R: Default,
198 {
199 match self.inner.write() {
200 Ok(mut guard) => {
201 let map = guard.entry(namespace.to_string()).or_default();
202 f(map)
203 }
204 Err(_) => R::default(),
205 }
206 }
207}
208
209#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212pub struct RenderedContextMap {
213 pub namespace: String,
214 pub entities: Vec<RenderedEntry>,
215 pub constants: Vec<RenderedEntry>,
216 pub schemas: Vec<RenderedEntry>,
217 pub token_estimate: u32,
218 pub budget: u32,
219 pub hit_count: u64,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct RenderedEntry {
225 pub key: String,
226 pub value: String,
227 pub freq: u32,
228 pub token_estimate: u32,
229}
230
231pub fn resolve_namespace(
233 cfg: &OrientationCacheConfig,
234 agent_id: &str,
235 org_id: Option<&str>,
236) -> String {
237 if let Some(ref ns) = cfg.namespace {
238 return ns.clone();
239 }
240 match (org_id, agent_id.is_empty()) {
241 (Some(o), false) if !o.is_empty() => format!("{o}:{agent_id}"),
242 (Some(o), true) if !o.is_empty() => o.to_string(),
243 (_, false) => agent_id.to_string(),
244 _ => GLOBAL_NAMESPACE.to_string(),
245 }
246}
247
248#[derive(Debug, Default)]
250pub struct DistillerOutput {
251 pub entities: Vec<(String, String)>,
252 pub constants: Vec<(String, String)>,
253 pub schemas: Vec<(String, String)>,
254}
255
256pub fn distill(content: &str) -> DistillerOutput {
266 DistillerOutput {
267 entities: extract_entities(content),
268 constants: extract_constants(content),
269 schemas: extract_schemas(content),
270 }
271}
272
273fn extract_entities(content: &str) -> Vec<(String, String)> {
274 let mut out: Vec<(String, String)> = Vec::new();
275 let mut current: Vec<&str> = Vec::new();
276 let push = |cur: &mut Vec<&str>, out: &mut Vec<(String, String)>| {
277 if !cur.is_empty() {
278 let phrase = cur.join(" ");
279 if phrase.len() >= 3 {
280 out.push((phrase.clone(), phrase));
281 }
282 cur.clear();
283 }
284 };
285 for raw_tok in content.split(|c: char| c.is_whitespace() || matches!(c, ',' | '.' | ';')) {
286 let tok = raw_tok.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');
287 if is_entity_token(tok) {
288 current.push(tok);
289 } else {
290 push(&mut current, &mut out);
291 }
292 }
293 push(&mut current, &mut out);
294 let mut seen = std::collections::BTreeSet::new();
296 out.retain(|(k, _)| seen.insert(k.clone()));
297 out
298}
299
300fn is_entity_token(tok: &str) -> bool {
301 if tok.len() < 2 {
302 return false;
303 }
304 let mut chars = tok.chars();
305 let first = chars.next().unwrap();
306 if !first.is_ascii_uppercase() {
307 return false;
308 }
309 chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
311}
312
313fn extract_constants(content: &str) -> Vec<(String, String)> {
314 let mut out: Vec<(String, String)> = Vec::new();
315 let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
316 let tokens: Vec<&str> = content
322 .split(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | '(' | ')' | '\'' | '"' | '`'))
323 .filter(|t| !t.is_empty())
324 .collect();
325 let mut i = 0;
326 while i < tokens.len() {
327 let tok = tokens[i];
328 if is_const_key(tok) {
330 if i + 2 < tokens.len() && is_separator(tokens[i + 1]) && !is_separator(tokens[i + 2]) {
331 push_const(&mut out, &mut seen, tok, tokens[i + 2]);
332 i += 3;
333 continue;
334 }
335 if i + 1 < tokens.len() {
336 let next = tokens[i + 1];
337 if let Some(val) = next.strip_prefix('=').or_else(|| next.strip_prefix(':'))
338 && !val.is_empty()
339 {
340 push_const(&mut out, &mut seen, tok, val);
341 i += 2;
342 continue;
343 }
344 }
345 } else if let Some((key, val)) = tok.split_once('=').or_else(|| tok.split_once(':')) {
346 if is_const_key(key) {
349 if !val.is_empty() {
350 push_const(&mut out, &mut seen, key, val);
351 } else if i + 1 < tokens.len() && !is_separator(tokens[i + 1]) {
352 push_const(&mut out, &mut seen, key, tokens[i + 1]);
353 i += 2;
354 continue;
355 }
356 }
357 }
358 i += 1;
359 }
360 out
361}
362
363fn is_const_key(s: &str) -> bool {
364 s.len() >= 3
365 && s.chars()
366 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
367 && s.chars().any(|c| c.is_ascii_uppercase())
368}
369
370fn is_separator(s: &str) -> bool {
371 matches!(s, "=" | ":" | "==" | "=>" | "->")
372}
373
374fn push_const(
375 out: &mut Vec<(String, String)>,
376 seen: &mut std::collections::BTreeSet<String>,
377 key: &str,
378 value: &str,
379) {
380 if seen.insert(key.to_string()) {
381 out.push((key.to_string(), value.to_string()));
382 }
383}
384
385fn extract_schemas(content: &str) -> Vec<(String, String)> {
386 let mut out: Vec<(String, String)> = Vec::new();
387 let mut in_fence = false;
389 let mut fence_buf = String::new();
390 let mut fence_lang = String::new();
391 for line in content.lines() {
392 if let Some(rest) = line.strip_prefix("```") {
393 if in_fence {
394 let key = if fence_lang.is_empty() {
395 format!("fenced:{}", fence_buf.lines().next().unwrap_or("").trim())
396 } else {
397 format!("fenced:{fence_lang}")
398 };
399 let value = truncate_value(&fence_buf, 240);
400 if !value.is_empty() {
401 out.push((key, value));
402 }
403 fence_buf.clear();
404 fence_lang.clear();
405 in_fence = false;
406 } else {
407 fence_lang = rest.trim().to_string();
408 in_fence = true;
409 }
410 continue;
411 }
412 if in_fence {
413 fence_buf.push_str(line);
414 fence_buf.push('\n');
415 continue;
416 }
417 let t = line.trim_start();
419 let leading: Option<&str> = ["CREATE TABLE", "interface ", "type ", "struct "]
420 .into_iter()
421 .find(|p| t.starts_with(p));
422 if let Some(prefix) = leading {
423 let key = format!("decl:{}", prefix.trim_end());
424 let value = truncate_value(t, 240);
425 out.push((key, value));
426 }
427 }
428 if in_fence && !fence_buf.is_empty() {
430 let key = if fence_lang.is_empty() {
431 "fenced:unclosed".to_string()
432 } else {
433 format!("fenced:{fence_lang}:unclosed")
434 };
435 out.push((key, truncate_value(&fence_buf, 240)));
436 }
437 let mut seen = std::collections::BTreeSet::new();
438 out.retain(|(k, _)| seen.insert(k.clone()));
439 out
440}
441
442fn truncate_value(s: &str, max_chars: usize) -> String {
443 let mut buf = String::with_capacity(max_chars.min(s.len()));
444 for (i, c) in s.chars().enumerate() {
445 if i >= max_chars {
446 buf.push('…');
447 break;
448 }
449 buf.push(c);
450 }
451 buf
452}
453
454fn merge_into(bucket: &mut BTreeMap<String, Entry>, items: Vec<(String, String)>, now_unix: u64) {
455 for (k, v) in items {
456 let entry = bucket.entry(k.clone()).or_insert_with(|| Entry {
457 value: v.clone(),
458 freq: 0,
459 last_seen_unix: now_unix,
460 token_estimate: estimate_tokens(&format!("{k}: {v}")),
461 });
462 entry.freq = entry.freq.saturating_add(1);
463 entry.last_seen_unix = now_unix;
464 if entry.value != v {
465 entry.value = v.clone();
466 entry.token_estimate = estimate_tokens(&format!("{k}: {v}"));
467 }
468 }
469}
470
471fn priority(entry: &Entry, now_unix: u64, budget: u32) -> f64 {
474 let age_s = now_unix.saturating_sub(entry.last_seen_unix) as f64;
475 let recency = 1.0 / (1.0 + age_s / 86_400.0);
476 let size_share = (entry.token_estimate as f64) / (budget.max(1) as f64);
477 let size_penalty = (1.0 - size_share).max(0.05);
478 (entry.freq as f64) * recency * size_penalty
479}
480
481fn evict_to_budget(map: &mut ContextMap, budget: u32, now_unix: u64) {
482 let bucket_totals = |m: &ContextMap| -> u32 {
484 m.entities.values().map(|e| e.token_estimate).sum::<u32>()
485 + m.constants.values().map(|e| e.token_estimate).sum::<u32>()
486 + m.schemas.values().map(|e| e.token_estimate).sum::<u32>()
487 };
488 if bucket_totals(map) <= budget {
489 return;
490 }
491 while bucket_totals(map) > budget {
494 let mut candidates: Vec<(f64, u8, String)> = Vec::new();
495 for (k, e) in &map.entities {
496 candidates.push((priority(e, now_unix, budget), 0, k.clone()));
497 }
498 for (k, e) in &map.constants {
499 candidates.push((priority(e, now_unix, budget), 1, k.clone()));
500 }
501 for (k, e) in &map.schemas {
502 candidates.push((priority(e, now_unix, budget), 2, k.clone()));
503 }
504 if candidates.is_empty() {
505 break;
506 }
507 candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
508 let (_, kind, key) = candidates.remove(0);
509 match kind {
510 0 => {
511 map.entities.remove(&key);
512 }
513 1 => {
514 map.constants.remove(&key);
515 }
516 _ => {
517 map.schemas.remove(&key);
518 }
519 }
520 }
521}
522
523fn render(map: &ContextMap, namespace: &str, budget: u32) -> RenderedContextMap {
524 fn render_bucket(bucket: &BTreeMap<String, Entry>) -> Vec<RenderedEntry> {
525 bucket
526 .iter()
527 .map(|(k, e)| RenderedEntry {
528 key: k.clone(),
529 value: e.value.clone(),
530 freq: e.freq,
531 token_estimate: e.token_estimate,
532 })
533 .collect()
534 }
535 let entities = render_bucket(&map.entities);
536 let constants = render_bucket(&map.constants);
537 let schemas = render_bucket(&map.schemas);
538 let token_estimate = entities.iter().map(|e| e.token_estimate).sum::<u32>()
539 + constants.iter().map(|e| e.token_estimate).sum::<u32>()
540 + schemas.iter().map(|e| e.token_estimate).sum::<u32>();
541 RenderedContextMap {
542 namespace: namespace.to_string(),
543 entities,
544 constants,
545 schemas,
546 token_estimate,
547 budget,
548 hit_count: map.hit_count,
549 }
550}
551
552pub fn update_and_render(
558 store: &OrientationCacheStore,
559 cfg: &OrientationCacheConfig,
560 namespace: &str,
561 hits: &[ScoredMemory],
562) -> RenderedContextMap {
563 let budget = cfg.token_budget.unwrap_or(DEFAULT_TOKEN_BUDGET).max(64);
564 let now_unix = SystemTime::now()
565 .duration_since(UNIX_EPOCH)
566 .map(|d| d.as_secs())
567 .unwrap_or(0);
568
569 store.with_namespace_mut(namespace, |map| {
570 if cfg.distill {
571 for hit in hits {
572 let out = distill(&hit.content);
573 merge_into(&mut map.entities, out.entities, now_unix);
574 merge_into(&mut map.constants, out.constants, now_unix);
575 merge_into(&mut map.schemas, out.schemas, now_unix);
576 map.hit_count = map.hit_count.saturating_add(1);
577 }
578 evict_to_budget(map, budget, now_unix);
579 }
580 render(map, namespace, budget)
581 })
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::model::memory::{MemoryType, Scope};
588 use serde_json::json;
589 use uuid::Uuid;
590
591 fn hit(content: &str) -> ScoredMemory {
592 ScoredMemory {
593 id: Uuid::now_v7(),
594 content: content.to_string(),
595 agent_id: "a".to_string(),
596 memory_type: MemoryType::Episodic,
597 scope: Scope::Private,
598 importance: 0.5,
599 tags: vec![],
600 metadata: json!({}),
601 score: 1.0,
602 access_count: 0,
603 created_at: "2026-05-23T00:00:00Z".to_string(),
604 updated_at: "2026-05-23T00:00:00Z".to_string(),
605 score_breakdown: None,
606 }
607 }
608
609 #[test]
610 fn distill_extracts_entities() {
611 let out = distill("The Foo Bar service calls QuxClient in the Frobnicator pipeline.");
612 let keys: Vec<&str> = out.entities.iter().map(|(k, _)| k.as_str()).collect();
613 assert!(keys.iter().any(|k| k.contains("Foo Bar")));
614 assert!(keys.iter().any(|k| k.contains("QuxClient")));
615 assert!(keys.iter().any(|k| k.contains("Frobnicator")));
616 }
617
618 #[test]
619 fn distill_extracts_uppercase_constants() {
620 let out = distill("API_BASE = https://api.example.com\nMAX_RETRIES: 5\nlower = noop");
621 let keys: Vec<&str> = out.constants.iter().map(|(k, _)| k.as_str()).collect();
622 assert!(keys.contains(&"API_BASE"));
623 assert!(keys.contains(&"MAX_RETRIES"));
624 assert!(!keys.contains(&"lower"));
625 }
626
627 #[test]
628 fn distill_extracts_fenced_schemas() {
629 let content = "preamble\n```sql\nCREATE TABLE users (id BIGINT);\n```\nafter";
630 let out = distill(content);
631 assert!(out.schemas.iter().any(|(k, _)| k == "fenced:sql"));
632 }
633
634 #[test]
635 fn distill_extracts_inline_decls() {
636 let out = distill("interface Foo { id: number; }\nCREATE TABLE orders (id BIGINT);");
637 let keys: Vec<&str> = out.schemas.iter().map(|(k, _)| k.as_str()).collect();
638 assert!(keys.iter().any(|k| k.contains("interface")));
639 assert!(keys.iter().any(|k| k.contains("CREATE TABLE")));
640 }
641
642 #[test]
643 fn namespace_falls_back_to_agent_when_org_missing() {
644 let cfg = OrientationCacheConfig::new();
645 assert_eq!(resolve_namespace(&cfg, "agent-1", None), "agent-1");
646 assert_eq!(
647 resolve_namespace(&cfg, "agent-1", Some("acme")),
648 "acme:agent-1"
649 );
650 assert_eq!(resolve_namespace(&cfg, "", None), GLOBAL_NAMESPACE);
651 }
652
653 #[test]
654 fn explicit_namespace_overrides_derivation() {
655 let cfg = OrientationCacheConfig::new().with_namespace("custom");
656 assert_eq!(resolve_namespace(&cfg, "agent-1", Some("acme")), "custom");
657 }
658
659 #[test]
660 fn update_renders_bounded_map_and_grows_hit_count() {
661 let store = OrientationCacheStore::new();
662 let cfg = OrientationCacheConfig::new();
663 let hits = vec![
664 hit(
665 "Foo Bar uses API_BASE = https://api.example.com\n```sql\nCREATE TABLE x (id BIGINT);\n```",
666 ),
667 hit("BazQux also depends on MAX_RETRIES: 3 for the Frobnicator pipeline"),
668 ];
669 let rendered = update_and_render(&store, &cfg, "ns-a", &hits);
670 assert_eq!(rendered.namespace, "ns-a");
671 assert_eq!(rendered.hit_count, 2);
672 assert!(!rendered.entities.is_empty());
673 assert!(!rendered.constants.is_empty());
674 assert!(!rendered.schemas.is_empty());
675 assert!(rendered.token_estimate <= rendered.budget);
676 }
677
678 #[test]
679 fn budget_evicts_low_priority_entries() {
680 let store = OrientationCacheStore::new();
681 let cfg = OrientationCacheConfig::new().with_token_budget(64);
682 let mut hits = Vec::new();
683 for i in 0..30 {
684 hits.push(hit(&format!(
685 "EntityNumber{i} uses CONST_{i} = value_{i} in schemaword"
686 )));
687 }
688 let rendered = update_and_render(&store, &cfg, "ns-evict", &hits);
689 assert!(
690 rendered.token_estimate <= rendered.budget,
691 "rendered {} exceeded budget {}",
692 rendered.token_estimate,
693 rendered.budget
694 );
695 assert!(rendered.entities.len() + rendered.constants.len() < 60);
698 }
699
700 #[test]
701 fn namespaces_are_isolated() {
702 let store = OrientationCacheStore::new();
703 let cfg = OrientationCacheConfig::new();
704 update_and_render(&store, &cfg, "ns-1", &[hit("Foo Bar = 1\nALPHA = 1")]);
705 update_and_render(&store, &cfg, "ns-2", &[hit("Baz Qux = 2\nBETA = 2")]);
706 let ns1 = store.snapshot("ns-1");
707 let ns2 = store.snapshot("ns-2");
708 assert!(!ns1.constants.contains_key("BETA"));
709 assert!(!ns2.constants.contains_key("ALPHA"));
710 assert_eq!(store.namespace_count(), 2);
711 }
712
713 #[test]
714 fn read_only_config_does_not_distill() {
715 let store = OrientationCacheStore::new();
716 let warm = OrientationCacheConfig::new();
717 update_and_render(&store, &warm, "ns-r", &[hit("Foo Bar = 1\nALPHA = 1")]);
718 let before = store.snapshot("ns-r");
719 let read = OrientationCacheConfig::new().read_only();
720 let _ = update_and_render(&store, &read, "ns-r", &[hit("Baz Qux = 2\nBETA = 2")]);
721 let after = store.snapshot("ns-r");
722 assert_eq!(before.entities.len(), after.entities.len());
723 assert_eq!(before.constants.len(), after.constants.len());
724 assert_eq!(before.hit_count, after.hit_count);
725 }
726
727 #[test]
728 fn rendered_token_estimate_stays_under_budget_across_many_updates() {
729 let store = OrientationCacheStore::new();
730 let cfg = OrientationCacheConfig::new().with_token_budget(128);
731 for round in 0..50 {
732 let h = hit(&format!(
733 "RoundEntity{round} has CONSTANT_{round} = val_{round}"
734 ));
735 let r = update_and_render(&store, &cfg, "ns-rounds", &[h]);
736 assert!(r.token_estimate <= r.budget);
737 }
738 }
739}