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                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
580/// Update importance (user feedback signal) for a memory
581pub 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
597/// Boost a memory's importance (positive feedback)
598pub 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); // Max boost of 0.5
605
606    // Update and return new importance
607    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
621/// Demote a memory's importance (negative feedback)
622pub 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); // Max demote of 0.5
629
630    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
644/// Get salience statistics for analytics
645pub 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    // Get all non-expired memories with their scores
658    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        // Calculate score
700        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        // Count by state
731        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    // Sort for percentile calculation
763    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    // Standard deviation
770    let variance: f32 = scores.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / total as f32;
771    let std_dev = variance.sqrt();
772
773    // Percentiles
774    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    // Count low/high salience
781    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
806/// Get salience history for a memory (for trend analysis)
807pub 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/// Salience history entry
839#[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        // Recently accessed should have high recency
896        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        // Accessed 14 days ago (half-life) should be ~0.5
901        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        // Accessed 28 days ago (2x half-life) should be ~0.25
910        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        // Never accessed should have low frequency
924        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        // Frequently accessed should have high frequency
932        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        // Frequency should have diminishing returns
940        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        // Recent with good engagement = active
969        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        // Old with low engagement = stale
974        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        // Very old with very low engagement = archived
979        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),  // Low salience
990            create_test_memory(2, 0.9, 50, 1),  // High salience
991            create_test_memory(3, 0.5, 10, 10), // Medium salience
992        ];
993
994        let queue = calculator.priority_queue(&memories);
995
996        // Should be sorted by salience descending
997        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        // Test extreme cases
1006        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        // Scores should be bounded 0.0-1.0
1013        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        // Min salience should be enforced
1017        assert!(score_worst.score >= 0.05, "Min salience should be enforced");
1018    }
1019}