Skip to main content

velesdb_core/agent/
reinforcement.rs

1//! Adaptive reinforcement strategies for `ProceduralMemory`.
2//!
3//! Provides extensible strategies for updating procedure confidence:
4//! - `FixedRate`: Simple fixed increment/decrement (default behavior)
5//! - `AdaptiveLearningRate`: Adjusts rate based on usage history
6//! - `TemporalDecay`: Older procedures lose relevance over time
7//! - `ContextualReinforcement`: Considers usage context for updates
8
9// Reason: Numeric casts in reinforcement are intentional:
10// - u64->f32 casts for usage counts in decay calculations (approximate)
11// - Values bounded by practical limits (usage counts, time periods)
12// - Precision loss acceptable for confidence heuristics
13#![allow(clippy::cast_precision_loss)]
14
15use std::collections::HashMap;
16
17/// Context information for reinforcement decisions.
18#[derive(Debug, Clone, Default)]
19pub struct ReinforcementContext {
20    /// Number of times the procedure has been used.
21    pub usage_count: u64,
22    /// Timestamp of last usage (Unix seconds).
23    pub last_used: u64,
24    /// Timestamp when the procedure was created (Unix seconds).
25    pub created_at: u64,
26    /// Current timestamp (Unix seconds).
27    pub current_time: u64,
28    /// Recent success rate (0.0 - 1.0).
29    pub recent_success_rate: Option<f32>,
30    /// Custom context data.
31    pub custom: HashMap<String, f64>,
32}
33
34impl ReinforcementContext {
35    /// Creates a new context with the current timestamp.
36    #[must_use]
37    pub fn new() -> Self {
38        Self {
39            current_time: std::time::SystemTime::now()
40                .duration_since(std::time::UNIX_EPOCH)
41                .map_or(0, |d| d.as_secs()),
42            ..Default::default()
43        }
44    }
45
46    /// Sets the usage count.
47    #[must_use]
48    pub fn with_usage_count(mut self, count: u64) -> Self {
49        self.usage_count = count;
50        self
51    }
52
53    /// Sets the last used timestamp.
54    #[must_use]
55    pub fn with_last_used(mut self, timestamp: u64) -> Self {
56        self.last_used = timestamp;
57        self
58    }
59
60    /// Sets the creation timestamp.
61    #[must_use]
62    pub fn with_created_at(mut self, timestamp: u64) -> Self {
63        self.created_at = timestamp;
64        self
65    }
66
67    /// Sets the recent success rate.
68    #[must_use]
69    pub fn with_success_rate(mut self, rate: f32) -> Self {
70        self.recent_success_rate = Some(rate);
71        self
72    }
73
74    /// Adds custom context data.
75    #[must_use]
76    pub fn with_custom(mut self, key: &str, value: f64) -> Self {
77        self.custom.insert(key.to_string(), value);
78        self
79    }
80
81    /// Returns the age of the procedure in seconds.
82    #[must_use]
83    pub fn age_seconds(&self) -> u64 {
84        self.current_time.saturating_sub(self.created_at)
85    }
86
87    /// Returns the time since last use in seconds.
88    #[must_use]
89    pub fn time_since_last_use(&self) -> u64 {
90        self.current_time.saturating_sub(self.last_used)
91    }
92}
93
94/// Trait for reinforcement strategies.
95///
96/// Implement this trait to create custom reinforcement behaviors.
97pub trait ReinforcementStrategy: Send + Sync {
98    /// Updates the confidence score based on success/failure.
99    ///
100    /// # Arguments
101    ///
102    /// * `old_confidence` - Current confidence score (0.0 - 1.0)
103    /// * `success` - Whether the procedure execution was successful
104    /// * `context` - Additional context for the decision
105    ///
106    /// # Returns
107    ///
108    /// New confidence score, clamped to [0.0, 1.0].
109    fn update_confidence(
110        &self,
111        old_confidence: f32,
112        success: bool,
113        context: &ReinforcementContext,
114    ) -> f32;
115
116    /// Returns the name of this strategy.
117    fn name(&self) -> &'static str;
118}
119
120/// Fixed rate reinforcement strategy.
121///
122/// Simple strategy with fixed increment on success and decrement on failure.
123/// This is the default behavior matching the original implementation.
124#[derive(Debug, Clone)]
125pub struct FixedRate {
126    /// Confidence increase on success.
127    pub success_delta: f32,
128    /// Confidence decrease on failure (positive value).
129    pub failure_delta: f32,
130}
131
132impl Default for FixedRate {
133    fn default() -> Self {
134        Self {
135            success_delta: 0.1,
136            failure_delta: 0.05,
137        }
138    }
139}
140
141impl FixedRate {
142    /// Creates a new fixed rate strategy with custom deltas.
143    #[must_use]
144    pub fn new(success_delta: f32, failure_delta: f32) -> Self {
145        Self {
146            success_delta,
147            failure_delta,
148        }
149    }
150}
151
152impl ReinforcementStrategy for FixedRate {
153    fn update_confidence(
154        &self,
155        old_confidence: f32,
156        success: bool,
157        _context: &ReinforcementContext,
158    ) -> f32 {
159        let new_confidence = if success {
160            old_confidence + self.success_delta
161        } else {
162            old_confidence - self.failure_delta
163        };
164        new_confidence.clamp(0.0, 1.0)
165    }
166
167    fn name(&self) -> &'static str {
168        "FixedRate"
169    }
170}
171
172/// Adaptive learning rate strategy.
173///
174/// Adjusts the learning rate based on usage history:
175/// - New procedures (low usage) have higher learning rates
176/// - Well-established procedures (high usage) have lower learning rates
177#[derive(Debug, Clone)]
178pub struct AdaptiveLearningRate {
179    /// Base learning rate for success.
180    pub base_success_rate: f32,
181    /// Base learning rate for failure.
182    pub base_failure_rate: f32,
183    /// Usage count at which learning rate is halved.
184    pub half_life_usage: u64,
185    /// Minimum learning rate multiplier.
186    pub min_rate_multiplier: f32,
187}
188
189impl Default for AdaptiveLearningRate {
190    fn default() -> Self {
191        Self {
192            base_success_rate: 0.2,
193            base_failure_rate: 0.1,
194            half_life_usage: 10,
195            min_rate_multiplier: 0.1,
196        }
197    }
198}
199
200impl AdaptiveLearningRate {
201    /// Calculates the learning rate multiplier based on usage count.
202    fn rate_multiplier(&self, usage_count: u64) -> f32 {
203        let half_life = self.half_life_usage.max(1);
204        let decay = 0.5_f32.powf(usage_count as f32 / half_life as f32);
205        decay.max(self.min_rate_multiplier)
206    }
207}
208
209impl ReinforcementStrategy for AdaptiveLearningRate {
210    fn update_confidence(
211        &self,
212        old_confidence: f32,
213        success: bool,
214        context: &ReinforcementContext,
215    ) -> f32 {
216        let multiplier = self.rate_multiplier(context.usage_count);
217
218        let delta = if success {
219            self.base_success_rate * multiplier
220        } else {
221            -self.base_failure_rate * multiplier
222        };
223
224        (old_confidence + delta).clamp(0.0, 1.0)
225    }
226
227    fn name(&self) -> &'static str {
228        "AdaptiveLearningRate"
229    }
230}
231
232/// Temporal decay strategy.
233///
234/// Older procedures gradually lose confidence over time.
235/// Useful for domains where knowledge becomes stale.
236#[derive(Debug, Clone)]
237pub struct TemporalDecay {
238    /// Base reinforcement rates.
239    pub base: FixedRate,
240    /// Half-life for confidence decay in seconds.
241    pub decay_half_life: u64,
242    /// Maximum decay per update (prevents sudden drops).
243    pub max_decay_per_update: f32,
244}
245
246impl Default for TemporalDecay {
247    fn default() -> Self {
248        Self {
249            base: FixedRate::default(),
250            decay_half_life: 30 * 24 * 60 * 60, // 30 days
251            max_decay_per_update: 0.1,
252        }
253    }
254}
255
256impl TemporalDecay {
257    /// Creates a new temporal decay strategy.
258    #[must_use]
259    pub fn new(decay_half_life_days: u64) -> Self {
260        Self {
261            decay_half_life: decay_half_life_days * 24 * 60 * 60,
262            ..Default::default()
263        }
264    }
265
266    /// Calculates the decay factor based on time since last use.
267    fn decay_factor(&self, time_since_last_use: u64) -> f32 {
268        let half_life = self.decay_half_life.max(1);
269        let decay = 0.5_f32.powf(time_since_last_use as f32 / half_life as f32);
270        (1.0 - decay).min(self.max_decay_per_update)
271    }
272}
273
274impl ReinforcementStrategy for TemporalDecay {
275    fn update_confidence(
276        &self,
277        old_confidence: f32,
278        success: bool,
279        context: &ReinforcementContext,
280    ) -> f32 {
281        let time_decay = self.decay_factor(context.time_since_last_use());
282        let decayed_confidence = old_confidence * (1.0 - time_decay);
283
284        let new_confidence = if success {
285            decayed_confidence + self.base.success_delta
286        } else {
287            decayed_confidence - self.base.failure_delta
288        };
289
290        new_confidence.clamp(0.0, 1.0)
291    }
292
293    fn name(&self) -> &'static str {
294        "TemporalDecay"
295    }
296}
297
298/// Contextual reinforcement strategy.
299///
300/// Considers multiple context factors for confidence updates:
301/// - Recent success rate affects learning direction
302/// - Usage frequency affects learning magnitude
303/// - Time since last use affects decay
304#[derive(Debug, Clone)]
305pub struct ContextualReinforcement {
306    /// Weight for success rate influence (0.0 - 1.0).
307    pub success_rate_weight: f32,
308    /// Weight for usage frequency influence (0.0 - 1.0).
309    pub usage_weight: f32,
310    /// Weight for recency influence (0.0 - 1.0).
311    pub recency_weight: f32,
312    /// Base learning rate.
313    pub base_rate: f32,
314}
315
316impl Default for ContextualReinforcement {
317    fn default() -> Self {
318        Self {
319            success_rate_weight: 0.3,
320            usage_weight: 0.3,
321            recency_weight: 0.4,
322            base_rate: 0.15,
323        }
324    }
325}
326
327impl ContextualReinforcement {
328    /// Calculates the recency factor (higher for recently used procedures).
329    fn recency_factor(time_since_last_use: u64) -> f32 {
330        let hours = time_since_last_use as f32 / 3600.0;
331        (-hours / 168.0).exp() // Decay over ~1 week
332    }
333
334    /// Calculates the usage factor (higher for frequently used procedures).
335    fn usage_factor(usage_count: u64) -> f32 {
336        let normalized = (usage_count as f32).ln_1p() / 10.0;
337        normalized.min(1.0)
338    }
339}
340
341impl ReinforcementStrategy for ContextualReinforcement {
342    fn update_confidence(
343        &self,
344        old_confidence: f32,
345        success: bool,
346        context: &ReinforcementContext,
347    ) -> f32 {
348        let recency = Self::recency_factor(context.time_since_last_use());
349        let usage = Self::usage_factor(context.usage_count);
350        let success_rate = context.recent_success_rate.unwrap_or(0.5);
351
352        let context_score = self.recency_weight * recency
353            + self.usage_weight * usage
354            + self.success_rate_weight * success_rate;
355
356        let effective_rate = self.base_rate * (0.5 + context_score);
357
358        let delta = if success {
359            effective_rate
360        } else {
361            -effective_rate * 0.5
362        };
363
364        (old_confidence + delta).clamp(0.0, 1.0)
365    }
366
367    fn name(&self) -> &'static str {
368        "ContextualReinforcement"
369    }
370}
371
372/// Composite strategy that combines multiple strategies.
373///
374/// Useful for complex reinforcement policies.
375pub struct CompositeStrategy {
376    strategies: Vec<(Box<dyn ReinforcementStrategy>, f32)>,
377}
378
379impl CompositeStrategy {
380    /// Creates a new composite strategy.
381    #[must_use]
382    pub fn new() -> Self {
383        Self {
384            strategies: Vec::new(),
385        }
386    }
387
388    /// Adds a strategy with a weight.
389    ///
390    /// Weights are normalized when calculating the final confidence.
391    #[must_use]
392    pub fn add_strategy<S: ReinforcementStrategy + 'static>(
393        mut self,
394        strategy: S,
395        weight: f32,
396    ) -> Self {
397        self.strategies.push((Box::new(strategy), weight));
398        self
399    }
400}
401
402impl Default for CompositeStrategy {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408impl ReinforcementStrategy for CompositeStrategy {
409    fn update_confidence(
410        &self,
411        old_confidence: f32,
412        success: bool,
413        context: &ReinforcementContext,
414    ) -> f32 {
415        if self.strategies.is_empty() {
416            return old_confidence;
417        }
418
419        let total_weight: f32 = self.strategies.iter().map(|(_, w)| w).sum();
420        if total_weight == 0.0 {
421            return old_confidence;
422        }
423
424        let weighted_sum: f32 = self
425            .strategies
426            .iter()
427            .map(|(strategy, weight)| {
428                strategy.update_confidence(old_confidence, success, context) * weight
429            })
430            .sum();
431
432        (weighted_sum / total_weight).clamp(0.0, 1.0)
433    }
434
435    fn name(&self) -> &'static str {
436        "CompositeStrategy"
437    }
438}
439
440/// Rescorla-Wagner diminishing-returns reinforcement strategy (ACT-R Phase 2).
441///
442/// Adjusts the confidence delta based on practice history, giving early
443/// reinforcements maximal impact while later ones taper off:
444///
445/// `Δ = base_delta / (1 + k × success_count)` on success,
446/// `Δ = base_delta / (1 + k × failure_count)` on failure.
447///
448/// Reference: Rescorla & Wagner (1972), "A Theory of Pavlovian Conditioning",
449/// in *Classical Conditioning II: Current Research and Theory*, pp. 64-99.
450#[derive(Debug, Clone)]
451pub struct DiminishingReturns {
452    /// Base confidence delta on success.
453    pub base_success_delta: f32,
454    /// Base confidence delta on failure (positive value; subtracted).
455    pub base_failure_delta: f32,
456    /// Diminishing-returns coefficient k (default 0.1).
457    /// Higher values compress the learning curve more aggressively.
458    pub k: f32,
459}
460
461impl Default for DiminishingReturns {
462    fn default() -> Self {
463        Self {
464            base_success_delta: 0.1,
465            base_failure_delta: 0.05,
466            k: 0.1,
467        }
468    }
469}
470
471impl DiminishingReturns {
472    /// Creates a new strategy with custom base deltas and coefficient.
473    #[must_use]
474    pub fn new(base_success_delta: f32, base_failure_delta: f32, k: f32) -> Self {
475        Self {
476            base_success_delta,
477            base_failure_delta,
478            k,
479        }
480    }
481
482    fn effective_delta(base: f32, count: f32, k: f32) -> f32 {
483        base / (1.0 + k * count)
484    }
485}
486
487impl ReinforcementStrategy for DiminishingReturns {
488    #[allow(clippy::cast_possible_truncation)]
489    // Reason: success/failure counts are bounded by practical usage limits (<10^6);
490    // f64→f32 truncation is operationally irrelevant for confidence heuristics.
491    fn update_confidence(
492        &self,
493        old_confidence: f32,
494        success: bool,
495        context: &ReinforcementContext,
496    ) -> f32 {
497        // Use success_count/failure_count from custom context if available,
498        // otherwise approximate from usage_count × recent_success_rate.
499        let (s_count, f_count) = if let (Some(&sc), Some(&fc)) = (
500            context.custom.get("success_count"),
501            context.custom.get("failure_count"),
502        ) {
503            (sc as f32, fc as f32)
504        } else {
505            let rate = context.recent_success_rate.unwrap_or(0.5);
506            let uses = context.usage_count as f32;
507            (uses * rate, uses * (1.0 - rate))
508        };
509
510        let delta = if success {
511            Self::effective_delta(self.base_success_delta, s_count, self.k)
512        } else {
513            -Self::effective_delta(self.base_failure_delta, f_count, self.k)
514        };
515
516        (old_confidence + delta).clamp(0.0, 1.0)
517    }
518
519    fn name(&self) -> &'static str {
520        "DiminishingReturns"
521    }
522}
523
524/// Applies ACT-R base-level power-law activation decay.
525///
526/// Returns `confidence × max(1, t_days)^(-d)` where:
527/// - `t_days` is time since last use in days
528/// - `d` is the decay exponent (ACT-R default ≈ 0.5, Anderson 1996)
529///
530/// Use this during recall (not reinforce) to dynamically reduce the
531/// *effective* confidence of long-unused procedures without modifying
532/// the stored value.
533///
534/// Reference: Anderson (1996), "ACT-R: A Theory of Higher Level Cognition",
535/// *Human-Computer Interaction* 12(4), 439-462.
536#[must_use]
537pub fn power_law_decay(confidence: f32, time_since_last_use_secs: u64, decay_exponent: f32) -> f32 {
538    let days = time_since_last_use_secs as f32 / 86_400.0;
539    let multiplier = days.max(1.0).powf(-decay_exponent);
540    (confidence * multiplier).clamp(0.0, 1.0)
541}
542
543/// Default strategy factory.
544///
545/// Returns the default reinforcement strategy (`FixedRate`).
546#[must_use]
547pub fn default_strategy() -> Box<dyn ReinforcementStrategy> {
548    Box::new(FixedRate::default())
549}