Skip to main content

engram/intelligence/
quality.rs

1//! Memory Quality Scoring (RML-892)
2//!
3//! Automatically scores memory quality based on multiple factors.
4
5use crate::types::Memory;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Quality metrics for a memory
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct QualityMetrics {
12    /// Content completeness (0.0 - 1.0)
13    pub completeness: f32,
14    /// Content clarity (0.0 - 1.0)
15    pub clarity: f32,
16    /// Relevance based on access patterns (0.0 - 1.0)
17    pub relevance: f32,
18    /// Freshness based on age and updates (0.0 - 1.0)
19    pub freshness: f32,
20    /// Connectivity in the knowledge graph (0.0 - 1.0)
21    pub connectivity: f32,
22    /// Consistency with other memories (0.0 - 1.0)
23    pub consistency: f32,
24}
25
26impl Default for QualityMetrics {
27    fn default() -> Self {
28        Self {
29            completeness: 0.5,
30            clarity: 0.5,
31            relevance: 0.5,
32            freshness: 0.5,
33            connectivity: 0.0,
34            consistency: 0.5,
35        }
36    }
37}
38
39/// Overall quality score with breakdown
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct QualityScore {
42    /// Overall score (0.0 - 1.0)
43    pub overall: f32,
44    /// Letter grade (A, B, C, D, F)
45    pub grade: char,
46    /// Detailed metrics
47    pub metrics: QualityMetrics,
48    /// Suggestions for improvement
49    pub suggestions: Vec<String>,
50    /// When the score was calculated
51    pub calculated_at: DateTime<Utc>,
52}
53
54impl QualityScore {
55    /// Get grade from overall score
56    fn grade_from_score(score: f32) -> char {
57        match score {
58            s if s >= 0.9 => 'A',
59            s if s >= 0.8 => 'B',
60            s if s >= 0.7 => 'C',
61            s if s >= 0.6 => 'D',
62            _ => 'F',
63        }
64    }
65}
66
67/// Configuration for quality scoring
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct QualityScorerConfig {
70    /// Weight for completeness
71    pub completeness_weight: f32,
72    /// Weight for clarity
73    pub clarity_weight: f32,
74    /// Weight for relevance
75    pub relevance_weight: f32,
76    /// Weight for freshness
77    pub freshness_weight: f32,
78    /// Weight for connectivity
79    pub connectivity_weight: f32,
80    /// Weight for consistency
81    pub consistency_weight: f32,
82    /// Minimum content length for good completeness
83    pub min_content_length: usize,
84    /// Ideal content length
85    pub ideal_content_length: usize,
86    /// Days until memory is considered stale
87    pub staleness_days: i64,
88}
89
90impl Default for QualityScorerConfig {
91    fn default() -> Self {
92        Self {
93            completeness_weight: 0.2,
94            clarity_weight: 0.2,
95            relevance_weight: 0.2,
96            freshness_weight: 0.15,
97            connectivity_weight: 0.15,
98            consistency_weight: 0.1,
99            min_content_length: 20,
100            ideal_content_length: 200,
101            staleness_days: 90,
102        }
103    }
104}
105
106/// Engine for scoring memory quality
107pub struct QualityScorer {
108    config: QualityScorerConfig,
109}
110
111impl Default for QualityScorer {
112    fn default() -> Self {
113        Self::new(QualityScorerConfig::default())
114    }
115}
116
117impl QualityScorer {
118    /// Create a new quality scorer
119    pub fn new(config: QualityScorerConfig) -> Self {
120        Self { config }
121    }
122
123    /// Score a memory's quality
124    pub fn score(&self, memory: &Memory, connection_count: usize) -> QualityScore {
125        let metrics = QualityMetrics {
126            completeness: self.score_completeness(memory),
127            clarity: self.score_clarity(memory),
128            relevance: self.score_relevance(memory),
129            freshness: self.score_freshness(memory),
130            connectivity: self.score_connectivity(connection_count),
131            consistency: 0.5, // Would need cross-memory analysis
132        };
133
134        let overall = self.calculate_overall(&metrics);
135        let suggestions = self.generate_suggestions(memory, &metrics);
136
137        QualityScore {
138            overall,
139            grade: QualityScore::grade_from_score(overall),
140            metrics,
141            suggestions,
142            calculated_at: Utc::now(),
143        }
144    }
145
146    /// Score content completeness
147    fn score_completeness(&self, memory: &Memory) -> f32 {
148        let len = memory.content.len();
149
150        // Too short is bad
151        if len < self.config.min_content_length {
152            return 0.3;
153        }
154
155        // Ideal length gets full score
156        if len >= self.config.ideal_content_length {
157            return 1.0;
158        }
159
160        // Linear interpolation between min and ideal
161        let range = (self.config.ideal_content_length - self.config.min_content_length) as f32;
162        let progress = (len - self.config.min_content_length) as f32;
163        0.3 + 0.7 * (progress / range)
164    }
165
166    /// Score content clarity
167    fn score_clarity(&self, memory: &Memory) -> f32 {
168        let content = &memory.content;
169        let mut score: f32 = 0.5;
170
171        // Has structure (sentences)
172        let sentence_count = content.matches('.').count()
173            + content.matches('!').count()
174            + content.matches('?').count();
175        if sentence_count > 0 {
176            score += 0.15;
177        }
178
179        // Not too many abbreviations or unclear terms
180        let word_count = content.split_whitespace().count();
181        if word_count > 0 {
182            let avg_word_len: f32 = content
183                .split_whitespace()
184                .map(|w| w.len() as f32)
185                .sum::<f32>()
186                / word_count as f32;
187
188            // Words between 3-10 chars are typically clear
189            if (3.0..=10.0).contains(&avg_word_len) {
190                score += 0.2;
191            }
192        }
193
194        // Has tags (organization)
195        if !memory.tags.is_empty() {
196            score += 0.15;
197        }
198
199        score.min(1.0_f32)
200    }
201
202    /// Score relevance based on access patterns
203    fn score_relevance(&self, memory: &Memory) -> f32 {
204        // Base on access count and recency of access
205        let access_score = (memory.access_count as f32 / 50.0).min(1.0);
206
207        let recency_score = memory
208            .last_accessed_at
209            .map(|dt| {
210                let days_ago = (Utc::now() - dt).num_days() as f32;
211                (1.0 - days_ago / 30.0).max(0.0)
212            })
213            .unwrap_or(0.3);
214
215        (access_score * 0.6 + recency_score * 0.4).min(1.0)
216    }
217
218    /// Score freshness
219    fn score_freshness(&self, memory: &Memory) -> f32 {
220        let age_days = (Utc::now() - memory.updated_at).num_days() as f32;
221        let staleness = self.config.staleness_days as f32;
222
223        if age_days <= 0.0 {
224            1.0
225        } else if age_days >= staleness {
226            0.2
227        } else {
228            1.0 - 0.8 * (age_days / staleness)
229        }
230    }
231
232    /// Score connectivity
233    fn score_connectivity(&self, connection_count: usize) -> f32 {
234        // Having connections is good, but diminishing returns
235        match connection_count {
236            0 => 0.2,
237            1..=2 => 0.5,
238            3..=5 => 0.8,
239            _ => 1.0,
240        }
241    }
242
243    /// Calculate overall score from metrics
244    fn calculate_overall(&self, metrics: &QualityMetrics) -> f32 {
245        let c = &self.config;
246        metrics.completeness * c.completeness_weight
247            + metrics.clarity * c.clarity_weight
248            + metrics.relevance * c.relevance_weight
249            + metrics.freshness * c.freshness_weight
250            + metrics.connectivity * c.connectivity_weight
251            + metrics.consistency * c.consistency_weight
252    }
253
254    /// Generate improvement suggestions
255    fn generate_suggestions(&self, memory: &Memory, metrics: &QualityMetrics) -> Vec<String> {
256        let mut suggestions = Vec::new();
257
258        if metrics.completeness < 0.5 {
259            suggestions.push("Add more detail to make this memory more useful".to_string());
260        }
261
262        if metrics.clarity < 0.5 {
263            suggestions.push("Consider adding structure with clear sentences".to_string());
264        }
265
266        if memory.tags.is_empty() {
267            suggestions.push("Add tags to improve organization and searchability".to_string());
268        }
269
270        if metrics.freshness < 0.3 {
271            suggestions.push("This memory may be outdated - consider reviewing".to_string());
272        }
273
274        if metrics.connectivity < 0.3 {
275            suggestions.push("Link this to related memories to build connections".to_string());
276        }
277
278        if metrics.relevance < 0.3 && memory.access_count == 0 {
279            suggestions
280                .push("This memory has never been accessed - is it still relevant?".to_string());
281        }
282
283        suggestions
284    }
285
286    /// Score multiple memories and return sorted by quality
287    pub fn score_batch(&self, memories: &[Memory]) -> Vec<(Memory, QualityScore)> {
288        let mut scored: Vec<_> = memories
289            .iter()
290            .map(|m| (m.clone(), self.score(m, 0)))
291            .collect();
292
293        scored.sort_by(|a, b| {
294            b.1.overall
295                .partial_cmp(&a.1.overall)
296                .unwrap_or(std::cmp::Ordering::Equal)
297        });
298
299        scored
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::types::{MemoryType, Visibility};
307    use std::collections::HashMap;
308
309    fn create_test_memory(content: &str, tags: Vec<&str>, access_count: i32) -> Memory {
310        Memory {
311            id: 1,
312            content: content.to_string(),
313            memory_type: MemoryType::Note,
314            tags: tags.into_iter().map(String::from).collect(),
315            metadata: HashMap::new(),
316            importance: 0.5,
317            access_count,
318            created_at: Utc::now() - chrono::Duration::days(10),
319            updated_at: Utc::now() - chrono::Duration::days(5),
320            last_accessed_at: Some(Utc::now() - chrono::Duration::days(1)),
321            owner_id: None,
322            visibility: Visibility::Private,
323            scope: crate::types::MemoryScope::Global,
324            workspace: "default".to_string(),
325            tier: crate::types::MemoryTier::Permanent,
326            version: 1,
327            has_embedding: false,
328            expires_at: None,
329            content_hash: None,
330            event_time: None,
331            event_duration_seconds: None,
332            trigger_pattern: None,
333            procedure_success_count: 0,
334            procedure_failure_count: 0,
335            summary_of_id: None,
336            lifecycle_state: crate::types::LifecycleState::Active,
337        }
338    }
339
340    #[test]
341    fn test_score_completeness() {
342        let scorer = QualityScorer::default();
343
344        let short = create_test_memory("Hi", vec![], 0);
345        let medium = create_test_memory(
346            "This is a medium length note with some useful content.",
347            vec![],
348            0,
349        );
350        let long = create_test_memory(&"This is a detailed note. ".repeat(20), vec![], 0);
351
352        let short_score = scorer.score_completeness(&short);
353        let medium_score = scorer.score_completeness(&medium);
354        let long_score = scorer.score_completeness(&long);
355
356        assert!(short_score < medium_score);
357        assert!(medium_score < long_score);
358    }
359
360    #[test]
361    fn test_quality_grade() {
362        assert_eq!(QualityScore::grade_from_score(0.95), 'A');
363        assert_eq!(QualityScore::grade_from_score(0.85), 'B');
364        assert_eq!(QualityScore::grade_from_score(0.75), 'C');
365        assert_eq!(QualityScore::grade_from_score(0.65), 'D');
366        assert_eq!(QualityScore::grade_from_score(0.5), 'F');
367    }
368
369    #[test]
370    fn test_suggestions_generation() {
371        let scorer = QualityScorer::default();
372
373        let poor_memory = create_test_memory("X", vec![], 0);
374        let score = scorer.score(&poor_memory, 0);
375
376        assert!(!score.suggestions.is_empty());
377        assert!(score.suggestions.iter().any(|s| s.contains("detail")));
378        assert!(score.suggestions.iter().any(|s| s.contains("tags")));
379    }
380
381    #[test]
382    fn test_overall_score() {
383        let scorer = QualityScorer::default();
384
385        let good_memory = create_test_memory(
386            "This is a well-written note about an important topic. It has good structure and clear sentences. The content is detailed enough to be useful.",
387            vec!["important", "well-written"],
388            20,
389        );
390
391        let score = scorer.score(&good_memory, 3);
392        assert!(score.overall > 0.6);
393        assert!(score.grade == 'A' || score.grade == 'B' || score.grade == 'C');
394    }
395}