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    pub admission: AdmissionPolicy,
13    pub compaction: CompactionPolicy,
14}
15
16impl MemoryPolicy {
17    pub fn apply_env_overrides(&mut self) {
18        self.knowledge.apply_env_overrides();
19        self.lifecycle.apply_env_overrides();
20        self.embeddings.apply_env_overrides();
21        self.gotcha.apply_env_overrides();
22        self.admission.apply_env_overrides();
23    }
24
25    pub fn apply_overrides(&mut self, o: &MemoryPolicyOverrides) {
26        self.knowledge.apply_overrides(&o.knowledge);
27        self.lifecycle.apply_overrides(&o.lifecycle);
28    }
29
30    pub fn validate(&self) -> Result<(), String> {
31        self.knowledge.validate()?;
32        self.lifecycle.validate()?;
33        self.embeddings.validate()?;
34        self.gotcha.validate()?;
35        self.admission.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    // A profile's `max_facts` (removed fact cap) is an unknown key here and
51    // skipped by serde; see `KnowledgePolicy::max_facts`.
52    pub max_patterns: Option<usize>,
53    pub max_history: Option<usize>,
54    pub contradiction_threshold: Option<f32>,
55    pub recall_facts_limit: Option<usize>,
56    pub rooms_limit: Option<usize>,
57    pub timeline_limit: Option<usize>,
58    pub relations_limit: Option<usize>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62#[serde(default)]
63pub struct LifecyclePolicyOverrides {
64    pub decay_rate: Option<f32>,
65    pub low_confidence_threshold: Option<f32>,
66    pub stale_days: Option<i64>,
67    pub similarity_threshold: Option<f32>,
68    pub forgetting_model: Option<String>,
69    pub base_stability_days: Option<f32>,
70    pub archetype_aware_decay: Option<bool>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(default)]
75pub struct KnowledgePolicy {
76    /// Deprecated and ignored (3.12.1): knowledge has no fact limit (owner
77    /// decision 2026-10-24). Kept only so an existing `max_facts` in a
78    /// config.toml still loads and round-trips; nothing reads it. Facts leave
79    /// the active store by quality alone (decay, near-duplicate merge,
80    /// low-confidence/stale archival), never by count.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub max_facts: Option<usize>,
83    pub max_patterns: usize,
84    pub max_history: usize,
85    pub contradiction_threshold: f32,
86    /// Maximum number of facts returned by recall operations.
87    pub recall_facts_limit: usize,
88    /// Maximum number of rooms returned by `ctx_knowledge action=rooms`.
89    pub rooms_limit: usize,
90    /// Maximum number of timeline entries returned by `ctx_knowledge action=timeline`.
91    pub timeline_limit: usize,
92    /// Maximum number of relations/edges returned by relations queries/diagrams.
93    pub relations_limit: usize,
94}
95
96impl Default for KnowledgePolicy {
97    fn default() -> Self {
98        Self {
99            max_facts: None,
100            max_patterns: 50,
101            max_history: 100,
102            contradiction_threshold: 0.5,
103            recall_facts_limit: crate::core::budgets::KNOWLEDGE_RECALL_FACTS_LIMIT,
104            rooms_limit: crate::core::budgets::KNOWLEDGE_ROOMS_LIMIT,
105            timeline_limit: crate::core::budgets::KNOWLEDGE_TIMELINE_LIMIT,
106            relations_limit: 40,
107        }
108    }
109}
110
111impl KnowledgePolicy {
112    fn apply_env_overrides(&mut self) {
113        // `LEAN_CTX_KNOWLEDGE_MAX_FACTS` is ignored: knowledge has no fact limit.
114        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_PATTERNS")
115            && let Ok(n) = v.parse()
116        {
117            self.max_patterns = n;
118        }
119        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_HISTORY")
120            && let Ok(n) = v.parse()
121        {
122            self.max_history = n;
123        }
124        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_CONTRADICTION_THRESHOLD")
125            && let Ok(n) = v.parse()
126        {
127            self.contradiction_threshold = n;
128        }
129        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_RECALL_FACTS_LIMIT")
130            && let Ok(n) = v.parse()
131        {
132            self.recall_facts_limit = n;
133        }
134        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_ROOMS_LIMIT")
135            && let Ok(n) = v.parse()
136        {
137            self.rooms_limit = n;
138        }
139        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_TIMELINE_LIMIT")
140            && let Ok(n) = v.parse()
141        {
142            self.timeline_limit = n;
143        }
144        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_RELATIONS_LIMIT")
145            && let Ok(n) = v.parse()
146        {
147            self.relations_limit = n;
148        }
149    }
150
151    fn validate(&self) -> Result<(), String> {
152        // `max_facts` is ignored, so any value (0 included) is accepted.
153        if self.max_patterns == 0 {
154            return Err("memory.knowledge.max_patterns must be > 0".to_string());
155        }
156        if self.max_history == 0 {
157            return Err("memory.knowledge.max_history must be > 0".to_string());
158        }
159        if !(0.0..=1.0).contains(&self.contradiction_threshold) {
160            return Err(
161                "memory.knowledge.contradiction_threshold must be in [0.0, 1.0]".to_string(),
162            );
163        }
164        if self.recall_facts_limit == 0 {
165            return Err("memory.knowledge.recall_facts_limit must be > 0".to_string());
166        }
167        if self.rooms_limit == 0 {
168            return Err("memory.knowledge.rooms_limit must be > 0".to_string());
169        }
170        if self.timeline_limit == 0 {
171            return Err("memory.knowledge.timeline_limit must be > 0".to_string());
172        }
173        if self.relations_limit == 0 {
174            return Err("memory.knowledge.relations_limit must be > 0".to_string());
175        }
176        Ok(())
177    }
178
179    fn apply_overrides(&mut self, o: &KnowledgePolicyOverrides) {
180        if let Some(v) = o.max_patterns {
181            self.max_patterns = v;
182        }
183        if let Some(v) = o.max_history {
184            self.max_history = v;
185        }
186        if let Some(v) = o.contradiction_threshold {
187            self.contradiction_threshold = v;
188        }
189        if let Some(v) = o.recall_facts_limit {
190            self.recall_facts_limit = v;
191        }
192        if let Some(v) = o.rooms_limit {
193            self.rooms_limit = v;
194        }
195        if let Some(v) = o.timeline_limit {
196            self.timeline_limit = v;
197        }
198        if let Some(v) = o.relations_limit {
199            self.relations_limit = v;
200        }
201    }
202}
203
204/// `[memory.episodic]`: no effect. The episode memory is retired
205/// (DEPRECATIONS.toml `config-memory-episodic-procedural`); the section stays
206/// accepted, unvalidated, so existing configs keep loading. Its
207/// `LEAN_CTX_EPISODIC_*` environment variables are ignored.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(default)]
210pub struct EpisodicPolicy {
211    pub max_episodes: usize,
212    pub max_actions_per_episode: usize,
213    pub summary_max_chars: usize,
214}
215
216impl Default for EpisodicPolicy {
217    fn default() -> Self {
218        Self {
219            max_episodes: 500,
220            max_actions_per_episode: 50,
221            summary_max_chars: 200,
222        }
223    }
224}
225
226/// `[memory.procedural]`: no effect. The procedure memory is retired
227/// (DEPRECATIONS.toml `config-memory-episodic-procedural`); the section stays
228/// accepted, unvalidated, so existing configs keep loading. Its
229/// `LEAN_CTX_PROCEDURAL_*` environment variables are ignored.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(default)]
232pub struct ProceduralPolicy {
233    pub min_repetitions: usize,
234    pub min_sequence_len: usize,
235    pub max_procedures: usize,
236    pub max_window_size: usize,
237}
238
239impl Default for ProceduralPolicy {
240    fn default() -> Self {
241        Self {
242            min_repetitions: 3,
243            min_sequence_len: 2,
244            max_procedures: 100,
245            max_window_size: 10,
246        }
247    }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(default)]
252pub struct LifecyclePolicy {
253    pub decay_rate: f32,
254    pub low_confidence_threshold: f32,
255    pub stale_days: i64,
256    pub similarity_threshold: f32,
257    /// Forgetting curve (#1): `ebbinghaus` (default) or `linear` (legacy).
258    pub forgetting_model: String,
259    /// Characteristic memory stability in days for the Ebbinghaus curve.
260    pub base_stability_days: f32,
261    /// Scale Ebbinghaus stability by fact archetype so structural evidence decays
262    /// slower than inference. Default false keeps the baseline tuning unchanged.
263    pub archetype_aware_decay: bool,
264    /// Archive single-confirmation facts untouched for this many days that were
265    /// never retrieved — dead weight regardless of confidence (#962). Defaults to
266    /// a conservative 90 days (#972): genuinely cold facts are archived (and
267    /// rehydrate on recall), so a store self-curates instead of only churning at
268    /// its cap. Set `None`/`off` to disable.
269    pub prune_unretrieved_after_days: Option<i64>,
270    /// Proactive headroom on a capacity reclaim (#995): when a store reaches its
271    /// cap, settle it at `1 - reclaim_headroom_pct` (e.g. `0.25` → 75%) instead
272    /// of churning right at the cap. Lossless — the reclaimed tail is archived
273    /// and restorable.
274    pub reclaim_headroom_pct: f32,
275    /// Master switch for the proactive reclaim (#995). `false` is the documented
276    /// escape hatch: trim only the overflow, no headroom. Eviction stays lossless
277    /// either way.
278    pub reclaim_enabled: bool,
279}
280
281impl Default for LifecyclePolicy {
282    fn default() -> Self {
283        Self {
284            decay_rate: 0.01,
285            low_confidence_threshold: 0.3,
286            stale_days: 30,
287            similarity_threshold: 0.85,
288            forgetting_model: "ebbinghaus".to_string(),
289            base_stability_days: crate::core::memory_lifecycle::DEFAULT_BASE_STABILITY_DAYS,
290            archetype_aware_decay: false,
291            prune_unretrieved_after_days: Some(90),
292            reclaim_headroom_pct: crate::core::memory_lifecycle::DEFAULT_RECLAIM_HEADROOM_PCT,
293            reclaim_enabled: true,
294        }
295    }
296}
297
298impl LifecyclePolicy {
299    fn apply_env_overrides(&mut self) {
300        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_DECAY_RATE")
301            && let Ok(n) = v.parse()
302        {
303            self.decay_rate = n;
304        }
305        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_LOW_CONFIDENCE_THRESHOLD")
306            && let Ok(n) = v.parse()
307        {
308            self.low_confidence_threshold = n;
309        }
310        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_STALE_DAYS")
311            && let Ok(n) = v.parse()
312        {
313            self.stale_days = n;
314        }
315        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_SIMILARITY_THRESHOLD")
316            && let Ok(n) = v.parse()
317        {
318            self.similarity_threshold = n;
319        }
320        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_FORGETTING") {
321            self.forgetting_model = v;
322        }
323        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_BASE_STABILITY_DAYS")
324            && let Ok(n) = v.parse()
325        {
326            self.base_stability_days = n;
327        }
328        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_ARCHETYPE_AWARE") {
329            self.archetype_aware_decay = v == "1" || v.eq_ignore_ascii_case("true");
330        }
331        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_PRUNE_UNRETRIEVED_DAYS") {
332            self.prune_unretrieved_after_days = match v.trim().to_lowercase().as_str() {
333                "" | "off" | "none" | "0" => None,
334                s => s.parse::<i64>().ok().filter(|&n| n > 0),
335            };
336        }
337        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_RECLAIM_HEADROOM_PCT")
338            && let Ok(n) = v.parse()
339        {
340            self.reclaim_headroom_pct = n;
341        }
342        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_RECLAIM_ENABLED") {
343            self.reclaim_enabled = !(v == "0" || v.eq_ignore_ascii_case("false"));
344        }
345    }
346
347    fn validate(&self) -> Result<(), String> {
348        if !(0.0..=1.0).contains(&self.decay_rate) {
349            return Err("memory.lifecycle.decay_rate must be in [0.0, 1.0]".to_string());
350        }
351        if !(0.0..=1.0).contains(&self.low_confidence_threshold) {
352            return Err(
353                "memory.lifecycle.low_confidence_threshold must be in [0.0, 1.0]".to_string(),
354            );
355        }
356        if self.stale_days < 0 {
357            return Err("memory.lifecycle.stale_days must be >= 0".to_string());
358        }
359        if !(0.0..=1.0).contains(&self.similarity_threshold) {
360            return Err("memory.lifecycle.similarity_threshold must be in [0.0, 1.0]".to_string());
361        }
362        if self.base_stability_days <= 0.0 {
363            return Err("memory.lifecycle.base_stability_days must be > 0".to_string());
364        }
365        if !(0.0..=0.95).contains(&self.reclaim_headroom_pct) {
366            return Err("memory.lifecycle.reclaim_headroom_pct must be in [0.0, 0.95]".to_string());
367        }
368        Ok(())
369    }
370
371    fn apply_overrides(&mut self, o: &LifecyclePolicyOverrides) {
372        if let Some(v) = o.decay_rate {
373            self.decay_rate = v;
374        }
375        if let Some(v) = o.low_confidence_threshold {
376            self.low_confidence_threshold = v;
377        }
378        if let Some(v) = o.stale_days {
379            self.stale_days = v;
380        }
381        if let Some(v) = o.similarity_threshold {
382            self.similarity_threshold = v;
383        }
384        if let Some(ref v) = o.forgetting_model {
385            self.forgetting_model.clone_from(v);
386        }
387        if let Some(v) = o.base_stability_days {
388            self.base_stability_days = v;
389        }
390        if let Some(v) = o.archetype_aware_decay {
391            self.archetype_aware_decay = v;
392        }
393    }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[serde(default)]
398pub struct EmbeddingsPolicy {
399    pub max_facts: usize,
400}
401
402impl Default for EmbeddingsPolicy {
403    fn default() -> Self {
404        Self { max_facts: 2000 }
405    }
406}
407
408impl EmbeddingsPolicy {
409    fn apply_env_overrides(&mut self) {
410        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_EMBEDDINGS_MAX_FACTS")
411            && let Ok(n) = v.parse()
412        {
413            self.max_facts = n;
414        }
415    }
416
417    fn validate(&self) -> Result<(), String> {
418        if self.max_facts == 0 {
419            return Err("memory.embeddings.max_facts must be > 0".to_string());
420        }
421        Ok(())
422    }
423}
424
425/// Gotcha store bounds (`[memory.gotcha]`).
426///
427/// Decay follows each gotcha's provenance (promoted, agent-reported,
428/// correlated, auto-detected), so the former `default_decay_rate`,
429/// `category_decay_overrides`, `retrieval_budget_per_room` and
430/// `auto_expire_days` keys were never applied; they are retired
431/// (`config::retired`) and ignored when an older config loads.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(default)]
434pub struct GotchaPolicy {
435    /// Most gotchas kept per project; the lowest-confidence ones are dropped
436    /// first. Env: `LEAN_CTX_GOTCHA_MAX_PER_PROJECT`.
437    pub max_gotchas_per_project: usize,
438}
439
440impl Default for GotchaPolicy {
441    fn default() -> Self {
442        Self {
443            max_gotchas_per_project: 100,
444        }
445    }
446}
447
448impl GotchaPolicy {
449    fn apply_env_overrides(&mut self) {
450        if let Ok(v) = std::env::var("LEAN_CTX_GOTCHA_MAX_PER_PROJECT")
451            && let Ok(n) = v.parse()
452        {
453            self.max_gotchas_per_project = n;
454        }
455    }
456
457    fn validate(&self) -> Result<(), String> {
458        if self.max_gotchas_per_project == 0 {
459            return Err("memory.gotcha.max_gotchas_per_project must be > 0".to_string());
460        }
461        Ok(())
462    }
463}
464
465/// Write-time admission control for the knowledge store (#970). The cap +
466/// importance-eviction is a backstop *after* the fact is written; admission is
467/// the boundary *before* it, so a capped store fills with signal, not noise.
468/// Applied only to direct `ctx_knowledge remember` (the agent-facing path);
469/// internal restorers (archive rehydrate, cognition auto-promotion) bypass it.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(default)]
472pub struct AdmissionPolicy {
473    /// Master switch for write-time admission. When off, every `remember`
474    /// inserts as before (legacy behavior).
475    pub enabled: bool,
476    /// A new fact whose value is at least this similar (word-Jaccard, 0.0–1.0) to
477    /// an existing *current* fact in the **same category** under a different key
478    /// is merged into it (a confirmation bump) instead of inserted as a new row.
479    /// High by default so only genuine near-duplicates collapse; `0.0` disables
480    /// auto-merge.
481    pub auto_merge_similarity: f32,
482    /// Facts whose content salience (`crate::core::memory_salience::text_salience`)
483    /// is below this floor are not admitted as normal facts. `0` (default)
484    /// disables the floor — the lossless choice; raise it to curate a noisy store.
485    pub min_salience: u32,
486}
487
488impl Default for AdmissionPolicy {
489    fn default() -> Self {
490        Self {
491            enabled: true,
492            auto_merge_similarity: 0.9,
493            min_salience: 0,
494        }
495    }
496}
497
498impl AdmissionPolicy {
499    fn apply_env_overrides(&mut self) {
500        if let Ok(v) = std::env::var("LEAN_CTX_ADMISSION_ENABLED") {
501            self.enabled = !(v == "0" || v.eq_ignore_ascii_case("false"));
502        }
503        if let Ok(v) = std::env::var("LEAN_CTX_ADMISSION_MERGE_SIMILARITY")
504            && let Ok(n) = v.parse()
505        {
506            self.auto_merge_similarity = n;
507        }
508        if let Ok(v) = std::env::var("LEAN_CTX_ADMISSION_MIN_SALIENCE")
509            && let Ok(n) = v.parse()
510        {
511            self.min_salience = n;
512        }
513    }
514
515    fn validate(&self) -> Result<(), String> {
516        if !(0.0..=1.0).contains(&self.auto_merge_similarity) {
517            return Err("memory.admission.auto_merge_similarity must be in [0.0, 1.0]".to_string());
518        }
519        Ok(())
520    }
521}
522
523/// `[memory.compaction]`: no effect. Cluster compaction (#971) ran only in the
524/// retired cognition loop (DEPRECATIONS.toml `config-memory-compaction`); the
525/// section stays accepted, unvalidated, so existing configs keep loading.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527#[serde(default)]
528pub struct CompactionPolicy {
529    pub enabled: bool,
530    pub min_cluster: usize,
531    pub similarity: f32,
532    pub max_confidence: f32,
533    pub max_confirmations: u32,
534}
535
536impl Default for CompactionPolicy {
537    fn default() -> Self {
538        Self {
539            enabled: true,
540            min_cluster: 4,
541            similarity: 0.5,
542            max_confidence: 0.5,
543            max_confirmations: 1,
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    fn restore_env(key: &str, prev: Option<String>) {
553        match prev {
554            Some(v) => crate::test_env::set_var(key, v),
555            None => crate::test_env::remove_var(key),
556        }
557    }
558
559    #[test]
560    fn default_policy_is_valid() {
561        let p = MemoryPolicy::default();
562        p.validate().expect("default policy must be valid");
563    }
564
565    #[test]
566    fn memory_discipline_defaults_are_premium() {
567        // #970/#971/#972: self-curation is on by default, but lossless.
568        let p = MemoryPolicy::default();
569        assert!(p.admission.enabled, "admission on by default");
570        assert_eq!(p.admission.min_salience, 0, "salience floor off (lossless)");
571        assert_eq!(
572            p.lifecycle.prune_unretrieved_after_days,
573            Some(90),
574            "conservative recoverable prune default"
575        );
576    }
577
578    #[test]
579    fn admission_env_overrides_apply() {
580        let _lock = crate::core::data_dir::test_env_lock();
581
582        let prev = [
583            (
584                "LEAN_CTX_ADMISSION_ENABLED",
585                std::env::var("LEAN_CTX_ADMISSION_ENABLED").ok(),
586            ),
587            (
588                "LEAN_CTX_ADMISSION_MIN_SALIENCE",
589                std::env::var("LEAN_CTX_ADMISSION_MIN_SALIENCE").ok(),
590            ),
591        ];
592        crate::test_env::set_var("LEAN_CTX_ADMISSION_ENABLED", "0");
593        crate::test_env::set_var("LEAN_CTX_ADMISSION_MIN_SALIENCE", "42");
594
595        let mut p = MemoryPolicy::default();
596        p.apply_env_overrides();
597
598        assert!(!p.admission.enabled);
599        assert_eq!(p.admission.min_salience, 42);
600
601        for (key, val) in prev {
602            restore_env(key, val);
603        }
604    }
605
606    #[test]
607    fn validate_rejects_invalid_admission() {
608        let mut p = MemoryPolicy::default();
609        p.admission.auto_merge_similarity = 1.5;
610        assert!(p.validate().is_err());
611    }
612
613    #[test]
614    fn retired_compaction_section_still_loads_and_validates() {
615        let p: MemoryPolicy =
616            toml::from_str("[compaction]\nenabled = true\nmin_cluster = 1\nsimilarity = 2.0\n")
617                .expect("a retired [compaction] section must keep loading");
618        assert_eq!(p.compaction.min_cluster, 1);
619        p.validate()
620            .expect("a retired [compaction] section is not validated");
621    }
622
623    #[test]
624    fn env_overrides_apply() {
625        let _lock = crate::core::data_dir::test_env_lock();
626
627        let prev_facts = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_FACTS").ok();
628        let prev_stale = std::env::var("LEAN_CTX_LIFECYCLE_STALE_DAYS").ok();
629        let prev_rep = std::env::var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS").ok();
630        let prev_eps = std::env::var("LEAN_CTX_EPISODIC_MAX_EPISODES").ok();
631
632        crate::test_env::set_var("LEAN_CTX_KNOWLEDGE_MAX_FACTS", "123");
633        crate::test_env::set_var("LEAN_CTX_LIFECYCLE_STALE_DAYS", "7");
634        crate::test_env::set_var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS", "4");
635        crate::test_env::set_var("LEAN_CTX_EPISODIC_MAX_EPISODES", "7");
636
637        let mut p = MemoryPolicy::default();
638        p.apply_env_overrides();
639
640        // The fact cap is gone: its env var no longer sets anything.
641        assert_eq!(p.knowledge.max_facts, None);
642        assert_eq!(p.lifecycle.stale_days, 7);
643        // The episode and procedure memory is retired: their env vars are ignored.
644        assert_eq!(p.procedural.min_repetitions, 3);
645        assert_eq!(p.episodic.max_episodes, 500);
646
647        restore_env("LEAN_CTX_KNOWLEDGE_MAX_FACTS", prev_facts);
648        restore_env("LEAN_CTX_LIFECYCLE_STALE_DAYS", prev_stale);
649        restore_env("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS", prev_rep);
650        restore_env("LEAN_CTX_EPISODIC_MAX_EPISODES", prev_eps);
651    }
652
653    #[test]
654    fn validate_rejects_invalid_values() {
655        let mut p = MemoryPolicy::default();
656        p.knowledge.max_patterns = 0;
657        assert!(p.validate().is_err());
658
659        // The ignored fact cap never fails validation, even at 0.
660        let mut p = MemoryPolicy::default();
661        p.knowledge.max_facts = Some(0);
662        assert!(p.validate().is_ok());
663
664        let mut p = MemoryPolicy::default();
665        p.lifecycle.decay_rate = 2.0;
666        assert!(p.validate().is_err());
667    }
668
669    #[test]
670    fn retired_episodic_and_procedural_sections_still_load_and_validate() {
671        let p: MemoryPolicy = toml::from_str(
672            "[episodic]\nmax_episodes = 0\n[procedural]\nmin_sequence_len = 1\nmax_procedures = 0\n",
673        )
674        .expect("retired [episodic]/[procedural] sections must keep loading");
675        assert_eq!(p.episodic.max_episodes, 0);
676        assert_eq!(p.procedural.min_sequence_len, 1);
677        p.validate()
678            .expect("retired [episodic]/[procedural] sections are not validated");
679    }
680}