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 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 #[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 pub recall_facts_limit: usize,
88 pub rooms_limit: usize,
90 pub timeline_limit: usize,
92 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 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 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#[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#[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 pub forgetting_model: String,
259 pub base_stability_days: f32,
261 pub archetype_aware_decay: bool,
264 pub prune_unretrieved_after_days: Option<i64>,
270 pub reclaim_headroom_pct: f32,
275 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#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(default)]
434pub struct GotchaPolicy {
435 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#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(default)]
472pub struct AdmissionPolicy {
473 pub enabled: bool,
476 pub auto_merge_similarity: f32,
482 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#[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 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 assert_eq!(p.knowledge.max_facts, None);
642 assert_eq!(p.lifecycle.stale_days, 7);
643 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 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}