Skip to main content

mnemo_core/query/
orientation_cache.rs

1//! v0.4.8 — Orientation cache: an opt-in, namespace-scoped,
2//! constant-token "context map" post-processor over the standard
3//! `recall` result set.
4//!
5//! # Anchor
6//!
7//! [arXiv:2605.19932](https://arxiv.org/abs/2605.19932) (PEEK —
8//! Prefix-Encoded Episodic Knowledge) shows that a small,
9//! token-budgeted "orientation map" maintained alongside an agent's
10//! retrieval surface (key entities, constants, schemas that have
11//! been useful) helps the agent re-enter long-running contexts with
12//! a fraction of the recall payload. The default mnemo recall path
13//! (semantic, BM25, graph, recency) returns whole memory records;
14//! it has no notion of a *distilled* orientation summary.
15//!
16//! The orientation cache runs *after* the normal recall result set
17//! is computed. Given a namespace (operator-chosen — typically
18//! `(org_id, agent_id)`), a `Distiller` extracts transferable
19//! knowledge from each hit (capitalized entities, `UPPER_SNAKE =
20//! value` constants, fenced schema fragments), and an `Evictor`
21//! enforces a fixed token budget. The recall response carries the
22//! resulting bounded map alongside `top-k` so the caller has both
23//! "what is in scope" and "what is relevant right now" in one
24//! payload.
25//!
26//! # Design contract
27//!
28//! - **Opt-in.** Triggered only when
29//!   [`RecallRequest::orientation_cache`][crate::query::recall::RecallRequest::orientation_cache]
30//!   is `Some` AND the engine has an [`OrientationCacheStore`]
31//!   attached via
32//!   [`MnemoEngine::with_orientation_cache_store`][crate::query::MnemoEngine::with_orientation_cache_store].
33//!   The default read path is unchanged.
34//! - **Post-processor, not a replacement.** Runs over whatever
35//!   candidates the underlying `RetrievalMode` produced. Does not
36//!   re-issue a query.
37//! - **Constant-token guarantee.** Each rendered map is bounded by
38//!   the caller's `token_budget` (default 512). The Evictor drops
39//!   entries by `priority = freq × recency × (1 - token_share)`
40//!   until the rendered map fits.
41//! - **Namespace-scoped.** The in-memory store is keyed by
42//!   `(org_id, agent_id)` (or `"__global__"` if neither is set).
43//!   Updates from one namespace never bleed into another.
44//! - **Deterministic distiller.** Pure regex/heuristic extraction —
45//!   no LLM call, no network. Keeps the recall hot path predictable
46//!   and the bench reproducible.
47//!
48//! # What this module is NOT
49//!
50//! - **Not a write-side memory consolidator.** It only summarises
51//!   hits as they pass through recall; it does not rewrite or
52//!   compact memories on disk.
53//! - **Not a learned summariser.** The Distiller is heuristic by
54//!   choice (`v0.4.8` ships the deterministic core; an LLM-backed
55//!   variant is parked for v0.5.x). Treat extracted entries as
56//!   pointers, not paraphrases.
57//! - **Not a context-window extender.** The map fits inside the
58//!   recall response and is bounded by the caller's token budget.
59//!   It does not bypass any model context limit.
60//! - **Not a faithful PEEK reproduction.** PEEK uses a learned
61//!   prefix encoder and a write-side update path. This module
62//!   adopts the *orientation map + constant-token budget* shape
63//!   only; faithfulness is left to operators who can plug an
64//!   embedder/LLM behind the Distiller trait in a follow-up cut.
65//! - **Not persisted.** The store is in-process
66//!   (`Arc<RwLock<HashMap<..>>>`). Restart drops it. Persistence
67//!   to DuckDB / Postgres is a v0.5.x knob, documented in the
68//!   v0.4.8 CHANGELOG entry.
69
70use 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
78/// Default token budget per rendered context map.
79pub const DEFAULT_TOKEN_BUDGET: u32 = 512;
80
81/// Default namespace when neither `org_id` nor `agent_id` is supplied.
82pub const GLOBAL_NAMESPACE: &str = "__global__";
83
84/// Heuristic token estimate (~4 chars per token).
85#[inline]
86fn estimate_tokens(s: &str) -> u32 {
87    (s.len().div_ceil(4)).max(1) as u32
88}
89
90/// Opt-in config for the orientation cache. Carried on
91/// [`RecallRequest::orientation_cache`][crate::query::recall::RecallRequest::orientation_cache].
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct OrientationCacheConfig {
94    /// Operator-chosen namespace label. If `None`, the engine
95    /// derives one from `(org_id, agent_id)` at request time
96    /// (falling back to `"__global__"` if both are absent).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub namespace: Option<String>,
99    /// Maximum rendered tokens. Defaults to
100    /// [`DEFAULT_TOKEN_BUDGET`]. Hard upper bound on the rendered
101    /// payload — the Evictor drops entries until the rendered map
102    /// fits.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub token_budget: Option<u32>,
105    /// When `true` (default), the rendered map is returned in
106    /// [`RecallResponse::orientation_cache`][crate::query::recall::RecallResponse::orientation_cache].
107    /// Set to `false` to update the in-process store without
108    /// growing the response payload (useful for warm-up calls).
109    #[serde(default = "default_true")]
110    pub include_in_response: bool,
111    /// When `true` (default), the Distiller runs over the recall
112    /// hits and updates the in-process map. Set to `false` to read
113    /// the current map without mutating it (useful for inspection).
114    #[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/// Internal entry in a [`ContextMap`].
152#[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/// Per-namespace context state held by the [`OrientationCacheStore`].
161#[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/// In-process per-engine store. Keyed by namespace string.
170#[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    /// Snapshot a namespace without mutating it.
181    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    /// Number of namespaces currently tracked. Diagnostic only.
190    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/// Bounded, serialisable rendering of a [`ContextMap`] returned in
210/// the recall response.
211#[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/// One rendered entry of a [`RenderedContextMap`].
223#[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
231/// Derive a namespace key from `(operator override, org_id, agent_id)`.
232pub 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/// Distiller output bucketed by knowledge kind.
249#[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
256/// Heuristic distiller. Pure-Rust, regex-free, deterministic.
257///
258/// - **Entities:** sequences of capitalized whitespace-separated
259///   tokens (length ≥ 3) anchored on a leading capital letter.
260/// - **Constants:** `UPPER_SNAKE_CASE` token followed by `=` or
261///   `:` and a non-whitespace value.
262/// - **Schemas:** fenced ```` ``` ```` blocks (any language) and
263///   lines beginning with `CREATE TABLE` / `interface ` / `type ` /
264///   `struct `.
265pub 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    // De-duplicate while preserving order.
295    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    // Allow CamelCase / PascalCase / leading-cap words.
310    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    // Token-by-token scan. Splits on whitespace + common punctuation
317    // (commas, semicolons, parens, quotes) so a mid-line
318    // `Foo uses API_BASE = https://api.example.com` reduces to the
319    // token sequence `["Foo", "uses", "API_BASE", "=", "https://..."]`
320    // and we match the `UPPER_SNAKE` + `[:=]` + value triple.
321    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        // Bare `UPPER_SNAKE` key — value is in the next 1-2 tokens.
329        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            // Glued forms: `KEY=value`, `KEY:value`, or `KEY:` /
347            // `KEY=` with the value in the next token.
348            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    // Fenced code blocks.
388    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        // Schema-shaped declarations on a single line.
418        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    // Flush an unclosed fence so partial schemas still register.
429    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
471/// Score = freq × recency_weight × size_penalty.
472/// Higher is better. Used by the Evictor to keep the most useful entries.
473fn 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    // Compute total tokens; if already under budget, no-op.
483    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    // Build a flat list of (priority, kind, key) tuples and drop the
492    // lowest-priority entry until under budget.
493    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
552/// Update the per-namespace map with the hits and return a bounded
553/// rendering. Called from `recall::execute` when both
554/// [`RecallRequest::orientation_cache`][crate::query::recall::RecallRequest::orientation_cache]
555/// is `Some` and the engine has an
556/// [`OrientationCacheStore`][OrientationCacheStore] attached.
557pub 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        // Some entries were evicted: the rendered map is smaller
696        // than the total possible 30 × 2 entries.
697        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}