Skip to main content

engram/intelligence/
salience.rs

1//! Salience Scoring System (Phase 8 - ENG-66, ENG-67, ENG-68)
2//!
3//! Calculates dynamic salience scores for memories based on:
4//! - Recency: How recently the memory was accessed (exponential decay)
5//! - Frequency: How often the memory is accessed (log-scaled)
6//! - Importance: User-set importance value
7//! - Feedback: Explicit user signals (boost/demote)
8//!
9//! Salience is used for:
10//! - Search result reranking
11//! - Priority queue ordering
12//! - Automatic archival decisions
13//! - Context budget allocation
14
15use chrono::{DateTime, Utc};
16use rusqlite::{params, Connection};
17use serde::{Deserialize, Serialize};
18
19use crate::error::Result;
20use crate::types::{LifecycleState, Memory, MemoryId};
21
22/// Configuration for salience scoring
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SalienceConfig {
25    /// Weight for recency component (0.0 - 1.0)
26    pub recency_weight: f32,
27    /// Weight for frequency component (0.0 - 1.0)
28    pub frequency_weight: f32,
29    /// Weight for importance component (0.0 - 1.0)
30    pub importance_weight: f32,
31    /// Weight for feedback component (0.0 - 1.0)
32    pub feedback_weight: f32,
33    /// Half-life for recency decay in days
34    pub recency_half_life_days: f32,
35    /// Log base for frequency scaling
36    pub frequency_log_base: f32,
37    /// Maximum frequency count for scaling (diminishing returns)
38    pub frequency_max_count: i32,
39    /// Minimum salience score (floor)
40    pub min_salience: f32,
41    /// Days of inactivity before marking stale
42    pub stale_threshold_days: i64,
43    /// Days of inactivity before suggesting archive
44    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, // 2 weeks half-life
55            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/// Salience score with component breakdown
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SalienceScore {
67    /// Overall salience score (0.0 - 1.0)
68    pub score: f32,
69    /// Recency component score
70    pub recency: f32,
71    /// Frequency component score
72    pub frequency: f32,
73    /// Importance component score
74    pub importance: f32,
75    /// Feedback component score
76    pub feedback: f32,
77    /// When the score was calculated
78    pub calculated_at: DateTime<Utc>,
79    /// Suggested lifecycle state based on salience
80    pub suggested_state: LifecycleState,
81}
82
83/// Result of a decay/refresh operation
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DecayResult {
86    /// Number of memories processed
87    pub processed: i64,
88    /// Number of memories marked stale
89    pub marked_stale: i64,
90    /// Number of memories suggested for archive
91    pub suggested_archive: i64,
92    /// Number of salience history records created
93    pub history_records: i64,
94    /// Duration of the operation in milliseconds
95    pub duration_ms: i64,
96}
97
98/// Salience statistics for analytics
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SalienceStats {
101    /// Total memories analyzed
102    pub total_memories: i64,
103    /// Average salience score
104    pub mean_salience: f32,
105    /// Median salience score
106    pub median_salience: f32,
107    /// Standard deviation
108    pub std_dev: f32,
109    /// Percentile distribution (10th, 25th, 50th, 75th, 90th)
110    pub percentiles: SaliencePercentiles,
111    /// Count by lifecycle state
112    pub by_state: StateDistribution,
113    /// Low salience memories (candidates for archive)
114    pub low_salience_count: i64,
115    /// High salience memories (most relevant)
116    pub high_salience_count: i64,
117}
118
119/// Percentile distribution for salience scores
120#[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/// Distribution of memories by lifecycle state
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct StateDistribution {
132    pub active: i64,
133    pub stale: i64,
134    pub archived: i64,
135}
136
137/// Memory with salience score for priority queue
138#[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        // Higher salience = higher priority
161        self.salience
162            .score
163            .partial_cmp(&other.salience.score)
164            .unwrap_or(std::cmp::Ordering::Equal)
165            .reverse() // For max-heap behavior in BinaryHeap
166    }
167}
168
169/// Salience calculator engine
170pub 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    /// Create a new salience calculator with custom config
182    pub fn new(config: SalienceConfig) -> Self {
183        Self { config }
184    }
185
186    /// Calculate salience score for a single memory
187    pub fn calculate(&self, memory: &Memory, feedback_signal: f32) -> SalienceScore {
188        let now = Utc::now();
189
190        // Recency: exponential decay based on last access
191        let recency = self.calculate_recency(memory, now);
192
193        // Frequency: log-scaled access count
194        let frequency = self.calculate_frequency(memory);
195
196        // Importance: user-set value (already 0.0-1.0)
197        let importance = memory.importance;
198
199        // Feedback: explicit user signal (clamped to 0.0-1.0)
200        let feedback = feedback_signal.clamp(0.0, 1.0);
201
202        // Weighted combination
203        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        // Suggest lifecycle state based on score and age
211        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    /// Calculate recency score using exponential decay
225    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        // Exponential decay: score = 0.5^(days / half_life)
230        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    /// Calculate frequency score using log scaling
236    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; // Base score for never-accessed
242        }
243
244        // Log scaling with diminishing returns
245        // log_b(x+1) / log_b(max+1) gives 0-1 range
246        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    /// Suggest lifecycle state based on salience and age
254    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        // Already archived stays archived
264        if memory.lifecycle_state == LifecycleState::Archived {
265            return LifecycleState::Archived;
266        }
267
268        // Low salience + long inactivity = suggest archive
269        if score < 0.2 && days_inactive >= self.config.archive_threshold_days {
270            return LifecycleState::Archived;
271        }
272
273        // Medium-low salience or moderate inactivity = stale
274        if score < 0.4 || days_inactive >= self.config.stale_threshold_days {
275            return LifecycleState::Stale;
276        }
277
278        LifecycleState::Active
279    }
280
281    /// Calculate salience for multiple memories
282    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    /// Get salience-sorted priority queue of memories
303    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
317/// Run decay on all memories and update lifecycle states
318pub 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    // Get all non-archived memories
338    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)?,            // importance
354                    row.get::<_, i32>(4)?,            // access_count
355                    row.get::<_, String>(5)?,         // created_at
356                    row.get::<_, String>(6)?,         // updated_at
357                    row.get::<_, Option<String>>(7)?, // last_accessed_at
358                    row.get::<_, String>(8)?,         // lifecycle_state
359                ))
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)?,            // importance
377                    row.get::<_, i32>(4)?,            // access_count
378                    row.get::<_, String>(5)?,         // created_at
379                    row.get::<_, String>(6)?,         // updated_at
380                    row.get::<_, Option<String>>(7)?, // last_accessed_at
381                    row.get::<_, String>(8)?,         // lifecycle_state
382                ))
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        // Parse dates
404        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        // Calculate recency
415        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        // Calculate frequency
420        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        // Calculate overall score (using 0.5 as default feedback)
430        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        // Determine suggested state
438        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        // Update state if changed
448        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        // Record history if enabled
462        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
486/// Get salience score for a specific memory
487pub 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            // Create a minimal memory for calculation
543            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
579/// Update importance (user feedback signal) for a memory
580pub 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
596/// Boost a memory's importance (positive feedback)
597pub 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); // Max boost of 0.5
604
605    // Update and return new importance
606    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
620/// Demote a memory's importance (negative feedback)
621pub 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); // Max demote of 0.5
628
629    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
643/// Get salience statistics for analytics
644pub 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    // Get all non-expired memories with their scores
657    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        // Calculate score
699        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        // Count by state
730        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    // Sort for percentile calculation
762    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    // Standard deviation
769    let variance: f32 = scores.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / total as f32;
770    let std_dev = variance.sqrt();
771
772    // Percentiles
773    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    // Count low/high salience
780    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
805/// Get salience history for a memory (for trend analysis)
806pub 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/// Salience history entry
838#[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        // Recently accessed should have high recency
894        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        // Accessed 14 days ago (half-life) should be ~0.5
899        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        // Accessed 28 days ago (2x half-life) should be ~0.25
908        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        // Never accessed should have low frequency
922        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        // Frequently accessed should have high frequency
930        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        // Frequency should have diminishing returns
938        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        // Recent with good engagement = active
967        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        // Old with low engagement = stale
972        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        // Very old with very low engagement = archived
977        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),  // Low salience
988            create_test_memory(2, 0.9, 50, 1),  // High salience
989            create_test_memory(3, 0.5, 10, 10), // Medium salience
990        ];
991
992        let queue = calculator.priority_queue(&memories);
993
994        // Should be sorted by salience descending
995        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        // Test extreme cases
1004        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        // Scores should be bounded 0.0-1.0
1011        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        // Min salience should be enforced
1015        assert!(score_worst.score >= 0.05, "Min salience should be enforced");
1016    }
1017}