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 media_url: None,
571 };
572
573 Ok(Some(calculator.calculate(&memory, feedback_signal)))
574 }
575 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
576 Err(e) => Err(e.into()),
577 }
578}
579
580pub fn set_memory_importance(
582 conn: &Connection,
583 memory_id: MemoryId,
584 importance: f32,
585) -> Result<()> {
586 let importance = importance.clamp(0.0, 1.0);
587 let now = Utc::now().to_rfc3339();
588
589 conn.execute(
590 "UPDATE memories SET importance = ?, updated_at = ? WHERE id = ?",
591 params![importance, now, memory_id],
592 )?;
593
594 Ok(())
595}
596
597pub fn boost_memory_salience(
599 conn: &Connection,
600 memory_id: MemoryId,
601 boost_amount: f32,
602) -> Result<f32> {
603 let now = Utc::now().to_rfc3339();
604 let boost = boost_amount.clamp(0.0, 0.5); conn.execute(
608 "UPDATE memories SET importance = MIN(1.0, importance + ?), updated_at = ? WHERE id = ?",
609 params![boost, now, memory_id],
610 )?;
611
612 let new_importance: f32 = conn.query_row(
613 "SELECT importance FROM memories WHERE id = ?",
614 params![memory_id],
615 |row| row.get(0),
616 )?;
617
618 Ok(new_importance)
619}
620
621pub fn demote_memory_salience(
623 conn: &Connection,
624 memory_id: MemoryId,
625 demote_amount: f32,
626) -> Result<f32> {
627 let now = Utc::now().to_rfc3339();
628 let demote = demote_amount.clamp(0.0, 0.5); conn.execute(
631 "UPDATE memories SET importance = MAX(0.0, importance - ?), updated_at = ? WHERE id = ?",
632 params![demote, now, memory_id],
633 )?;
634
635 let new_importance: f32 = conn.query_row(
636 "SELECT importance FROM memories WHERE id = ?",
637 params![memory_id],
638 |row| row.get(0),
639 )?;
640
641 Ok(new_importance)
642}
643
644pub fn get_salience_stats(conn: &Connection, config: &SalienceConfig) -> Result<SalienceStats> {
646 get_salience_stats_in_workspace(conn, config, None)
647}
648
649pub fn get_salience_stats_in_workspace(
650 conn: &Connection,
651 config: &SalienceConfig,
652 workspace: Option<&str>,
653) -> Result<SalienceStats> {
654 let now = Utc::now();
655 let now_str = now.to_rfc3339();
656
657 let mut scores: Vec<f32> = Vec::new();
659 let mut active_count = 0i64;
660 let mut stale_count = 0i64;
661 let mut archived_count = 0i64;
662
663 let rows = if let Some(workspace) = workspace {
664 let mut stmt = conn.prepare(
665 "SELECT importance, access_count, created_at, last_accessed_at, lifecycle_state
666 FROM memories
667 WHERE (expires_at IS NULL OR expires_at > ?)
668 AND workspace = ?",
669 )?;
670 let rows = stmt.query_map(params![now_str, workspace], |row| {
671 Ok((
672 row.get::<_, f32>(0)?,
673 row.get::<_, i32>(1)?,
674 row.get::<_, String>(2)?,
675 row.get::<_, Option<String>>(3)?,
676 row.get::<_, String>(4)?,
677 ))
678 })?;
679 rows.collect::<std::result::Result<Vec<_>, _>>()?
680 } else {
681 let mut stmt = conn.prepare(
682 "SELECT importance, access_count, created_at, last_accessed_at, lifecycle_state
683 FROM memories
684 WHERE (expires_at IS NULL OR expires_at > ?)",
685 )?;
686 let rows = stmt.query_map(params![now_str], |row| {
687 Ok((
688 row.get::<_, f32>(0)?,
689 row.get::<_, i32>(1)?,
690 row.get::<_, String>(2)?,
691 row.get::<_, Option<String>>(3)?,
692 row.get::<_, String>(4)?,
693 ))
694 })?;
695 rows.collect::<std::result::Result<Vec<_>, _>>()?
696 };
697
698 for (importance, access_count, created_at_str, last_accessed_str, state_str) in rows {
699 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
701 .map(|dt| dt.with_timezone(&Utc))
702 .unwrap_or(now);
703
704 let last_access = last_accessed_str
705 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
706 .map(|dt| dt.with_timezone(&Utc))
707 .unwrap_or(created_at);
708
709 let days_since_access = (now - last_access).num_hours() as f32 / 24.0;
710 let recency = 0.5_f32.powf(days_since_access / config.recency_half_life_days);
711
712 let count = access_count.max(0) as f32;
713 let frequency = if count <= 0.0 {
714 0.1
715 } else {
716 let log_count = (count + 1.0).log(config.frequency_log_base);
717 let log_max = (config.frequency_max_count as f32 + 1.0).log(config.frequency_log_base);
718 (log_count / log_max).min(1.0)
719 };
720
721 let score = (recency * config.recency_weight
722 + frequency * config.frequency_weight
723 + importance * config.importance_weight
724 + 0.5 * config.feedback_weight)
725 .max(config.min_salience)
726 .min(1.0);
727
728 scores.push(score);
729
730 match state_str.as_str() {
732 "active" => active_count += 1,
733 "stale" => stale_count += 1,
734 "archived" => archived_count += 1,
735 _ => active_count += 1,
736 }
737 }
738
739 if scores.is_empty() {
740 return Ok(SalienceStats {
741 total_memories: 0,
742 mean_salience: 0.0,
743 median_salience: 0.0,
744 std_dev: 0.0,
745 percentiles: SaliencePercentiles {
746 p10: 0.0,
747 p25: 0.0,
748 p50: 0.0,
749 p75: 0.0,
750 p90: 0.0,
751 },
752 by_state: StateDistribution {
753 active: 0,
754 stale: 0,
755 archived: 0,
756 },
757 low_salience_count: 0,
758 high_salience_count: 0,
759 });
760 }
761
762 scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
764
765 let total = scores.len();
766 let mean: f32 = scores.iter().sum::<f32>() / total as f32;
767 let median = scores[total / 2];
768
769 let variance: f32 = scores.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / total as f32;
771 let std_dev = variance.sqrt();
772
773 let p10 = scores[(total as f32 * 0.10) as usize];
775 let p25 = scores[(total as f32 * 0.25) as usize];
776 let p50 = scores[(total as f32 * 0.50) as usize];
777 let p75 = scores[((total as f32 * 0.75) as usize).min(total - 1)];
778 let p90 = scores[((total as f32 * 0.90) as usize).min(total - 1)];
779
780 let low_salience_count = scores.iter().filter(|&&s| s < 0.3).count() as i64;
782 let high_salience_count = scores.iter().filter(|&&s| s > 0.7).count() as i64;
783
784 Ok(SalienceStats {
785 total_memories: total as i64,
786 mean_salience: mean,
787 median_salience: median,
788 std_dev,
789 percentiles: SaliencePercentiles {
790 p10,
791 p25,
792 p50,
793 p75,
794 p90,
795 },
796 by_state: StateDistribution {
797 active: active_count,
798 stale: stale_count,
799 archived: archived_count,
800 },
801 low_salience_count,
802 high_salience_count,
803 })
804}
805
806pub fn get_salience_history(
808 conn: &Connection,
809 memory_id: MemoryId,
810 limit: i64,
811) -> Result<Vec<SalienceHistoryEntry>> {
812 let mut stmt = conn.prepare(
813 "SELECT salience_score, recency_score, frequency_score,
814 importance_score, feedback_score, recorded_at
815 FROM salience_history
816 WHERE memory_id = ?
817 ORDER BY recorded_at DESC
818 LIMIT ?",
819 )?;
820
821 let entries = stmt
822 .query_map(params![memory_id, limit], |row| {
823 Ok(SalienceHistoryEntry {
824 salience_score: row.get(0)?,
825 recency_score: row.get(1)?,
826 frequency_score: row.get(2)?,
827 importance_score: row.get(3)?,
828 feedback_score: row.get(4)?,
829 recorded_at: row.get(5)?,
830 })
831 })?
832 .filter_map(|r| r.ok())
833 .collect();
834
835 Ok(entries)
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct SalienceHistoryEntry {
841 pub salience_score: f32,
842 pub recency_score: f32,
843 pub frequency_score: f32,
844 pub importance_score: f32,
845 pub feedback_score: f32,
846 pub recorded_at: String,
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 fn create_test_memory(
854 id: MemoryId,
855 importance: f32,
856 access_count: i32,
857 days_since_access: i64,
858 ) -> Memory {
859 let now = Utc::now();
860 Memory {
861 id,
862 content: "Test content".to_string(),
863 memory_type: crate::types::MemoryType::Note,
864 tags: vec![],
865 metadata: HashMap::new(),
866 importance,
867 access_count,
868 created_at: now - chrono::Duration::days(30),
869 updated_at: now - chrono::Duration::days(1),
870 last_accessed_at: Some(now - chrono::Duration::days(days_since_access)),
871 owner_id: None,
872 visibility: crate::types::Visibility::Private,
873 scope: crate::types::MemoryScope::Global,
874 workspace: "default".to_string(),
875 tier: crate::types::MemoryTier::Permanent,
876 version: 1,
877 has_embedding: false,
878 expires_at: None,
879 content_hash: None,
880 event_time: None,
881 event_duration_seconds: None,
882 trigger_pattern: None,
883 procedure_success_count: 0,
884 procedure_failure_count: 0,
885 summary_of_id: None,
886 lifecycle_state: LifecycleState::Active,
887 media_url: None,
888 }
889 }
890
891 #[test]
892 fn test_recency_decay() {
893 let calculator = SalienceCalculator::default();
894
895 let recent = create_test_memory(1, 0.5, 10, 0);
897 let score_recent = calculator.calculate(&recent, 0.5);
898 assert!(score_recent.recency > 0.9, "Recent should be > 0.9");
899
900 let half_life = create_test_memory(2, 0.5, 10, 14);
902 let score_half = calculator.calculate(&half_life, 0.5);
903 assert!(
904 (score_half.recency - 0.5).abs() < 0.1,
905 "Half-life should be ~0.5, got {}",
906 score_half.recency
907 );
908
909 let old = create_test_memory(3, 0.5, 10, 28);
911 let score_old = calculator.calculate(&old, 0.5);
912 assert!(
913 (score_old.recency - 0.25).abs() < 0.1,
914 "2x half-life should be ~0.25, got {}",
915 score_old.recency
916 );
917 }
918
919 #[test]
920 fn test_frequency_scaling() {
921 let calculator = SalienceCalculator::default();
922
923 let never = create_test_memory(1, 0.5, 0, 1);
925 let score_never = calculator.calculate(&never, 0.5);
926 assert!(
927 score_never.frequency < 0.2,
928 "Never accessed should be < 0.2"
929 );
930
931 let frequent = create_test_memory(2, 0.5, 50, 1);
933 let score_frequent = calculator.calculate(&frequent, 0.5);
934 assert!(
935 score_frequent.frequency > 0.6,
936 "Frequently accessed should be > 0.6"
937 );
938
939 let very_frequent = create_test_memory(3, 0.5, 100, 1);
941 let score_very = calculator.calculate(&very_frequent, 0.5);
942 assert!(
943 score_very.frequency <= 1.0,
944 "Max frequency should be <= 1.0"
945 );
946 }
947
948 #[test]
949 fn test_importance_weight() {
950 let calculator = SalienceCalculator::default();
951
952 let low_importance = create_test_memory(1, 0.1, 10, 1);
953 let high_importance = create_test_memory(2, 0.9, 10, 1);
954
955 let score_low = calculator.calculate(&low_importance, 0.5);
956 let score_high = calculator.calculate(&high_importance, 0.5);
957
958 assert!(
959 score_high.score > score_low.score,
960 "High importance should have higher salience"
961 );
962 }
963
964 #[test]
965 fn test_lifecycle_suggestion() {
966 let calculator = SalienceCalculator::default();
967
968 let active = create_test_memory(1, 0.8, 20, 5);
970 let score_active = calculator.calculate(&active, 0.5);
971 assert_eq!(score_active.suggested_state, LifecycleState::Active);
972
973 let stale = create_test_memory(2, 0.3, 2, 45);
975 let score_stale = calculator.calculate(&stale, 0.5);
976 assert_eq!(score_stale.suggested_state, LifecycleState::Stale);
977
978 let archived = create_test_memory(3, 0.1, 0, 100);
980 let score_archived = calculator.calculate(&archived, 0.1);
981 assert_eq!(score_archived.suggested_state, LifecycleState::Archived);
982 }
983
984 #[test]
985 fn test_priority_queue() {
986 let calculator = SalienceCalculator::default();
987
988 let memories = vec![
989 create_test_memory(1, 0.3, 5, 20), create_test_memory(2, 0.9, 50, 1), create_test_memory(3, 0.5, 10, 10), ];
993
994 let queue = calculator.priority_queue(&memories);
995
996 assert_eq!(queue[0].memory.id, 2, "Highest salience first");
998 assert_eq!(queue[2].memory.id, 1, "Lowest salience last");
999 }
1000
1001 #[test]
1002 fn test_score_bounds() {
1003 let calculator = SalienceCalculator::default();
1004
1005 let worst = create_test_memory(1, 0.0, 0, 365);
1007 let best = create_test_memory(2, 1.0, 100, 0);
1008
1009 let score_worst = calculator.calculate(&worst, 0.0);
1010 let score_best = calculator.calculate(&best, 1.0);
1011
1012 assert!(score_worst.score >= 0.0 && score_worst.score <= 1.0);
1014 assert!(score_best.score >= 0.0 && score_best.score <= 1.0);
1015
1016 assert!(score_worst.score >= 0.05, "Min salience should be enforced");
1018 }
1019}