Skip to main content

smelt_memory/utility/
ranker.rs

1//! Utility-based ranking for episode retrieval
2
3use super::decay::{apply_decay, DecayParams};
4use super::wilson::wilson_score_default;
5use crate::types::{Episode, RankedEpisode};
6use chrono::Utc;
7
8/// Configuration for utility-based ranking
9#[derive(Debug, Clone)]
10pub struct RankerConfig {
11    /// Weight for semantic similarity (0.0 to 1.0)
12    pub similarity_weight: f64,
13    /// Weight for utility score (0.0 to 1.0)
14    pub utility_weight: f64,
15    /// Weight for feedback score (0.0 to 1.0)
16    pub feedback_weight: f64,
17    /// Decay parameters
18    pub decay_params: DecayParams,
19}
20
21impl Default for RankerConfig {
22    fn default() -> Self {
23        Self {
24            similarity_weight: 0.5,
25            utility_weight: 0.3,
26            feedback_weight: 0.2,
27            decay_params: DecayParams::default(),
28        }
29    }
30}
31
32/// Utility-based ranker for episodes
33pub struct UtilityRanker {
34    config: RankerConfig,
35}
36
37impl UtilityRanker {
38    /// Create a new ranker with default configuration
39    pub fn new() -> Self {
40        Self {
41            config: RankerConfig::default(),
42        }
43    }
44
45    /// Create a ranker with custom configuration
46    pub fn with_config(config: RankerConfig) -> Self {
47        Self { config }
48    }
49
50    /// Rank episodes based on similarity and utility
51    ///
52    /// # Arguments
53    /// * `episodes` - Episodes to rank
54    /// * `similarities` - Semantic similarity scores (0.0 to 1.0)
55    ///
56    /// # Returns
57    /// Ranked episodes sorted by combined score (descending)
58    pub fn rank(&self, episodes: Vec<Episode>, similarities: Vec<f64>) -> Vec<RankedEpisode> {
59        let now = Utc::now();
60
61        let mut ranked: Vec<RankedEpisode> = episodes
62            .into_iter()
63            .zip(similarities)
64            .map(|(episode, similarity)| {
65                let score = self.compute_score(&episode, similarity, now);
66                RankedEpisode {
67                    episode,
68                    similarity,
69                    score,
70                }
71            })
72            .collect();
73
74        // Sort by score descending
75        ranked.sort_by(|a, b| {
76            b.score
77                .partial_cmp(&a.score)
78                .unwrap_or(std::cmp::Ordering::Equal)
79        });
80
81        ranked
82    }
83
84    /// Compute combined score for an episode
85    fn compute_score(&self, episode: &Episode, similarity: f64, now: chrono::DateTime<Utc>) -> f64 {
86        // Apply decay to utility
87        let decayed_utility = apply_decay(
88            episode.utility,
89            episode.created_at,
90            now,
91            &self.config.decay_params,
92        );
93
94        // Calculate feedback score using Wilson
95        let feedback_score = wilson_score_default(episode.helpful_count, episode.feedback_count);
96
97        // Combine scores
98        self.config.similarity_weight * similarity
99            + self.config.utility_weight * decayed_utility
100            + self.config.feedback_weight * feedback_score
101    }
102
103    /// Update utility based on feedback
104    ///
105    /// Simple update rule: new_utility = old_utility + α * (feedback_value - old_utility)
106    pub fn update_utility_from_feedback(
107        &self,
108        episode: &Episode,
109        helpful: bool,
110        learning_rate: f64,
111    ) -> f64 {
112        let feedback_value = if helpful { 1.0 } else { 0.0 };
113        let new_utility = episode.utility + learning_rate * (feedback_value - episode.utility);
114        new_utility.clamp(0.0, 1.0)
115    }
116}
117
118impl Default for UtilityRanker {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::types::EpisodeOutcome;
128
129    fn make_episode(summary: &str, utility: f64, helpful: u32, total: u32) -> Episode {
130        let mut ep = Episode::new(
131            summary.to_string(),
132            "test".to_string(),
133            EpisodeOutcome::Success,
134        );
135        ep.utility = utility;
136        ep.helpful_count = helpful;
137        ep.feedback_count = total;
138        ep
139    }
140
141    #[test]
142    fn test_ranking_by_similarity() {
143        let ranker = UtilityRanker::new();
144
145        let ep1 = make_episode("Episode 1", 0.5, 0, 0);
146        let ep2 = make_episode("Episode 2", 0.5, 0, 0);
147
148        let episodes = vec![ep1, ep2];
149        let similarities = vec![0.9, 0.5]; // First is more similar
150
151        let ranked = ranker.rank(episodes, similarities);
152
153        assert_eq!(ranked[0].episode.summary, "Episode 1");
154        assert!(ranked[0].score > ranked[1].score);
155    }
156
157    #[test]
158    fn test_ranking_considers_utility() {
159        let ranker = UtilityRanker::new();
160
161        let ep1 = make_episode("Low utility", 0.1, 0, 0);
162        let ep2 = make_episode("High utility", 0.9, 0, 0);
163
164        let episodes = vec![ep1, ep2];
165        let similarities = vec![0.8, 0.8]; // Same similarity
166
167        let ranked = ranker.rank(episodes, similarities);
168
169        // Higher utility should rank first
170        assert_eq!(ranked[0].episode.summary, "High utility");
171    }
172
173    #[test]
174    fn test_ranking_considers_feedback() {
175        let ranker = UtilityRanker::new();
176
177        let ep1 = make_episode("Poor feedback", 0.5, 1, 10); // 10% helpful
178        let ep2 = make_episode("Good feedback", 0.5, 9, 10); // 90% helpful
179
180        let episodes = vec![ep1, ep2];
181        let similarities = vec![0.8, 0.8]; // Same similarity
182
183        let ranked = ranker.rank(episodes, similarities);
184
185        // Better feedback should rank first
186        assert_eq!(ranked[0].episode.summary, "Good feedback");
187    }
188
189    #[test]
190    fn test_utility_update() {
191        let ranker = UtilityRanker::new();
192
193        let episode = make_episode("Test", 0.5, 0, 0);
194
195        // Positive feedback should increase utility
196        let new_utility = ranker.update_utility_from_feedback(&episode, true, 0.1);
197        assert!(new_utility > 0.5);
198
199        // Negative feedback should decrease utility
200        let new_utility = ranker.update_utility_from_feedback(&episode, false, 0.1);
201        assert!(new_utility < 0.5);
202    }
203
204    #[test]
205    fn test_utility_bounds() {
206        let ranker = UtilityRanker::new();
207
208        let high = make_episode("High", 0.99, 0, 0);
209        let low = make_episode("Low", 0.01, 0, 0);
210
211        // Should not exceed 1.0
212        let new_high = ranker.update_utility_from_feedback(&high, true, 0.5);
213        assert!(new_high <= 1.0);
214
215        // Should not go below 0.0
216        let new_low = ranker.update_utility_from_feedback(&low, false, 0.5);
217        assert!(new_low >= 0.0);
218    }
219}