Skip to main content

do_memory_core/reward/
mod.rs

1//! # Reward Calculator
2//!
3//! Calculates reward scores for episodes based on outcome, efficiency, and quality.
4//! Supports both fixed thresholds and adaptive domain-based calibration.
5//!
6//! ## Modules
7//!
8//! - `domain_stats` - Domain-specific statistics for adaptive calibration
9//! - `adaptive` - Adaptive reward calculator using domain baselines
10//!
11
12#![allow(clippy::if_not_else)]
13#![allow(clippy::cast_precision_loss)]
14#![allow(clippy::map_unwrap_or)]
15#![allow(clippy::doc_markdown)]
16
17// Public modules
18pub mod adaptive;
19pub mod base;
20pub mod constants;
21pub mod domain_stats;
22pub mod efficiency;
23
24#[cfg(feature = "agentfs")]
25pub mod external;
26
27#[cfg(test)]
28pub mod tests;
29
30// Re-export for convenience
31pub use adaptive::AdaptiveRewardCalculator;
32pub use domain_stats::{DomainStatistics, DomainStatisticsCache};
33
34use crate::episode::Episode;
35use crate::types::{ComplexityLevel, RewardScore, TaskOutcome};
36use tracing::{debug, instrument};
37
38/// Threshold for "efficient" episode duration (in seconds)
39const EFFICIENT_DURATION_SECS: f32 = 60.0;
40
41/// Threshold for "efficient" step count
42const EFFICIENT_STEP_COUNT: usize = 10;
43
44/// Maximum efficiency multiplier
45const MAX_EFFICIENCY_MULTIPLIER: f32 = 1.5;
46
47/// Minimum efficiency multiplier
48const MIN_EFFICIENCY_MULTIPLIER: f32 = 0.5;
49
50/// Calculator for episode reward scores
51#[derive(Clone)]
52pub struct RewardCalculator {
53    /// Weight for duration in efficiency calculation
54    duration_weight: f32,
55    /// Weight for step count in efficiency calculation
56    step_count_weight: f32,
57}
58
59impl Default for RewardCalculator {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl RewardCalculator {
66    /// Create a new reward calculator with default weights
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            duration_weight: 0.5,
71            step_count_weight: 0.5,
72        }
73    }
74
75    /// Create a calculator with custom weights
76    #[must_use]
77    pub fn with_weights(duration_weight: f32, step_count_weight: f32) -> Self {
78        Self {
79            duration_weight,
80            step_count_weight,
81        }
82    }
83
84    /// Calculate reward score for an episode
85    #[instrument(skip(self, episode), fields(episode_id = %episode.episode_id))]
86    pub fn calculate(&self, episode: &Episode) -> RewardScore {
87        let base = self.calculate_base_reward(episode);
88        let efficiency = self.calculate_efficiency_multiplier(episode);
89        let complexity_bonus = self.calculate_complexity_bonus(episode);
90        let quality_multiplier = self.calculate_quality_multiplier(episode);
91        let learning_bonus = self.calculate_learning_bonus(episode);
92        let abstention_score = efficiency::calculate_abstention_score(episode);
93        let total = ((base + abstention_score).max(0.0)
94            * efficiency
95            * complexity_bonus
96            * quality_multiplier)
97            + learning_bonus;
98        debug!(base = base, total = total, "Calculated reward score");
99        RewardScore {
100            total,
101            base,
102            efficiency,
103            complexity_bonus,
104            quality_multiplier,
105            learning_bonus,
106            abstention_score,
107            raw_reward: total,
108            normalized_reward: total,
109            decayed_reward: total,
110            effective_reward: total,
111        }
112    }
113
114    /// Calculate base reward from outcome
115    fn calculate_base_reward(&self, episode: &Episode) -> f32 {
116        match &episode.outcome {
117            Some(TaskOutcome::Success { .. }) => 1.0,
118            Some(TaskOutcome::PartialSuccess {
119                completed, failed, ..
120            }) => {
121                // Proportional reward based on completion ratio
122                let total = completed.len() + failed.len();
123                if total == 0 {
124                    0.5 // Default for partial success with no specifics
125                } else {
126                    completed.len() as f32 / total as f32
127                }
128            }
129            Some(TaskOutcome::Failure { .. }) => 0.0,
130            Some(TaskOutcome::Abstained { .. }) => 0.3,
131            None => 0.0, // Not completed
132        }
133    }
134
135    /// Calculate efficiency multiplier based on duration and step count
136    fn calculate_efficiency_multiplier(&self, episode: &Episode) -> f32 {
137        let duration_score = self.calculate_duration_efficiency(episode);
138        let step_count_score = self.calculate_step_count_efficiency(episode);
139
140        let combined =
141            (duration_score * self.duration_weight) + (step_count_score * self.step_count_weight);
142
143        // Clamp to reasonable bounds
144        combined.clamp(MIN_EFFICIENCY_MULTIPLIER, MAX_EFFICIENCY_MULTIPLIER)
145    }
146
147    /// Calculate duration efficiency score
148    fn calculate_duration_efficiency(&self, episode: &Episode) -> f32 {
149        if let Some(duration) = episode.duration() {
150            let duration_secs = duration.num_seconds() as f32;
151
152            if duration_secs <= 0.0 {
153                return MAX_EFFICIENCY_MULTIPLIER;
154            }
155
156            // Efficiency decreases as duration increases
157            // Exponential decay: e^(-x/threshold)
158            let ratio = duration_secs / EFFICIENT_DURATION_SECS;
159            let score = (-ratio / 2.0).exp();
160
161            // Map to multiplier range
162            MIN_EFFICIENCY_MULTIPLIER
163                + (score * (MAX_EFFICIENCY_MULTIPLIER - MIN_EFFICIENCY_MULTIPLIER))
164        } else {
165            1.0 // Default if no duration
166        }
167    }
168
169    /// Calculate step count efficiency score
170    fn calculate_step_count_efficiency(&self, episode: &Episode) -> f32 {
171        let step_count = episode.steps.len();
172
173        if step_count == 0 {
174            return MIN_EFFICIENCY_MULTIPLIER;
175        }
176
177        // Efficiency decreases as step count increases
178        let ratio = step_count as f32 / EFFICIENT_STEP_COUNT as f32;
179        let score = (-ratio / 2.0).exp();
180
181        // Map to multiplier range
182        MIN_EFFICIENCY_MULTIPLIER
183            + (score * (MAX_EFFICIENCY_MULTIPLIER - MIN_EFFICIENCY_MULTIPLIER))
184    }
185
186    /// Calculate complexity bonus multiplier
187    fn calculate_complexity_bonus(&self, episode: &Episode) -> f32 {
188        match episode.context.complexity {
189            ComplexityLevel::Simple => 1.0,
190            ComplexityLevel::Moderate => 1.1,
191            ComplexityLevel::Complex => 1.2,
192        }
193    }
194
195    /// Calculate quality multiplier based on code quality metrics
196    ///
197    /// Analyzes artifacts and execution quality to determine a multiplier.
198    /// Factors include:
199    /// - Test coverage (detected from artifacts)
200    /// - Code quality indicators (linting, formatting)
201    /// - Error handling quality (low error rate)
202    fn calculate_quality_multiplier(&self, episode: &Episode) -> f32 {
203        let mut quality: f32 = 1.0;
204
205        // Analyze artifacts for quality indicators
206        if let Some(TaskOutcome::Success { artifacts, .. }) = &episode.outcome {
207            // Bonus for test coverage artifacts
208            let has_test_coverage = artifacts
209                .iter()
210                .any(|a| a.contains("coverage") || a.contains("test"));
211            if has_test_coverage {
212                quality += 0.1;
213            }
214
215            // Bonus for multiple quality artifacts (docs, tests, etc.)
216            if artifacts.len() >= 3 {
217                quality += 0.05;
218            }
219
220            // Check for quality-related metadata
221            if let Some(coverage_str) = episode.metadata.get("test_coverage") {
222                if let Ok(coverage) = coverage_str.parse::<f32>() {
223                    // Bonus for high test coverage (>80%)
224                    #[allow(clippy::excessive_nesting)]
225                    if coverage > 80.0 {
226                        quality += 0.15;
227                    } else if coverage > 60.0 {
228                        quality += 0.1;
229                    }
230                }
231            }
232        }
233
234        // Quality based on error handling
235        let total_steps = episode.steps.len();
236        if total_steps > 0 {
237            let error_rate = episode.failed_steps_count() as f32 / total_steps as f32;
238
239            // Penalize high error rates
240            if error_rate > 0.3 {
241                quality -= 0.2;
242            } else if error_rate > 0.1 {
243                quality -= 0.1;
244            } else if error_rate == 0.0 {
245                // Bonus for zero errors
246                quality += 0.1;
247            }
248        }
249
250        // Check for linting/formatting indicators
251        if episode.metadata.contains_key("clippy_warnings") {
252            if let Some(warnings) = episode.metadata.get("clippy_warnings") {
253                if warnings == "0" {
254                    quality += 0.05;
255                }
256            }
257        }
258
259        // Clamp to reasonable bounds (0.5 to 1.5)
260        quality.clamp(0.5, 1.5)
261    }
262
263    /// Calculate learning bonus for discovering patterns and improvements
264    ///
265    /// Awards bonus points for:
266    /// - Discovering new patterns (novel approaches)
267    /// - Improving on past attempts (learning from history)
268    /// - Efficient problem-solving (first-time success)
269    fn calculate_learning_bonus(&self, episode: &Episode) -> f32 {
270        let mut bonus = 0.0;
271
272        // Bonus for discovering new patterns
273        let pattern_count = episode.patterns.len();
274        if pattern_count > 0 {
275            // More patterns = more learning
276            bonus += (pattern_count as f32 * 0.1).min(0.3);
277        }
278
279        // Bonus for novel tool sequences
280        if let Some(novelty) = self.calculate_novelty_bonus(episode) {
281            bonus += novelty;
282        }
283
284        // Bonus for efficient problem solving (high success rate)
285        let total_steps = episode.steps.len();
286        if total_steps > 0 {
287            let success_rate = episode.successful_steps_count() as f32 / total_steps as f32;
288
289            if success_rate > 0.9 && total_steps >= 5 {
290                // High reliability with meaningful complexity
291                bonus += 0.2;
292            } else if success_rate == 1.0 && total_steps >= 3 {
293                // Perfect execution
294                bonus += 0.15;
295            }
296        }
297
298        // Bonus for error recovery (learning from failures)
299        if self.detect_error_recovery(episode) {
300            bonus += 0.15;
301        }
302
303        // Bonus for optimization (completing quickly with few steps)
304        if let Some(duration) = episode.duration() {
305            let duration_secs = duration.num_seconds() as f32;
306            if duration_secs < 30.0 && total_steps > 0 && total_steps < 10 {
307                bonus += 0.1;
308            }
309        }
310
311        // Cap learning bonus
312        bonus.min(0.5)
313    }
314
315    /// Calculate novelty bonus for unique tool combinations
316    fn calculate_novelty_bonus(&self, episode: &Episode) -> Option<f32> {
317        if episode.steps.len() < 3 {
318            return None;
319        }
320
321        // Count unique tools used
322        let unique_tools: std::collections::HashSet<_> =
323            episode.steps.iter().map(|s| &s.tool).collect();
324
325        // Bonus for diverse tool usage
326        if unique_tools.len() >= 5 {
327            Some(0.15)
328        } else if unique_tools.len() >= 3 {
329            Some(0.1)
330        } else {
331            None
332        }
333    }
334
335    /// Detect if the episode shows error recovery
336    fn detect_error_recovery(&self, episode: &Episode) -> bool {
337        for i in 0..episode.steps.len().saturating_sub(1) {
338            let current = &episode.steps[i];
339            let next = &episode.steps[i + 1];
340
341            // Error followed by success = recovery
342            if !current.is_success() && next.is_success() {
343                return true;
344            }
345        }
346        false
347    }
348
349    /// Calculate adoption bonus for patterns that were recommended AND applied AND succeeded.
350    ///
351    /// This bonus rewards episodes where the agent successfully applied recommended patterns.
352    /// The bonus is proportional to the number of successfully adopted patterns.
353    ///
354    /// # Arguments
355    ///
356    /// * `applied_pattern_ids` - Pattern IDs that were actually applied
357    /// * `outcome_success` - Whether the episode outcome was successful
358    ///
359    /// # Returns
360    ///
361    /// Bonus value between 0.0 and 0.3 (max 30% bonus for 3+ successful adoptions)
362    ///
363    /// # Example
364    ///
365    /// ```
366    /// use do_memory_core::reward::RewardCalculator;
367    ///
368    /// let calculator = RewardCalculator::new();
369    ///
370    /// // 2 patterns applied successfully
371    /// let bonus = calculator.calculate_adoption_bonus(
372    ///     &["p1".to_string(), "p2".to_string()],
373    ///     true
374    /// );
375    /// assert!(bonus > 0.0);
376    ///
377    /// // No bonus for failed outcome
378    /// let no_bonus = calculator.calculate_adoption_bonus(
379    ///     &["p1".to_string()],
380    ///     false
381    /// );
382    /// assert_eq!(no_bonus, 0.0);
383    /// ```
384    #[must_use]
385    pub fn calculate_adoption_bonus(
386        &self,
387        applied_pattern_ids: &[String],
388        outcome_success: bool,
389    ) -> f32 {
390        if !outcome_success || applied_pattern_ids.is_empty() {
391            return 0.0;
392        }
393
394        // Bonus scales with number of successfully adopted patterns
395        // 1 pattern = 0.1, 2 patterns = 0.2, 3+ patterns = 0.3 (capped)
396        let pattern_count = applied_pattern_ids.len();
397        (pattern_count as f32 * 0.1).min(0.3)
398    }
399}
400
401#[cfg(test)]
402mod adoption_bonus_tests {
403    use super::*;
404
405    #[test]
406    fn test_adoption_bonus_no_patterns() {
407        let calc = RewardCalculator::new();
408        let bonus = calc.calculate_adoption_bonus(&[], true);
409        assert_eq!(bonus, 0.0);
410    }
411
412    #[test]
413    fn test_adoption_bonus_failed_outcome() {
414        let calc = RewardCalculator::new();
415        let bonus = calc.calculate_adoption_bonus(&["p1".to_string()], false);
416        assert_eq!(bonus, 0.0);
417    }
418
419    #[test]
420    fn test_adoption_bonus_single_pattern() {
421        let calc = RewardCalculator::new();
422        let bonus = calc.calculate_adoption_bonus(&["p1".to_string()], true);
423        assert!((bonus - 0.1).abs() < 0.01);
424    }
425
426    #[test]
427    fn test_adoption_bonus_multiple_patterns() {
428        let calc = RewardCalculator::new();
429        let bonus = calc.calculate_adoption_bonus(
430            &["p1".to_string(), "p2".to_string(), "p3".to_string()],
431            true,
432        );
433        assert!((bonus - 0.3).abs() < 0.01); // Capped at 0.3
434    }
435}