Skip to main content

lean_ctx/core/
memory_policy.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4#[serde(default)]
5pub struct MemoryPolicy {
6    pub knowledge: KnowledgePolicy,
7    pub episodic: EpisodicPolicy,
8    pub procedural: ProceduralPolicy,
9    pub lifecycle: LifecyclePolicy,
10    pub embeddings: EmbeddingsPolicy,
11    pub gotcha: GotchaPolicy,
12}
13
14impl MemoryPolicy {
15    pub fn apply_env_overrides(&mut self) {
16        self.knowledge.apply_env_overrides();
17        self.episodic.apply_env_overrides();
18        self.procedural.apply_env_overrides();
19        self.lifecycle.apply_env_overrides();
20        self.embeddings.apply_env_overrides();
21        self.gotcha.apply_env_overrides();
22    }
23
24    pub fn apply_overrides(&mut self, o: &MemoryPolicyOverrides) {
25        self.knowledge.apply_overrides(&o.knowledge);
26        self.lifecycle.apply_overrides(&o.lifecycle);
27    }
28
29    pub fn validate(&self) -> Result<(), String> {
30        self.knowledge.validate()?;
31        self.episodic.validate()?;
32        self.procedural.validate()?;
33        self.lifecycle.validate()?;
34        self.embeddings.validate()?;
35        self.gotcha.validate()?;
36        Ok(())
37    }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41#[serde(default)]
42pub struct MemoryPolicyOverrides {
43    pub knowledge: KnowledgePolicyOverrides,
44    pub lifecycle: LifecyclePolicyOverrides,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48#[serde(default)]
49pub struct KnowledgePolicyOverrides {
50    pub max_facts: Option<usize>,
51    pub max_patterns: Option<usize>,
52    pub max_history: Option<usize>,
53    pub contradiction_threshold: Option<f32>,
54    pub recall_facts_limit: Option<usize>,
55    pub rooms_limit: Option<usize>,
56    pub timeline_limit: Option<usize>,
57    pub relations_limit: Option<usize>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61#[serde(default)]
62pub struct LifecyclePolicyOverrides {
63    pub decay_rate: Option<f32>,
64    pub low_confidence_threshold: Option<f32>,
65    pub stale_days: Option<i64>,
66    pub similarity_threshold: Option<f32>,
67    pub forgetting_model: Option<String>,
68    pub base_stability_days: Option<f32>,
69    pub archetype_aware_decay: Option<bool>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(default)]
74pub struct KnowledgePolicy {
75    pub max_facts: usize,
76    pub max_patterns: usize,
77    pub max_history: usize,
78    pub contradiction_threshold: f32,
79    /// Maximum number of facts returned by recall operations.
80    pub recall_facts_limit: usize,
81    /// Maximum number of rooms returned by `ctx_knowledge action=rooms`.
82    pub rooms_limit: usize,
83    /// Maximum number of timeline entries returned by `ctx_knowledge action=timeline`.
84    pub timeline_limit: usize,
85    /// Maximum number of relations/edges returned by relations queries/diagrams.
86    pub relations_limit: usize,
87}
88
89impl Default for KnowledgePolicy {
90    fn default() -> Self {
91        Self {
92            max_facts: 200,
93            max_patterns: 50,
94            max_history: 100,
95            contradiction_threshold: 0.5,
96            recall_facts_limit: crate::core::budgets::KNOWLEDGE_RECALL_FACTS_LIMIT,
97            rooms_limit: crate::core::budgets::KNOWLEDGE_ROOMS_LIMIT,
98            timeline_limit: crate::core::budgets::KNOWLEDGE_TIMELINE_LIMIT,
99            relations_limit: 40,
100        }
101    }
102}
103
104impl KnowledgePolicy {
105    fn apply_env_overrides(&mut self) {
106        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_FACTS")
107            && let Ok(n) = v.parse()
108        {
109            self.max_facts = n;
110        }
111        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_PATTERNS")
112            && let Ok(n) = v.parse()
113        {
114            self.max_patterns = n;
115        }
116        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_HISTORY")
117            && let Ok(n) = v.parse()
118        {
119            self.max_history = n;
120        }
121        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_CONTRADICTION_THRESHOLD")
122            && let Ok(n) = v.parse()
123        {
124            self.contradiction_threshold = n;
125        }
126        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_RECALL_FACTS_LIMIT")
127            && let Ok(n) = v.parse()
128        {
129            self.recall_facts_limit = n;
130        }
131        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_ROOMS_LIMIT")
132            && let Ok(n) = v.parse()
133        {
134            self.rooms_limit = n;
135        }
136        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_TIMELINE_LIMIT")
137            && let Ok(n) = v.parse()
138        {
139            self.timeline_limit = n;
140        }
141        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_RELATIONS_LIMIT")
142            && let Ok(n) = v.parse()
143        {
144            self.relations_limit = n;
145        }
146    }
147
148    fn validate(&self) -> Result<(), String> {
149        if self.max_facts == 0 {
150            return Err("memory.knowledge.max_facts must be > 0".to_string());
151        }
152        if self.max_patterns == 0 {
153            return Err("memory.knowledge.max_patterns must be > 0".to_string());
154        }
155        if self.max_history == 0 {
156            return Err("memory.knowledge.max_history must be > 0".to_string());
157        }
158        if !(0.0..=1.0).contains(&self.contradiction_threshold) {
159            return Err(
160                "memory.knowledge.contradiction_threshold must be in [0.0, 1.0]".to_string(),
161            );
162        }
163        if self.recall_facts_limit == 0 {
164            return Err("memory.knowledge.recall_facts_limit must be > 0".to_string());
165        }
166        if self.rooms_limit == 0 {
167            return Err("memory.knowledge.rooms_limit must be > 0".to_string());
168        }
169        if self.timeline_limit == 0 {
170            return Err("memory.knowledge.timeline_limit must be > 0".to_string());
171        }
172        if self.relations_limit == 0 {
173            return Err("memory.knowledge.relations_limit must be > 0".to_string());
174        }
175        Ok(())
176    }
177
178    fn apply_overrides(&mut self, o: &KnowledgePolicyOverrides) {
179        if let Some(v) = o.max_facts {
180            self.max_facts = v;
181        }
182        if let Some(v) = o.max_patterns {
183            self.max_patterns = v;
184        }
185        if let Some(v) = o.max_history {
186            self.max_history = v;
187        }
188        if let Some(v) = o.contradiction_threshold {
189            self.contradiction_threshold = v;
190        }
191        if let Some(v) = o.recall_facts_limit {
192            self.recall_facts_limit = v;
193        }
194        if let Some(v) = o.rooms_limit {
195            self.rooms_limit = v;
196        }
197        if let Some(v) = o.timeline_limit {
198            self.timeline_limit = v;
199        }
200        if let Some(v) = o.relations_limit {
201            self.relations_limit = v;
202        }
203    }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(default)]
208pub struct EpisodicPolicy {
209    pub max_episodes: usize,
210    pub max_actions_per_episode: usize,
211    pub summary_max_chars: usize,
212}
213
214impl Default for EpisodicPolicy {
215    fn default() -> Self {
216        Self {
217            max_episodes: 500,
218            max_actions_per_episode: 50,
219            summary_max_chars: 200,
220        }
221    }
222}
223
224impl EpisodicPolicy {
225    fn apply_env_overrides(&mut self) {
226        if let Ok(v) = std::env::var("LEAN_CTX_EPISODIC_MAX_EPISODES")
227            && let Ok(n) = v.parse()
228        {
229            self.max_episodes = n;
230        }
231        if let Ok(v) = std::env::var("LEAN_CTX_EPISODIC_MAX_ACTIONS_PER_EPISODE")
232            && let Ok(n) = v.parse()
233        {
234            self.max_actions_per_episode = n;
235        }
236        if let Ok(v) = std::env::var("LEAN_CTX_EPISODIC_SUMMARY_MAX_CHARS")
237            && let Ok(n) = v.parse()
238        {
239            self.summary_max_chars = n;
240        }
241    }
242
243    fn validate(&self) -> Result<(), String> {
244        if self.max_episodes == 0 {
245            return Err("memory.episodic.max_episodes must be > 0".to_string());
246        }
247        if self.max_actions_per_episode == 0 {
248            return Err("memory.episodic.max_actions_per_episode must be > 0".to_string());
249        }
250        if self.summary_max_chars < 40 {
251            return Err("memory.episodic.summary_max_chars must be >= 40".to_string());
252        }
253        Ok(())
254    }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[serde(default)]
259pub struct ProceduralPolicy {
260    pub min_repetitions: usize,
261    pub min_sequence_len: usize,
262    pub max_procedures: usize,
263    pub max_window_size: usize,
264}
265
266impl Default for ProceduralPolicy {
267    fn default() -> Self {
268        Self {
269            min_repetitions: 3,
270            min_sequence_len: 2,
271            max_procedures: 100,
272            max_window_size: 10,
273        }
274    }
275}
276
277impl ProceduralPolicy {
278    fn apply_env_overrides(&mut self) {
279        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS")
280            && let Ok(n) = v.parse()
281        {
282            self.min_repetitions = n;
283        }
284        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MIN_SEQUENCE_LEN")
285            && let Ok(n) = v.parse()
286        {
287            self.min_sequence_len = n;
288        }
289        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MAX_PROCEDURES")
290            && let Ok(n) = v.parse()
291        {
292            self.max_procedures = n;
293        }
294        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MAX_WINDOW_SIZE")
295            && let Ok(n) = v.parse()
296        {
297            self.max_window_size = n;
298        }
299    }
300
301    fn validate(&self) -> Result<(), String> {
302        if self.min_repetitions == 0 {
303            return Err("memory.procedural.min_repetitions must be > 0".to_string());
304        }
305        if self.min_sequence_len < 2 {
306            return Err("memory.procedural.min_sequence_len must be >= 2".to_string());
307        }
308        if self.max_procedures == 0 {
309            return Err("memory.procedural.max_procedures must be > 0".to_string());
310        }
311        if self.max_window_size < self.min_sequence_len {
312            return Err(
313                "memory.procedural.max_window_size must be >= min_sequence_len".to_string(),
314            );
315        }
316        Ok(())
317    }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321#[serde(default)]
322pub struct LifecyclePolicy {
323    pub decay_rate: f32,
324    pub low_confidence_threshold: f32,
325    pub stale_days: i64,
326    pub similarity_threshold: f32,
327    /// Forgetting curve (#1): `ebbinghaus` (default) or `linear` (legacy).
328    pub forgetting_model: String,
329    /// Characteristic memory stability in days for the Ebbinghaus curve.
330    pub base_stability_days: f32,
331    /// Scale Ebbinghaus stability by fact archetype so structural evidence decays
332    /// slower than inference. Default false keeps the baseline tuning unchanged.
333    pub archetype_aware_decay: bool,
334}
335
336impl Default for LifecyclePolicy {
337    fn default() -> Self {
338        Self {
339            decay_rate: 0.01,
340            low_confidence_threshold: 0.3,
341            stale_days: 30,
342            similarity_threshold: 0.85,
343            forgetting_model: "ebbinghaus".to_string(),
344            base_stability_days: crate::core::memory_lifecycle::DEFAULT_BASE_STABILITY_DAYS,
345            archetype_aware_decay: false,
346        }
347    }
348}
349
350impl LifecyclePolicy {
351    fn apply_env_overrides(&mut self) {
352        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_DECAY_RATE")
353            && let Ok(n) = v.parse()
354        {
355            self.decay_rate = n;
356        }
357        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_LOW_CONFIDENCE_THRESHOLD")
358            && let Ok(n) = v.parse()
359        {
360            self.low_confidence_threshold = n;
361        }
362        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_STALE_DAYS")
363            && let Ok(n) = v.parse()
364        {
365            self.stale_days = n;
366        }
367        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_SIMILARITY_THRESHOLD")
368            && let Ok(n) = v.parse()
369        {
370            self.similarity_threshold = n;
371        }
372        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_FORGETTING") {
373            self.forgetting_model = v;
374        }
375        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_BASE_STABILITY_DAYS")
376            && let Ok(n) = v.parse()
377        {
378            self.base_stability_days = n;
379        }
380        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_ARCHETYPE_AWARE") {
381            self.archetype_aware_decay = v == "1" || v.eq_ignore_ascii_case("true");
382        }
383    }
384
385    fn validate(&self) -> Result<(), String> {
386        if !(0.0..=1.0).contains(&self.decay_rate) {
387            return Err("memory.lifecycle.decay_rate must be in [0.0, 1.0]".to_string());
388        }
389        if !(0.0..=1.0).contains(&self.low_confidence_threshold) {
390            return Err(
391                "memory.lifecycle.low_confidence_threshold must be in [0.0, 1.0]".to_string(),
392            );
393        }
394        if self.stale_days < 0 {
395            return Err("memory.lifecycle.stale_days must be >= 0".to_string());
396        }
397        if !(0.0..=1.0).contains(&self.similarity_threshold) {
398            return Err("memory.lifecycle.similarity_threshold must be in [0.0, 1.0]".to_string());
399        }
400        if self.base_stability_days <= 0.0 {
401            return Err("memory.lifecycle.base_stability_days must be > 0".to_string());
402        }
403        Ok(())
404    }
405
406    fn apply_overrides(&mut self, o: &LifecyclePolicyOverrides) {
407        if let Some(v) = o.decay_rate {
408            self.decay_rate = v;
409        }
410        if let Some(v) = o.low_confidence_threshold {
411            self.low_confidence_threshold = v;
412        }
413        if let Some(v) = o.stale_days {
414            self.stale_days = v;
415        }
416        if let Some(v) = o.similarity_threshold {
417            self.similarity_threshold = v;
418        }
419        if let Some(ref v) = o.forgetting_model {
420            self.forgetting_model.clone_from(v);
421        }
422        if let Some(v) = o.base_stability_days {
423            self.base_stability_days = v;
424        }
425        if let Some(v) = o.archetype_aware_decay {
426            self.archetype_aware_decay = v;
427        }
428    }
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(default)]
433pub struct EmbeddingsPolicy {
434    pub max_facts: usize,
435}
436
437impl Default for EmbeddingsPolicy {
438    fn default() -> Self {
439        Self { max_facts: 2000 }
440    }
441}
442
443impl EmbeddingsPolicy {
444    fn apply_env_overrides(&mut self) {
445        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_EMBEDDINGS_MAX_FACTS")
446            && let Ok(n) = v.parse()
447        {
448            self.max_facts = n;
449        }
450    }
451
452    fn validate(&self) -> Result<(), String> {
453        if self.max_facts == 0 {
454            return Err("memory.embeddings.max_facts must be > 0".to_string());
455        }
456        Ok(())
457    }
458}
459
460use std::collections::HashMap;
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463#[serde(default)]
464pub struct GotchaPolicy {
465    pub max_gotchas_per_project: usize,
466    pub retrieval_budget_per_room: usize,
467    pub default_decay_rate: f32,
468    pub category_decay_overrides: HashMap<String, f32>,
469    pub auto_expire_days: Option<i64>,
470}
471
472impl Default for GotchaPolicy {
473    fn default() -> Self {
474        Self {
475            max_gotchas_per_project: 100,
476            retrieval_budget_per_room: 10,
477            default_decay_rate: 0.03,
478            category_decay_overrides: HashMap::new(),
479            auto_expire_days: None,
480        }
481    }
482}
483
484impl GotchaPolicy {
485    fn apply_env_overrides(&mut self) {
486        if let Ok(v) = std::env::var("LEAN_CTX_GOTCHA_MAX_PER_PROJECT")
487            && let Ok(n) = v.parse()
488        {
489            self.max_gotchas_per_project = n;
490        }
491        if let Ok(v) = std::env::var("LEAN_CTX_GOTCHA_RETRIEVAL_BUDGET")
492            && let Ok(n) = v.parse()
493        {
494            self.retrieval_budget_per_room = n;
495        }
496    }
497
498    fn validate(&self) -> Result<(), String> {
499        if self.max_gotchas_per_project == 0 {
500            return Err("memory.gotcha.max_gotchas_per_project must be > 0".to_string());
501        }
502        if self.retrieval_budget_per_room == 0 {
503            return Err("memory.gotcha.retrieval_budget_per_room must be > 0".to_string());
504        }
505        if !(0.0..=1.0).contains(&self.default_decay_rate) {
506            return Err("memory.gotcha.default_decay_rate must be 0.0-1.0".to_string());
507        }
508        Ok(())
509    }
510
511    pub fn effective_decay_rate(&self, category: &str) -> f32 {
512        self.category_decay_overrides
513            .get(category)
514            .copied()
515            .unwrap_or(self.default_decay_rate)
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn restore_env(key: &str, prev: Option<String>) {
524        match prev {
525            Some(v) => crate::test_env::set_var(key, v),
526            None => crate::test_env::remove_var(key),
527        }
528    }
529
530    #[test]
531    fn default_policy_is_valid() {
532        let p = MemoryPolicy::default();
533        p.validate().expect("default policy must be valid");
534    }
535
536    #[test]
537    fn env_overrides_apply() {
538        let _lock = crate::core::data_dir::test_env_lock();
539
540        let prev_facts = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_FACTS").ok();
541        let prev_stale = std::env::var("LEAN_CTX_LIFECYCLE_STALE_DAYS").ok();
542        let prev_rep = std::env::var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS").ok();
543
544        crate::test_env::set_var("LEAN_CTX_KNOWLEDGE_MAX_FACTS", "123");
545        crate::test_env::set_var("LEAN_CTX_LIFECYCLE_STALE_DAYS", "7");
546        crate::test_env::set_var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS", "4");
547
548        let mut p = MemoryPolicy::default();
549        p.apply_env_overrides();
550
551        assert_eq!(p.knowledge.max_facts, 123);
552        assert_eq!(p.lifecycle.stale_days, 7);
553        assert_eq!(p.procedural.min_repetitions, 4);
554
555        restore_env("LEAN_CTX_KNOWLEDGE_MAX_FACTS", prev_facts);
556        restore_env("LEAN_CTX_LIFECYCLE_STALE_DAYS", prev_stale);
557        restore_env("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS", prev_rep);
558    }
559
560    #[test]
561    fn validate_rejects_invalid_values() {
562        let mut p = MemoryPolicy::default();
563        p.knowledge.max_facts = 0;
564        assert!(p.validate().is_err());
565
566        let mut p = MemoryPolicy::default();
567        p.lifecycle.decay_rate = 2.0;
568        assert!(p.validate().is_err());
569
570        let mut p = MemoryPolicy::default();
571        p.procedural.min_sequence_len = 1;
572        assert!(p.validate().is_err());
573    }
574}