do_memory_core/reward/
mod.rs1#![allow(clippy::if_not_else)]
13#![allow(clippy::cast_precision_loss)]
14#![allow(clippy::map_unwrap_or)]
15#![allow(clippy::doc_markdown)]
16
17pub 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
30pub 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
38const EFFICIENT_DURATION_SECS: f32 = 60.0;
40
41const EFFICIENT_STEP_COUNT: usize = 10;
43
44const MAX_EFFICIENCY_MULTIPLIER: f32 = 1.5;
46
47const MIN_EFFICIENCY_MULTIPLIER: f32 = 0.5;
49
50#[derive(Clone)]
52pub struct RewardCalculator {
53 duration_weight: f32,
55 step_count_weight: f32,
57}
58
59impl Default for RewardCalculator {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl RewardCalculator {
66 #[must_use]
68 pub fn new() -> Self {
69 Self {
70 duration_weight: 0.5,
71 step_count_weight: 0.5,
72 }
73 }
74
75 #[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 #[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 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 let total = completed.len() + failed.len();
123 if total == 0 {
124 0.5 } 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, }
133 }
134
135 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 combined.clamp(MIN_EFFICIENCY_MULTIPLIER, MAX_EFFICIENCY_MULTIPLIER)
145 }
146
147 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 let ratio = duration_secs / EFFICIENT_DURATION_SECS;
159 let score = (-ratio / 2.0).exp();
160
161 MIN_EFFICIENCY_MULTIPLIER
163 + (score * (MAX_EFFICIENCY_MULTIPLIER - MIN_EFFICIENCY_MULTIPLIER))
164 } else {
165 1.0 }
167 }
168
169 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 let ratio = step_count as f32 / EFFICIENT_STEP_COUNT as f32;
179 let score = (-ratio / 2.0).exp();
180
181 MIN_EFFICIENCY_MULTIPLIER
183 + (score * (MAX_EFFICIENCY_MULTIPLIER - MIN_EFFICIENCY_MULTIPLIER))
184 }
185
186 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 fn calculate_quality_multiplier(&self, episode: &Episode) -> f32 {
203 let mut quality: f32 = 1.0;
204
205 if let Some(TaskOutcome::Success { artifacts, .. }) = &episode.outcome {
207 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 if artifacts.len() >= 3 {
217 quality += 0.05;
218 }
219
220 if let Some(coverage_str) = episode.metadata.get("test_coverage") {
222 if let Ok(coverage) = coverage_str.parse::<f32>() {
223 #[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 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 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 quality += 0.1;
247 }
248 }
249
250 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 quality.clamp(0.5, 1.5)
261 }
262
263 fn calculate_learning_bonus(&self, episode: &Episode) -> f32 {
270 let mut bonus = 0.0;
271
272 let pattern_count = episode.patterns.len();
274 if pattern_count > 0 {
275 bonus += (pattern_count as f32 * 0.1).min(0.3);
277 }
278
279 if let Some(novelty) = self.calculate_novelty_bonus(episode) {
281 bonus += novelty;
282 }
283
284 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 bonus += 0.2;
292 } else if success_rate == 1.0 && total_steps >= 3 {
293 bonus += 0.15;
295 }
296 }
297
298 if self.detect_error_recovery(episode) {
300 bonus += 0.15;
301 }
302
303 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 bonus.min(0.5)
313 }
314
315 fn calculate_novelty_bonus(&self, episode: &Episode) -> Option<f32> {
317 if episode.steps.len() < 3 {
318 return None;
319 }
320
321 let unique_tools: std::collections::HashSet<_> =
323 episode.steps.iter().map(|s| &s.tool).collect();
324
325 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 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 if !current.is_success() && next.is_success() {
343 return true;
344 }
345 }
346 false
347 }
348
349 #[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 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); }
435}