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.episodic.apply_env_overrides();
20        self.procedural.apply_env_overrides();
21        self.lifecycle.apply_env_overrides();
22        self.embeddings.apply_env_overrides();
23        self.gotcha.apply_env_overrides();
24        self.admission.apply_env_overrides();
25        self.compaction.apply_env_overrides();
26    }
27
28    pub fn apply_overrides(&mut self, o: &MemoryPolicyOverrides) {
29        self.knowledge.apply_overrides(&o.knowledge);
30        self.lifecycle.apply_overrides(&o.lifecycle);
31    }
32
33    pub fn validate(&self) -> Result<(), String> {
34        self.knowledge.validate()?;
35        self.episodic.validate()?;
36        self.procedural.validate()?;
37        self.lifecycle.validate()?;
38        self.embeddings.validate()?;
39        self.gotcha.validate()?;
40        self.admission.validate()?;
41        self.compaction.validate()?;
42        Ok(())
43    }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47#[serde(default)]
48pub struct MemoryPolicyOverrides {
49    pub knowledge: KnowledgePolicyOverrides,
50    pub lifecycle: LifecyclePolicyOverrides,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(default)]
55pub struct KnowledgePolicyOverrides {
56    pub max_facts: Option<usize>,
57    pub max_patterns: Option<usize>,
58    pub max_history: Option<usize>,
59    pub contradiction_threshold: Option<f32>,
60    pub recall_facts_limit: Option<usize>,
61    pub rooms_limit: Option<usize>,
62    pub timeline_limit: Option<usize>,
63    pub relations_limit: Option<usize>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67#[serde(default)]
68pub struct LifecyclePolicyOverrides {
69    pub decay_rate: Option<f32>,
70    pub low_confidence_threshold: Option<f32>,
71    pub stale_days: Option<i64>,
72    pub similarity_threshold: Option<f32>,
73    pub forgetting_model: Option<String>,
74    pub base_stability_days: Option<f32>,
75    pub archetype_aware_decay: Option<bool>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default)]
80pub struct KnowledgePolicy {
81    pub max_facts: usize,
82    pub max_patterns: usize,
83    pub max_history: usize,
84    pub contradiction_threshold: f32,
85    /// Maximum number of facts returned by recall operations.
86    pub recall_facts_limit: usize,
87    /// Maximum number of rooms returned by `ctx_knowledge action=rooms`.
88    pub rooms_limit: usize,
89    /// Maximum number of timeline entries returned by `ctx_knowledge action=timeline`.
90    pub timeline_limit: usize,
91    /// Maximum number of relations/edges returned by relations queries/diagrams.
92    pub relations_limit: usize,
93}
94
95impl Default for KnowledgePolicy {
96    fn default() -> Self {
97        Self {
98            max_facts: 200,
99            max_patterns: 50,
100            max_history: 100,
101            contradiction_threshold: 0.5,
102            recall_facts_limit: crate::core::budgets::KNOWLEDGE_RECALL_FACTS_LIMIT,
103            rooms_limit: crate::core::budgets::KNOWLEDGE_ROOMS_LIMIT,
104            timeline_limit: crate::core::budgets::KNOWLEDGE_TIMELINE_LIMIT,
105            relations_limit: 40,
106        }
107    }
108}
109
110impl KnowledgePolicy {
111    fn apply_env_overrides(&mut self) {
112        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_FACTS")
113            && let Ok(n) = v.parse()
114        {
115            self.max_facts = n;
116        }
117        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_PATTERNS")
118            && let Ok(n) = v.parse()
119        {
120            self.max_patterns = n;
121        }
122        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_HISTORY")
123            && let Ok(n) = v.parse()
124        {
125            self.max_history = n;
126        }
127        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_CONTRADICTION_THRESHOLD")
128            && let Ok(n) = v.parse()
129        {
130            self.contradiction_threshold = n;
131        }
132        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_RECALL_FACTS_LIMIT")
133            && let Ok(n) = v.parse()
134        {
135            self.recall_facts_limit = n;
136        }
137        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_ROOMS_LIMIT")
138            && let Ok(n) = v.parse()
139        {
140            self.rooms_limit = n;
141        }
142        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_TIMELINE_LIMIT")
143            && let Ok(n) = v.parse()
144        {
145            self.timeline_limit = n;
146        }
147        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_RELATIONS_LIMIT")
148            && let Ok(n) = v.parse()
149        {
150            self.relations_limit = n;
151        }
152    }
153
154    fn validate(&self) -> Result<(), String> {
155        if self.max_facts == 0 {
156            return Err("memory.knowledge.max_facts must be > 0".to_string());
157        }
158        if self.max_patterns == 0 {
159            return Err("memory.knowledge.max_patterns must be > 0".to_string());
160        }
161        if self.max_history == 0 {
162            return Err("memory.knowledge.max_history must be > 0".to_string());
163        }
164        if !(0.0..=1.0).contains(&self.contradiction_threshold) {
165            return Err(
166                "memory.knowledge.contradiction_threshold must be in [0.0, 1.0]".to_string(),
167            );
168        }
169        if self.recall_facts_limit == 0 {
170            return Err("memory.knowledge.recall_facts_limit must be > 0".to_string());
171        }
172        if self.rooms_limit == 0 {
173            return Err("memory.knowledge.rooms_limit must be > 0".to_string());
174        }
175        if self.timeline_limit == 0 {
176            return Err("memory.knowledge.timeline_limit must be > 0".to_string());
177        }
178        if self.relations_limit == 0 {
179            return Err("memory.knowledge.relations_limit must be > 0".to_string());
180        }
181        Ok(())
182    }
183
184    fn apply_overrides(&mut self, o: &KnowledgePolicyOverrides) {
185        if let Some(v) = o.max_facts {
186            self.max_facts = v;
187        }
188        if let Some(v) = o.max_patterns {
189            self.max_patterns = v;
190        }
191        if let Some(v) = o.max_history {
192            self.max_history = v;
193        }
194        if let Some(v) = o.contradiction_threshold {
195            self.contradiction_threshold = v;
196        }
197        if let Some(v) = o.recall_facts_limit {
198            self.recall_facts_limit = v;
199        }
200        if let Some(v) = o.rooms_limit {
201            self.rooms_limit = v;
202        }
203        if let Some(v) = o.timeline_limit {
204            self.timeline_limit = v;
205        }
206        if let Some(v) = o.relations_limit {
207            self.relations_limit = v;
208        }
209    }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(default)]
214pub struct EpisodicPolicy {
215    pub max_episodes: usize,
216    pub max_actions_per_episode: usize,
217    pub summary_max_chars: usize,
218}
219
220impl Default for EpisodicPolicy {
221    fn default() -> Self {
222        Self {
223            max_episodes: 500,
224            max_actions_per_episode: 50,
225            summary_max_chars: 200,
226        }
227    }
228}
229
230impl EpisodicPolicy {
231    fn apply_env_overrides(&mut self) {
232        if let Ok(v) = std::env::var("LEAN_CTX_EPISODIC_MAX_EPISODES")
233            && let Ok(n) = v.parse()
234        {
235            self.max_episodes = n;
236        }
237        if let Ok(v) = std::env::var("LEAN_CTX_EPISODIC_MAX_ACTIONS_PER_EPISODE")
238            && let Ok(n) = v.parse()
239        {
240            self.max_actions_per_episode = n;
241        }
242        if let Ok(v) = std::env::var("LEAN_CTX_EPISODIC_SUMMARY_MAX_CHARS")
243            && let Ok(n) = v.parse()
244        {
245            self.summary_max_chars = n;
246        }
247    }
248
249    fn validate(&self) -> Result<(), String> {
250        if self.max_episodes == 0 {
251            return Err("memory.episodic.max_episodes must be > 0".to_string());
252        }
253        if self.max_actions_per_episode == 0 {
254            return Err("memory.episodic.max_actions_per_episode must be > 0".to_string());
255        }
256        if self.summary_max_chars < 40 {
257            return Err("memory.episodic.summary_max_chars must be >= 40".to_string());
258        }
259        Ok(())
260    }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(default)]
265pub struct ProceduralPolicy {
266    pub min_repetitions: usize,
267    pub min_sequence_len: usize,
268    pub max_procedures: usize,
269    pub max_window_size: usize,
270}
271
272impl Default for ProceduralPolicy {
273    fn default() -> Self {
274        Self {
275            min_repetitions: 3,
276            min_sequence_len: 2,
277            max_procedures: 100,
278            max_window_size: 10,
279        }
280    }
281}
282
283impl ProceduralPolicy {
284    fn apply_env_overrides(&mut self) {
285        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS")
286            && let Ok(n) = v.parse()
287        {
288            self.min_repetitions = n;
289        }
290        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MIN_SEQUENCE_LEN")
291            && let Ok(n) = v.parse()
292        {
293            self.min_sequence_len = n;
294        }
295        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MAX_PROCEDURES")
296            && let Ok(n) = v.parse()
297        {
298            self.max_procedures = n;
299        }
300        if let Ok(v) = std::env::var("LEAN_CTX_PROCEDURAL_MAX_WINDOW_SIZE")
301            && let Ok(n) = v.parse()
302        {
303            self.max_window_size = n;
304        }
305    }
306
307    fn validate(&self) -> Result<(), String> {
308        if self.min_repetitions == 0 {
309            return Err("memory.procedural.min_repetitions must be > 0".to_string());
310        }
311        if self.min_sequence_len < 2 {
312            return Err("memory.procedural.min_sequence_len must be >= 2".to_string());
313        }
314        if self.max_procedures == 0 {
315            return Err("memory.procedural.max_procedures must be > 0".to_string());
316        }
317        if self.max_window_size < self.min_sequence_len {
318            return Err(
319                "memory.procedural.max_window_size must be >= min_sequence_len".to_string(),
320            );
321        }
322        Ok(())
323    }
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(default)]
328pub struct LifecyclePolicy {
329    pub decay_rate: f32,
330    pub low_confidence_threshold: f32,
331    pub stale_days: i64,
332    pub similarity_threshold: f32,
333    /// Forgetting curve (#1): `ebbinghaus` (default) or `linear` (legacy).
334    pub forgetting_model: String,
335    /// Characteristic memory stability in days for the Ebbinghaus curve.
336    pub base_stability_days: f32,
337    /// Scale Ebbinghaus stability by fact archetype so structural evidence decays
338    /// slower than inference. Default false keeps the baseline tuning unchanged.
339    pub archetype_aware_decay: bool,
340    /// Archive single-confirmation facts untouched for this many days that were
341    /// never retrieved — dead weight regardless of confidence (#962). Defaults to
342    /// a conservative 90 days (#972): genuinely cold facts are archived (and
343    /// rehydrate on recall), so a store self-curates instead of only churning at
344    /// its cap. Set `None`/`off` to disable.
345    pub prune_unretrieved_after_days: Option<i64>,
346    /// Proactive headroom on a capacity reclaim (#995): when a store reaches its
347    /// cap, settle it at `1 - reclaim_headroom_pct` (e.g. `0.25` → 75%) instead
348    /// of churning right at the cap. Lossless — the reclaimed tail is archived
349    /// and restorable.
350    pub reclaim_headroom_pct: f32,
351    /// Master switch for the proactive reclaim (#995). `false` is the documented
352    /// escape hatch: trim only the overflow, no headroom. Eviction stays lossless
353    /// either way.
354    pub reclaim_enabled: bool,
355}
356
357impl Default for LifecyclePolicy {
358    fn default() -> Self {
359        Self {
360            decay_rate: 0.01,
361            low_confidence_threshold: 0.3,
362            stale_days: 30,
363            similarity_threshold: 0.85,
364            forgetting_model: "ebbinghaus".to_string(),
365            base_stability_days: crate::core::memory_lifecycle::DEFAULT_BASE_STABILITY_DAYS,
366            archetype_aware_decay: false,
367            prune_unretrieved_after_days: Some(90),
368            reclaim_headroom_pct: crate::core::memory_lifecycle::DEFAULT_RECLAIM_HEADROOM_PCT,
369            reclaim_enabled: true,
370        }
371    }
372}
373
374impl LifecyclePolicy {
375    fn apply_env_overrides(&mut self) {
376        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_DECAY_RATE")
377            && let Ok(n) = v.parse()
378        {
379            self.decay_rate = n;
380        }
381        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_LOW_CONFIDENCE_THRESHOLD")
382            && let Ok(n) = v.parse()
383        {
384            self.low_confidence_threshold = n;
385        }
386        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_STALE_DAYS")
387            && let Ok(n) = v.parse()
388        {
389            self.stale_days = n;
390        }
391        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_SIMILARITY_THRESHOLD")
392            && let Ok(n) = v.parse()
393        {
394            self.similarity_threshold = n;
395        }
396        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_FORGETTING") {
397            self.forgetting_model = v;
398        }
399        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_BASE_STABILITY_DAYS")
400            && let Ok(n) = v.parse()
401        {
402            self.base_stability_days = n;
403        }
404        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_ARCHETYPE_AWARE") {
405            self.archetype_aware_decay = v == "1" || v.eq_ignore_ascii_case("true");
406        }
407        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_PRUNE_UNRETRIEVED_DAYS") {
408            self.prune_unretrieved_after_days = match v.trim().to_lowercase().as_str() {
409                "" | "off" | "none" | "0" => None,
410                s => s.parse::<i64>().ok().filter(|&n| n > 0),
411            };
412        }
413        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_RECLAIM_HEADROOM_PCT")
414            && let Ok(n) = v.parse()
415        {
416            self.reclaim_headroom_pct = n;
417        }
418        if let Ok(v) = std::env::var("LEAN_CTX_LIFECYCLE_RECLAIM_ENABLED") {
419            self.reclaim_enabled = !(v == "0" || v.eq_ignore_ascii_case("false"));
420        }
421    }
422
423    fn validate(&self) -> Result<(), String> {
424        if !(0.0..=1.0).contains(&self.decay_rate) {
425            return Err("memory.lifecycle.decay_rate must be in [0.0, 1.0]".to_string());
426        }
427        if !(0.0..=1.0).contains(&self.low_confidence_threshold) {
428            return Err(
429                "memory.lifecycle.low_confidence_threshold must be in [0.0, 1.0]".to_string(),
430            );
431        }
432        if self.stale_days < 0 {
433            return Err("memory.lifecycle.stale_days must be >= 0".to_string());
434        }
435        if !(0.0..=1.0).contains(&self.similarity_threshold) {
436            return Err("memory.lifecycle.similarity_threshold must be in [0.0, 1.0]".to_string());
437        }
438        if self.base_stability_days <= 0.0 {
439            return Err("memory.lifecycle.base_stability_days must be > 0".to_string());
440        }
441        if !(0.0..=0.95).contains(&self.reclaim_headroom_pct) {
442            return Err("memory.lifecycle.reclaim_headroom_pct must be in [0.0, 0.95]".to_string());
443        }
444        Ok(())
445    }
446
447    fn apply_overrides(&mut self, o: &LifecyclePolicyOverrides) {
448        if let Some(v) = o.decay_rate {
449            self.decay_rate = v;
450        }
451        if let Some(v) = o.low_confidence_threshold {
452            self.low_confidence_threshold = v;
453        }
454        if let Some(v) = o.stale_days {
455            self.stale_days = v;
456        }
457        if let Some(v) = o.similarity_threshold {
458            self.similarity_threshold = v;
459        }
460        if let Some(ref v) = o.forgetting_model {
461            self.forgetting_model.clone_from(v);
462        }
463        if let Some(v) = o.base_stability_days {
464            self.base_stability_days = v;
465        }
466        if let Some(v) = o.archetype_aware_decay {
467            self.archetype_aware_decay = v;
468        }
469    }
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473#[serde(default)]
474pub struct EmbeddingsPolicy {
475    pub max_facts: usize,
476}
477
478impl Default for EmbeddingsPolicy {
479    fn default() -> Self {
480        Self { max_facts: 2000 }
481    }
482}
483
484impl EmbeddingsPolicy {
485    fn apply_env_overrides(&mut self) {
486        if let Ok(v) = std::env::var("LEAN_CTX_KNOWLEDGE_EMBEDDINGS_MAX_FACTS")
487            && let Ok(n) = v.parse()
488        {
489            self.max_facts = n;
490        }
491    }
492
493    fn validate(&self) -> Result<(), String> {
494        if self.max_facts == 0 {
495            return Err("memory.embeddings.max_facts must be > 0".to_string());
496        }
497        Ok(())
498    }
499}
500
501use std::collections::HashMap;
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(default)]
505pub struct GotchaPolicy {
506    pub max_gotchas_per_project: usize,
507    pub retrieval_budget_per_room: usize,
508    pub default_decay_rate: f32,
509    pub category_decay_overrides: HashMap<String, f32>,
510    pub auto_expire_days: Option<i64>,
511}
512
513impl Default for GotchaPolicy {
514    fn default() -> Self {
515        Self {
516            max_gotchas_per_project: 100,
517            retrieval_budget_per_room: 10,
518            default_decay_rate: 0.03,
519            category_decay_overrides: HashMap::new(),
520            auto_expire_days: None,
521        }
522    }
523}
524
525impl GotchaPolicy {
526    fn apply_env_overrides(&mut self) {
527        if let Ok(v) = std::env::var("LEAN_CTX_GOTCHA_MAX_PER_PROJECT")
528            && let Ok(n) = v.parse()
529        {
530            self.max_gotchas_per_project = n;
531        }
532        if let Ok(v) = std::env::var("LEAN_CTX_GOTCHA_RETRIEVAL_BUDGET")
533            && let Ok(n) = v.parse()
534        {
535            self.retrieval_budget_per_room = n;
536        }
537    }
538
539    fn validate(&self) -> Result<(), String> {
540        if self.max_gotchas_per_project == 0 {
541            return Err("memory.gotcha.max_gotchas_per_project must be > 0".to_string());
542        }
543        if self.retrieval_budget_per_room == 0 {
544            return Err("memory.gotcha.retrieval_budget_per_room must be > 0".to_string());
545        }
546        if !(0.0..=1.0).contains(&self.default_decay_rate) {
547            return Err("memory.gotcha.default_decay_rate must be 0.0-1.0".to_string());
548        }
549        Ok(())
550    }
551
552    pub fn effective_decay_rate(&self, category: &str) -> f32 {
553        self.category_decay_overrides
554            .get(category)
555            .copied()
556            .unwrap_or(self.default_decay_rate)
557    }
558}
559
560/// Write-time admission control for the knowledge store (#970). The cap +
561/// importance-eviction is a backstop *after* the fact is written; admission is
562/// the boundary *before* it, so a capped store fills with signal, not noise.
563/// Applied only to direct `ctx_knowledge remember` (the agent-facing path);
564/// internal restorers (archive rehydrate, cognition auto-promotion) bypass it.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566#[serde(default)]
567pub struct AdmissionPolicy {
568    /// Master switch for write-time admission. When off, every `remember`
569    /// inserts as before (legacy behavior).
570    pub enabled: bool,
571    /// A new fact whose value is at least this similar (word-Jaccard, 0.0–1.0) to
572    /// an existing *current* fact in the **same category** under a different key
573    /// is merged into it (a confirmation bump) instead of inserted as a new row.
574    /// High by default so only genuine near-duplicates collapse; `0.0` disables
575    /// auto-merge.
576    pub auto_merge_similarity: f32,
577    /// Facts whose content salience (`crate::core::memory_salience::text_salience`)
578    /// is below this floor are not admitted as normal facts. `0` (default)
579    /// disables the floor — the lossless choice; raise it to curate a noisy store.
580    pub min_salience: u32,
581}
582
583impl Default for AdmissionPolicy {
584    fn default() -> Self {
585        Self {
586            enabled: true,
587            auto_merge_similarity: 0.9,
588            min_salience: 0,
589        }
590    }
591}
592
593impl AdmissionPolicy {
594    fn apply_env_overrides(&mut self) {
595        if let Ok(v) = std::env::var("LEAN_CTX_ADMISSION_ENABLED") {
596            self.enabled = !(v == "0" || v.eq_ignore_ascii_case("false"));
597        }
598        if let Ok(v) = std::env::var("LEAN_CTX_ADMISSION_MERGE_SIMILARITY")
599            && let Ok(n) = v.parse()
600        {
601            self.auto_merge_similarity = n;
602        }
603        if let Ok(v) = std::env::var("LEAN_CTX_ADMISSION_MIN_SALIENCE")
604            && let Ok(n) = v.parse()
605        {
606            self.min_salience = n;
607        }
608    }
609
610    fn validate(&self) -> Result<(), String> {
611        if !(0.0..=1.0).contains(&self.auto_merge_similarity) {
612            return Err("memory.admission.auto_merge_similarity must be in [0.0, 1.0]".to_string());
613        }
614        Ok(())
615    }
616}
617
618/// Cluster compaction (#971): the background cognition loop collapses piles of
619/// low-value, mutually-similar facts into a single recoverable digest, so a busy
620/// store's live fact count actually *drops* instead of churning at its cap. It is
621/// strictly guarded — only faded (`< max_confidence`), barely-confirmed
622/// (`<= max_confirmations`), never-frequently/recently-retrieved facts in a
623/// cluster of at least `min_cluster` qualify — and lossless, since the originals
624/// are archived and rehydrate on recall.
625#[derive(Debug, Clone, Serialize, Deserialize)]
626#[serde(default)]
627pub struct CompactionPolicy {
628    /// Master switch for cluster compaction in the cognition loop.
629    pub enabled: bool,
630    /// Minimum number of facts in a same-category cluster before it is collapsed
631    /// into a digest. Must be `>= 2`.
632    pub min_cluster: usize,
633    /// Average word-Jaccard similarity (0.0–1.0) a fact needs to join a cluster.
634    pub similarity: f32,
635    /// Importance ceiling: only facts *below* this confidence are eligible, so a
636    /// high-confidence fact is never compacted.
637    pub max_confidence: f32,
638    /// Only facts confirmed at most this many times are eligible — a
639    /// repeatedly-confirmed fact is structurally important and always kept.
640    pub max_confirmations: u32,
641}
642
643impl Default for CompactionPolicy {
644    fn default() -> Self {
645        Self {
646            enabled: true,
647            min_cluster: 4,
648            similarity: 0.5,
649            max_confidence: 0.5,
650            max_confirmations: 1,
651        }
652    }
653}
654
655impl CompactionPolicy {
656    fn apply_env_overrides(&mut self) {
657        if let Ok(v) = std::env::var("LEAN_CTX_COMPACTION_ENABLED") {
658            self.enabled = !(v == "0" || v.eq_ignore_ascii_case("false"));
659        }
660        if let Ok(v) = std::env::var("LEAN_CTX_COMPACTION_MIN_CLUSTER")
661            && let Ok(n) = v.parse()
662        {
663            self.min_cluster = n;
664        }
665        if let Ok(v) = std::env::var("LEAN_CTX_COMPACTION_SIMILARITY")
666            && let Ok(n) = v.parse()
667        {
668            self.similarity = n;
669        }
670        if let Ok(v) = std::env::var("LEAN_CTX_COMPACTION_MAX_CONFIDENCE")
671            && let Ok(n) = v.parse()
672        {
673            self.max_confidence = n;
674        }
675        if let Ok(v) = std::env::var("LEAN_CTX_COMPACTION_MAX_CONFIRMATIONS")
676            && let Ok(n) = v.parse()
677        {
678            self.max_confirmations = n;
679        }
680    }
681
682    fn validate(&self) -> Result<(), String> {
683        if self.min_cluster < 2 {
684            return Err("memory.compaction.min_cluster must be >= 2".to_string());
685        }
686        if !(0.0..=1.0).contains(&self.similarity) {
687            return Err("memory.compaction.similarity must be in [0.0, 1.0]".to_string());
688        }
689        if !(0.0..=1.0).contains(&self.max_confidence) {
690            return Err("memory.compaction.max_confidence must be in [0.0, 1.0]".to_string());
691        }
692        Ok(())
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    fn restore_env(key: &str, prev: Option<String>) {
701        match prev {
702            Some(v) => crate::test_env::set_var(key, v),
703            None => crate::test_env::remove_var(key),
704        }
705    }
706
707    #[test]
708    fn default_policy_is_valid() {
709        let p = MemoryPolicy::default();
710        p.validate().expect("default policy must be valid");
711    }
712
713    #[test]
714    fn memory_discipline_defaults_are_premium() {
715        // #970/#971/#972: self-curation is on by default, but lossless.
716        let p = MemoryPolicy::default();
717        assert!(p.admission.enabled, "admission on by default");
718        assert_eq!(p.admission.min_salience, 0, "salience floor off (lossless)");
719        assert!(p.compaction.enabled, "cluster compaction on by default");
720        assert!(p.compaction.min_cluster >= 2);
721        assert_eq!(
722            p.lifecycle.prune_unretrieved_after_days,
723            Some(90),
724            "conservative recoverable prune default"
725        );
726    }
727
728    #[test]
729    fn admission_and_compaction_env_overrides_apply() {
730        let _lock = crate::core::data_dir::test_env_lock();
731
732        let prev = [
733            (
734                "LEAN_CTX_ADMISSION_ENABLED",
735                std::env::var("LEAN_CTX_ADMISSION_ENABLED").ok(),
736            ),
737            (
738                "LEAN_CTX_ADMISSION_MIN_SALIENCE",
739                std::env::var("LEAN_CTX_ADMISSION_MIN_SALIENCE").ok(),
740            ),
741            (
742                "LEAN_CTX_COMPACTION_MIN_CLUSTER",
743                std::env::var("LEAN_CTX_COMPACTION_MIN_CLUSTER").ok(),
744            ),
745        ];
746        crate::test_env::set_var("LEAN_CTX_ADMISSION_ENABLED", "0");
747        crate::test_env::set_var("LEAN_CTX_ADMISSION_MIN_SALIENCE", "42");
748        crate::test_env::set_var("LEAN_CTX_COMPACTION_MIN_CLUSTER", "7");
749
750        let mut p = MemoryPolicy::default();
751        p.apply_env_overrides();
752
753        assert!(!p.admission.enabled);
754        assert_eq!(p.admission.min_salience, 42);
755        assert_eq!(p.compaction.min_cluster, 7);
756
757        for (key, val) in prev {
758            restore_env(key, val);
759        }
760    }
761
762    #[test]
763    fn validate_rejects_invalid_compaction() {
764        let mut p = MemoryPolicy::default();
765        p.compaction.min_cluster = 1;
766        assert!(p.validate().is_err());
767
768        let mut p = MemoryPolicy::default();
769        p.admission.auto_merge_similarity = 1.5;
770        assert!(p.validate().is_err());
771    }
772
773    #[test]
774    fn env_overrides_apply() {
775        let _lock = crate::core::data_dir::test_env_lock();
776
777        let prev_facts = std::env::var("LEAN_CTX_KNOWLEDGE_MAX_FACTS").ok();
778        let prev_stale = std::env::var("LEAN_CTX_LIFECYCLE_STALE_DAYS").ok();
779        let prev_rep = std::env::var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS").ok();
780
781        crate::test_env::set_var("LEAN_CTX_KNOWLEDGE_MAX_FACTS", "123");
782        crate::test_env::set_var("LEAN_CTX_LIFECYCLE_STALE_DAYS", "7");
783        crate::test_env::set_var("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS", "4");
784
785        let mut p = MemoryPolicy::default();
786        p.apply_env_overrides();
787
788        assert_eq!(p.knowledge.max_facts, 123);
789        assert_eq!(p.lifecycle.stale_days, 7);
790        assert_eq!(p.procedural.min_repetitions, 4);
791
792        restore_env("LEAN_CTX_KNOWLEDGE_MAX_FACTS", prev_facts);
793        restore_env("LEAN_CTX_LIFECYCLE_STALE_DAYS", prev_stale);
794        restore_env("LEAN_CTX_PROCEDURAL_MIN_REPETITIONS", prev_rep);
795    }
796
797    #[test]
798    fn validate_rejects_invalid_values() {
799        let mut p = MemoryPolicy::default();
800        p.knowledge.max_facts = 0;
801        assert!(p.validate().is_err());
802
803        let mut p = MemoryPolicy::default();
804        p.lifecycle.decay_rate = 2.0;
805        assert!(p.validate().is_err());
806
807        let mut p = MemoryPolicy::default();
808        p.procedural.min_sequence_len = 1;
809        assert!(p.validate().is_err());
810    }
811}