1use chrono::{DateTime, Utc};
16use rusqlite::{params, Connection};
17use serde::{Deserialize, Serialize};
18
19use crate::error::Result;
20use crate::types::{LifecycleState, Memory, MemoryId};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SalienceConfig {
25 pub recency_weight: f32,
27 pub frequency_weight: f32,
29 pub importance_weight: f32,
31 pub feedback_weight: f32,
33 pub recency_half_life_days: f32,
35 pub frequency_log_base: f32,
37 pub frequency_max_count: i32,
39 pub min_salience: f32,
41 pub stale_threshold_days: i64,
43 pub archive_threshold_days: i64,
45}
46
47impl Default for SalienceConfig {
48 fn default() -> Self {
49 Self {
50 recency_weight: 0.30,
51 frequency_weight: 0.20,
52 importance_weight: 0.30,
53 feedback_weight: 0.20,
54 recency_half_life_days: 14.0, frequency_log_base: 2.0,
56 frequency_max_count: 100,
57 min_salience: 0.05,
58 stale_threshold_days: 30,
59 archive_threshold_days: 90,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SalienceScore {
67 pub score: f32,
69 pub recency: f32,
71 pub frequency: f32,
73 pub importance: f32,
75 pub feedback: f32,
77 pub calculated_at: DateTime<Utc>,
79 pub suggested_state: LifecycleState,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DecayResult {
86 pub processed: i64,
88 pub marked_stale: i64,
90 pub suggested_archive: i64,
92 pub history_records: i64,
94 pub duration_ms: i64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SalienceStats {
101 pub total_memories: i64,
103 pub mean_salience: f32,
105 pub median_salience: f32,
107 pub std_dev: f32,
109 pub percentiles: SaliencePercentiles,
111 pub by_state: StateDistribution,
113 pub low_salience_count: i64,
115 pub high_salience_count: i64,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct SaliencePercentiles {
122 pub p10: f32,
123 pub p25: f32,
124 pub p50: f32,
125 pub p75: f32,
126 pub p90: f32,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct StateDistribution {
132 pub active: i64,
133 pub stale: i64,
134 pub archived: i64,
135}
136
137#[derive(Debug, Clone)]
139pub struct ScoredMemory {
140 pub memory: Memory,
141 pub salience: SalienceScore,
142}
143
144impl PartialEq for ScoredMemory {
145 fn eq(&self, other: &Self) -> bool {
146 self.memory.id == other.memory.id
147 }
148}
149
150impl Eq for ScoredMemory {}
151
152impl PartialOrd for ScoredMemory {
153 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
154 Some(self.cmp(other))
155 }
156}
157
158impl Ord for ScoredMemory {
159 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
160 self.salience
162 .score
163 .partial_cmp(&other.salience.score)
164 .unwrap_or(std::cmp::Ordering::Equal)
165 .reverse() }
167}
168
169pub struct SalienceCalculator {
171 config: SalienceConfig,
172}
173
174impl Default for SalienceCalculator {
175 fn default() -> Self {
176 Self::new(SalienceConfig::default())
177 }
178}
179
180impl SalienceCalculator {
181 pub fn new(config: SalienceConfig) -> Self {
183 Self { config }
184 }
185
186 pub fn calculate(&self, memory: &Memory, feedback_signal: f32) -> SalienceScore {
188 let now = Utc::now();
189
190 let recency = self.calculate_recency(memory, now);
192
193 let frequency = self.calculate_frequency(memory);
195
196 let importance = memory.importance;
198
199 let feedback = feedback_signal.clamp(0.0, 1.0);
201
202 let score = (recency * self.config.recency_weight
204 + frequency * self.config.frequency_weight
205 + importance * self.config.importance_weight
206 + feedback * self.config.feedback_weight)
207 .max(self.config.min_salience)
208 .min(1.0);
209
210 let suggested_state = self.suggest_lifecycle_state(memory, score, now);
212
213 SalienceScore {
214 score,
215 recency,
216 frequency,
217 importance,
218 feedback,
219 calculated_at: now,
220 suggested_state,
221 }
222 }
223
224 fn calculate_recency(&self, memory: &Memory, now: DateTime<Utc>) -> f32 {
226 let last_access = memory.last_accessed_at.unwrap_or(memory.created_at);
227 let days_since_access = (now - last_access).num_hours() as f32 / 24.0;
228
229 let decay = 0.5_f32.powf(days_since_access / self.config.recency_half_life_days);
231
232 decay.clamp(0.0, 1.0)
233 }
234
235 fn calculate_frequency(&self, memory: &Memory) -> f32 {
237 let count = memory.access_count.max(0) as f32;
238 let max_count = self.config.frequency_max_count as f32;
239
240 if count <= 0.0 {
241 return 0.1; }
243
244 let log_base = self.config.frequency_log_base;
247 let log_count = (count + 1.0).log(log_base);
248 let log_max = (max_count + 1.0).log(log_base);
249
250 (log_count / log_max).min(1.0)
251 }
252
253 fn suggest_lifecycle_state(
255 &self,
256 memory: &Memory,
257 score: f32,
258 now: DateTime<Utc>,
259 ) -> LifecycleState {
260 let last_access = memory.last_accessed_at.unwrap_or(memory.created_at);
261 let days_inactive = (now - last_access).num_days();
262
263 if memory.lifecycle_state == LifecycleState::Archived {
265 return LifecycleState::Archived;
266 }
267
268 if score < 0.2 && days_inactive >= self.config.archive_threshold_days {
270 return LifecycleState::Archived;
271 }
272
273 if score < 0.4 || days_inactive >= self.config.stale_threshold_days {
275 return LifecycleState::Stale;
276 }
277
278 LifecycleState::Active
279 }
280
281 pub fn calculate_batch(
283 &self,
284 memories: &[Memory],
285 feedback_signals: Option<&HashMap<MemoryId, f32>>,
286 ) -> Vec<ScoredMemory> {
287 let empty = HashMap::new();
288 let signals = feedback_signals.unwrap_or(&empty);
289
290 memories
291 .iter()
292 .map(|m| {
293 let feedback = signals.get(&m.id).copied().unwrap_or(0.5);
294 ScoredMemory {
295 salience: self.calculate(m, feedback),
296 memory: m.clone(),
297 }
298 })
299 .collect()
300 }
301
302 pub fn priority_queue(&self, memories: &[Memory]) -> Vec<ScoredMemory> {
304 let mut scored = self.calculate_batch(memories, None);
305 scored.sort_by(|a, b| {
306 b.salience
307 .score
308 .partial_cmp(&a.salience.score)
309 .unwrap_or(std::cmp::Ordering::Equal)
310 });
311 scored
312 }
313}
314
315use std::collections::HashMap;
316
317pub fn run_salience_decay(
319 conn: &Connection,
320 config: &SalienceConfig,
321 record_history: bool,
322) -> Result<DecayResult> {
323 run_salience_decay_in_workspace(conn, config, record_history, None)
324}
325
326pub fn run_salience_decay_in_workspace(
327 conn: &Connection,
328 config: &SalienceConfig,
329 record_history: bool,
330 workspace: Option<&str>,
331) -> Result<DecayResult> {
332 let start = std::time::Instant::now();
333 let now = Utc::now();
334 let now_str = now.to_rfc3339();
335 let _calculator = SalienceCalculator::new(config.clone());
336
337 let memories: Vec<(MemoryId, f32, i32, String, String, Option<String>, String)> =
339 if let Some(workspace) = workspace {
340 let mut stmt = conn.prepare(
341 "SELECT id, content, memory_type, importance, access_count,
342 created_at, updated_at, last_accessed_at, lifecycle_state,
343 workspace, tier
344 FROM memories
345 WHERE lifecycle_state != 'archived'
346 AND (expires_at IS NULL OR expires_at > ?)
347 AND workspace = ?",
348 )?;
349
350 let rows = stmt.query_map(params![now_str, workspace], |row| {
351 Ok((
352 row.get::<_, MemoryId>(0)?,
353 row.get::<_, f32>(3)?, row.get::<_, i32>(4)?, row.get::<_, String>(5)?, row.get::<_, String>(6)?, row.get::<_, Option<String>>(7)?, row.get::<_, String>(8)?, ))
360 })?;
361
362 rows.collect::<std::result::Result<Vec<_>, _>>()?
363 } else {
364 let mut stmt = conn.prepare(
365 "SELECT id, content, memory_type, importance, access_count,
366 created_at, updated_at, last_accessed_at, lifecycle_state,
367 workspace, tier
368 FROM memories
369 WHERE lifecycle_state != 'archived'
370 AND (expires_at IS NULL OR expires_at > ?)",
371 )?;
372
373 let rows = stmt.query_map(params![now_str], |row| {
374 Ok((
375 row.get::<_, MemoryId>(0)?,
376 row.get::<_, f32>(3)?, row.get::<_, i32>(4)?, row.get::<_, String>(5)?, row.get::<_, String>(6)?, row.get::<_, Option<String>>(7)?, row.get::<_, String>(8)?, ))
383 })?;
384
385 rows.collect::<std::result::Result<Vec<_>, _>>()?
386 };
387
388 let mut processed = 0i64;
389 let mut marked_stale = 0i64;
390 let mut suggested_archive = 0i64;
391 let mut history_records = 0i64;
392
393 for (
394 id,
395 importance,
396 access_count,
397 created_at_str,
398 _updated_at_str,
399 last_accessed_str,
400 current_state,
401 ) in memories
402 {
403 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
405 .map(|dt| dt.with_timezone(&Utc))
406 .unwrap_or(now);
407
408 let last_accessed_at = last_accessed_str.and_then(|s| {
409 DateTime::parse_from_rfc3339(&s)
410 .map(|dt| dt.with_timezone(&Utc))
411 .ok()
412 });
413
414 let last_access = last_accessed_at.unwrap_or(created_at);
416 let days_since_access = (now - last_access).num_hours() as f32 / 24.0;
417 let recency = 0.5_f32.powf(days_since_access / config.recency_half_life_days);
418
419 let count = access_count.max(0) as f32;
421 let frequency = if count <= 0.0 {
422 0.1
423 } else {
424 let log_count = (count + 1.0).log(config.frequency_log_base);
425 let log_max = (config.frequency_max_count as f32 + 1.0).log(config.frequency_log_base);
426 (log_count / log_max).min(1.0)
427 };
428
429 let score = (recency * config.recency_weight
431 + frequency * config.frequency_weight
432 + importance * config.importance_weight
433 + 0.5 * config.feedback_weight)
434 .max(config.min_salience)
435 .min(1.0);
436
437 let days_inactive = (now - last_access).num_days();
439 let new_state = if score < 0.2 && days_inactive >= config.archive_threshold_days {
440 "archived"
441 } else if score < 0.4 || days_inactive >= config.stale_threshold_days {
442 "stale"
443 } else {
444 "active"
445 };
446
447 if new_state != current_state {
449 conn.execute(
450 "UPDATE memories SET lifecycle_state = ?, updated_at = ? WHERE id = ?",
451 params![new_state, now_str, id],
452 )?;
453
454 if new_state == "stale" {
455 marked_stale += 1;
456 } else if new_state == "archived" {
457 suggested_archive += 1;
458 }
459 }
460
461 if record_history {
463 conn.execute(
464 "INSERT INTO salience_history (memory_id, salience_score, recency_score,
465 frequency_score, importance_score, feedback_score, recorded_at)
466 VALUES (?, ?, ?, ?, ?, ?, ?)",
467 params![id, score, recency, frequency, importance, 0.5, now_str],
468 )?;
469 history_records += 1;
470 }
471
472 processed += 1;
473 }
474
475 let duration_ms = start.elapsed().as_millis() as i64;
476
477 Ok(DecayResult {
478 processed,
479 marked_stale,
480 suggested_archive,
481 history_records,
482 duration_ms,
483 })
484}
485
486pub fn get_memory_salience(
488 conn: &Connection,
489 memory_id: MemoryId,
490 config: &SalienceConfig,
491) -> Result<Option<SalienceScore>> {
492 get_memory_salience_with_feedback(conn, memory_id, config, 0.5)
493}
494
495pub fn get_memory_salience_with_feedback(
496 conn: &Connection,
497 memory_id: MemoryId,
498 config: &SalienceConfig,
499 feedback_signal: f32,
500) -> Result<Option<SalienceScore>> {
501 let row = conn.query_row(
502 "SELECT importance, access_count, created_at, updated_at,
503 last_accessed_at, lifecycle_state
504 FROM memories WHERE id = ?",
505 params![memory_id],
506 |row| {
507 Ok((
508 row.get::<_, f32>(0)?,
509 row.get::<_, i32>(1)?,
510 row.get::<_, String>(2)?,
511 row.get::<_, String>(3)?,
512 row.get::<_, Option<String>>(4)?,
513 row.get::<_, String>(5)?,
514 ))
515 },
516 );
517
518 match row {
519 Ok((
520 importance,
521 access_count,
522 created_at_str,
523 _updated_at_str,
524 last_accessed_str,
525 lifecycle_str,
526 )) => {
527 let now = Utc::now();
528 let calculator = SalienceCalculator::new(config.clone());
529
530 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
531 .map(|dt| dt.with_timezone(&Utc))
532 .unwrap_or(now);
533
534 let last_accessed_at = last_accessed_str.and_then(|s| {
535 DateTime::parse_from_rfc3339(&s)
536 .map(|dt| dt.with_timezone(&Utc))
537 .ok()
538 });
539
540 let lifecycle_state = lifecycle_str.parse().unwrap_or(LifecycleState::Active);
541
542 let memory = Memory {
544 id: memory_id,
545 content: String::new(),
546 memory_type: crate::types::MemoryType::Note,
547 tags: vec![],
548 metadata: HashMap::new(),
549 importance,
550 access_count,
551 created_at,
552 updated_at: now,
553 last_accessed_at,
554 owner_id: None,
555 visibility: crate::types::Visibility::Private,
556 scope: crate::types::MemoryScope::Global,
557 workspace: "default".to_string(),
558 tier: crate::types::MemoryTier::Permanent,
559 version: 1,
560 has_embedding: false,
561 expires_at: None,
562 content_hash: None,
563 event_time: None,
564 event_duration_seconds: None,
565 trigger_pattern: None,
566 procedure_success_count: 0,
567 procedure_failure_count: 0,
568 summary_of_id: None,
569 lifecycle_state,
570 };
571
572 Ok(Some(calculator.calculate(&memory, feedback_signal)))
573 }
574 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
575 Err(e) => Err(e.into()),
576 }
577}
578
579pub fn set_memory_importance(
581 conn: &Connection,
582 memory_id: MemoryId,
583 importance: f32,
584) -> Result<()> {
585 let importance = importance.clamp(0.0, 1.0);
586 let now = Utc::now().to_rfc3339();
587
588 conn.execute(
589 "UPDATE memories SET importance = ?, updated_at = ? WHERE id = ?",
590 params![importance, now, memory_id],
591 )?;
592
593 Ok(())
594}
595
596pub fn boost_memory_salience(
598 conn: &Connection,
599 memory_id: MemoryId,
600 boost_amount: f32,
601) -> Result<f32> {
602 let now = Utc::now().to_rfc3339();
603 let boost = boost_amount.clamp(0.0, 0.5); conn.execute(
607 "UPDATE memories SET importance = MIN(1.0, importance + ?), updated_at = ? WHERE id = ?",
608 params![boost, now, memory_id],
609 )?;
610
611 let new_importance: f32 = conn.query_row(
612 "SELECT importance FROM memories WHERE id = ?",
613 params![memory_id],
614 |row| row.get(0),
615 )?;
616
617 Ok(new_importance)
618}
619
620pub fn demote_memory_salience(
622 conn: &Connection,
623 memory_id: MemoryId,
624 demote_amount: f32,
625) -> Result<f32> {
626 let now = Utc::now().to_rfc3339();
627 let demote = demote_amount.clamp(0.0, 0.5); conn.execute(
630 "UPDATE memories SET importance = MAX(0.0, importance - ?), updated_at = ? WHERE id = ?",
631 params![demote, now, memory_id],
632 )?;
633
634 let new_importance: f32 = conn.query_row(
635 "SELECT importance FROM memories WHERE id = ?",
636 params![memory_id],
637 |row| row.get(0),
638 )?;
639
640 Ok(new_importance)
641}
642
643pub fn get_salience_stats(conn: &Connection, config: &SalienceConfig) -> Result<SalienceStats> {
645 get_salience_stats_in_workspace(conn, config, None)
646}
647
648pub fn get_salience_stats_in_workspace(
649 conn: &Connection,
650 config: &SalienceConfig,
651 workspace: Option<&str>,
652) -> Result<SalienceStats> {
653 let now = Utc::now();
654 let now_str = now.to_rfc3339();
655
656 let mut scores: Vec<f32> = Vec::new();
658 let mut active_count = 0i64;
659 let mut stale_count = 0i64;
660 let mut archived_count = 0i64;
661
662 let rows = if let Some(workspace) = workspace {
663 let mut stmt = conn.prepare(
664 "SELECT importance, access_count, created_at, last_accessed_at, lifecycle_state
665 FROM memories
666 WHERE (expires_at IS NULL OR expires_at > ?)
667 AND workspace = ?",
668 )?;
669 let rows = stmt.query_map(params![now_str, workspace], |row| {
670 Ok((
671 row.get::<_, f32>(0)?,
672 row.get::<_, i32>(1)?,
673 row.get::<_, String>(2)?,
674 row.get::<_, Option<String>>(3)?,
675 row.get::<_, String>(4)?,
676 ))
677 })?;
678 rows.collect::<std::result::Result<Vec<_>, _>>()?
679 } else {
680 let mut stmt = conn.prepare(
681 "SELECT importance, access_count, created_at, last_accessed_at, lifecycle_state
682 FROM memories
683 WHERE (expires_at IS NULL OR expires_at > ?)",
684 )?;
685 let rows = stmt.query_map(params![now_str], |row| {
686 Ok((
687 row.get::<_, f32>(0)?,
688 row.get::<_, i32>(1)?,
689 row.get::<_, String>(2)?,
690 row.get::<_, Option<String>>(3)?,
691 row.get::<_, String>(4)?,
692 ))
693 })?;
694 rows.collect::<std::result::Result<Vec<_>, _>>()?
695 };
696
697 for (importance, access_count, created_at_str, last_accessed_str, state_str) in rows {
698 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
700 .map(|dt| dt.with_timezone(&Utc))
701 .unwrap_or(now);
702
703 let last_access = last_accessed_str
704 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
705 .map(|dt| dt.with_timezone(&Utc))
706 .unwrap_or(created_at);
707
708 let days_since_access = (now - last_access).num_hours() as f32 / 24.0;
709 let recency = 0.5_f32.powf(days_since_access / config.recency_half_life_days);
710
711 let count = access_count.max(0) as f32;
712 let frequency = if count <= 0.0 {
713 0.1
714 } else {
715 let log_count = (count + 1.0).log(config.frequency_log_base);
716 let log_max = (config.frequency_max_count as f32 + 1.0).log(config.frequency_log_base);
717 (log_count / log_max).min(1.0)
718 };
719
720 let score = (recency * config.recency_weight
721 + frequency * config.frequency_weight
722 + importance * config.importance_weight
723 + 0.5 * config.feedback_weight)
724 .max(config.min_salience)
725 .min(1.0);
726
727 scores.push(score);
728
729 match state_str.as_str() {
731 "active" => active_count += 1,
732 "stale" => stale_count += 1,
733 "archived" => archived_count += 1,
734 _ => active_count += 1,
735 }
736 }
737
738 if scores.is_empty() {
739 return Ok(SalienceStats {
740 total_memories: 0,
741 mean_salience: 0.0,
742 median_salience: 0.0,
743 std_dev: 0.0,
744 percentiles: SaliencePercentiles {
745 p10: 0.0,
746 p25: 0.0,
747 p50: 0.0,
748 p75: 0.0,
749 p90: 0.0,
750 },
751 by_state: StateDistribution {
752 active: 0,
753 stale: 0,
754 archived: 0,
755 },
756 low_salience_count: 0,
757 high_salience_count: 0,
758 });
759 }
760
761 scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
763
764 let total = scores.len();
765 let mean: f32 = scores.iter().sum::<f32>() / total as f32;
766 let median = scores[total / 2];
767
768 let variance: f32 = scores.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / total as f32;
770 let std_dev = variance.sqrt();
771
772 let p10 = scores[(total as f32 * 0.10) as usize];
774 let p25 = scores[(total as f32 * 0.25) as usize];
775 let p50 = scores[(total as f32 * 0.50) as usize];
776 let p75 = scores[((total as f32 * 0.75) as usize).min(total - 1)];
777 let p90 = scores[((total as f32 * 0.90) as usize).min(total - 1)];
778
779 let low_salience_count = scores.iter().filter(|&&s| s < 0.3).count() as i64;
781 let high_salience_count = scores.iter().filter(|&&s| s > 0.7).count() as i64;
782
783 Ok(SalienceStats {
784 total_memories: total as i64,
785 mean_salience: mean,
786 median_salience: median,
787 std_dev,
788 percentiles: SaliencePercentiles {
789 p10,
790 p25,
791 p50,
792 p75,
793 p90,
794 },
795 by_state: StateDistribution {
796 active: active_count,
797 stale: stale_count,
798 archived: archived_count,
799 },
800 low_salience_count,
801 high_salience_count,
802 })
803}
804
805pub fn get_salience_history(
807 conn: &Connection,
808 memory_id: MemoryId,
809 limit: i64,
810) -> Result<Vec<SalienceHistoryEntry>> {
811 let mut stmt = conn.prepare(
812 "SELECT salience_score, recency_score, frequency_score,
813 importance_score, feedback_score, recorded_at
814 FROM salience_history
815 WHERE memory_id = ?
816 ORDER BY recorded_at DESC
817 LIMIT ?",
818 )?;
819
820 let entries = stmt
821 .query_map(params![memory_id, limit], |row| {
822 Ok(SalienceHistoryEntry {
823 salience_score: row.get(0)?,
824 recency_score: row.get(1)?,
825 frequency_score: row.get(2)?,
826 importance_score: row.get(3)?,
827 feedback_score: row.get(4)?,
828 recorded_at: row.get(5)?,
829 })
830 })?
831 .filter_map(|r| r.ok())
832 .collect();
833
834 Ok(entries)
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize)]
839pub struct SalienceHistoryEntry {
840 pub salience_score: f32,
841 pub recency_score: f32,
842 pub frequency_score: f32,
843 pub importance_score: f32,
844 pub feedback_score: f32,
845 pub recorded_at: String,
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 fn create_test_memory(
853 id: MemoryId,
854 importance: f32,
855 access_count: i32,
856 days_since_access: i64,
857 ) -> Memory {
858 let now = Utc::now();
859 Memory {
860 id,
861 content: "Test content".to_string(),
862 memory_type: crate::types::MemoryType::Note,
863 tags: vec![],
864 metadata: HashMap::new(),
865 importance,
866 access_count,
867 created_at: now - chrono::Duration::days(30),
868 updated_at: now - chrono::Duration::days(1),
869 last_accessed_at: Some(now - chrono::Duration::days(days_since_access)),
870 owner_id: None,
871 visibility: crate::types::Visibility::Private,
872 scope: crate::types::MemoryScope::Global,
873 workspace: "default".to_string(),
874 tier: crate::types::MemoryTier::Permanent,
875 version: 1,
876 has_embedding: false,
877 expires_at: None,
878 content_hash: None,
879 event_time: None,
880 event_duration_seconds: None,
881 trigger_pattern: None,
882 procedure_success_count: 0,
883 procedure_failure_count: 0,
884 summary_of_id: None,
885 lifecycle_state: LifecycleState::Active,
886 }
887 }
888
889 #[test]
890 fn test_recency_decay() {
891 let calculator = SalienceCalculator::default();
892
893 let recent = create_test_memory(1, 0.5, 10, 0);
895 let score_recent = calculator.calculate(&recent, 0.5);
896 assert!(score_recent.recency > 0.9, "Recent should be > 0.9");
897
898 let half_life = create_test_memory(2, 0.5, 10, 14);
900 let score_half = calculator.calculate(&half_life, 0.5);
901 assert!(
902 (score_half.recency - 0.5).abs() < 0.1,
903 "Half-life should be ~0.5, got {}",
904 score_half.recency
905 );
906
907 let old = create_test_memory(3, 0.5, 10, 28);
909 let score_old = calculator.calculate(&old, 0.5);
910 assert!(
911 (score_old.recency - 0.25).abs() < 0.1,
912 "2x half-life should be ~0.25, got {}",
913 score_old.recency
914 );
915 }
916
917 #[test]
918 fn test_frequency_scaling() {
919 let calculator = SalienceCalculator::default();
920
921 let never = create_test_memory(1, 0.5, 0, 1);
923 let score_never = calculator.calculate(&never, 0.5);
924 assert!(
925 score_never.frequency < 0.2,
926 "Never accessed should be < 0.2"
927 );
928
929 let frequent = create_test_memory(2, 0.5, 50, 1);
931 let score_frequent = calculator.calculate(&frequent, 0.5);
932 assert!(
933 score_frequent.frequency > 0.6,
934 "Frequently accessed should be > 0.6"
935 );
936
937 let very_frequent = create_test_memory(3, 0.5, 100, 1);
939 let score_very = calculator.calculate(&very_frequent, 0.5);
940 assert!(
941 score_very.frequency <= 1.0,
942 "Max frequency should be <= 1.0"
943 );
944 }
945
946 #[test]
947 fn test_importance_weight() {
948 let calculator = SalienceCalculator::default();
949
950 let low_importance = create_test_memory(1, 0.1, 10, 1);
951 let high_importance = create_test_memory(2, 0.9, 10, 1);
952
953 let score_low = calculator.calculate(&low_importance, 0.5);
954 let score_high = calculator.calculate(&high_importance, 0.5);
955
956 assert!(
957 score_high.score > score_low.score,
958 "High importance should have higher salience"
959 );
960 }
961
962 #[test]
963 fn test_lifecycle_suggestion() {
964 let calculator = SalienceCalculator::default();
965
966 let active = create_test_memory(1, 0.8, 20, 5);
968 let score_active = calculator.calculate(&active, 0.5);
969 assert_eq!(score_active.suggested_state, LifecycleState::Active);
970
971 let stale = create_test_memory(2, 0.3, 2, 45);
973 let score_stale = calculator.calculate(&stale, 0.5);
974 assert_eq!(score_stale.suggested_state, LifecycleState::Stale);
975
976 let archived = create_test_memory(3, 0.1, 0, 100);
978 let score_archived = calculator.calculate(&archived, 0.1);
979 assert_eq!(score_archived.suggested_state, LifecycleState::Archived);
980 }
981
982 #[test]
983 fn test_priority_queue() {
984 let calculator = SalienceCalculator::default();
985
986 let memories = vec![
987 create_test_memory(1, 0.3, 5, 20), create_test_memory(2, 0.9, 50, 1), create_test_memory(3, 0.5, 10, 10), ];
991
992 let queue = calculator.priority_queue(&memories);
993
994 assert_eq!(queue[0].memory.id, 2, "Highest salience first");
996 assert_eq!(queue[2].memory.id, 1, "Lowest salience last");
997 }
998
999 #[test]
1000 fn test_score_bounds() {
1001 let calculator = SalienceCalculator::default();
1002
1003 let worst = create_test_memory(1, 0.0, 0, 365);
1005 let best = create_test_memory(2, 1.0, 100, 0);
1006
1007 let score_worst = calculator.calculate(&worst, 0.0);
1008 let score_best = calculator.calculate(&best, 1.0);
1009
1010 assert!(score_worst.score >= 0.0 && score_worst.score <= 1.0);
1012 assert!(score_best.score >= 0.0 && score_best.score <= 1.0);
1013
1014 assert!(score_worst.score >= 0.05, "Min salience should be enforced");
1016 }
1017}