Skip to main content

pulsehive_runtime/intelligence/
context.rs

1//! Context optimization with temporal decay and insights-first priority ordering.
2//!
3//! The [`ContextOptimizer`] computes decayed importance for experiences and assembles
4//! context with the priority: insights > high-importance experiences > recent experiences.
5//! This ensures agents receive the most relevant, consolidated knowledge.
6
7use pulsedb::{Activity, DerivedInsight, Experience, Timestamp};
8use pulsehive_core::context::{estimate_tokens, ContextBudget};
9use pulsehive_core::llm::Message;
10
11/// Configuration for the context optimizer.
12#[derive(Debug, Clone)]
13pub struct ContextOptimizerConfig {
14    /// Half-life for exponential decay in hours.
15    /// After this many hours, an experience's importance decays to 50%.
16    /// Default: 72.0 (3 days)
17    pub decay_half_life_hours: f32,
18
19    /// Boost per application/reinforcement.
20    /// Each time an experience is applied, its effective importance
21    /// increases by this factor (multiplicative).
22    /// Default: 0.1 (10% per application)
23    pub reinforcement_boost: f32,
24}
25
26impl Default for ContextOptimizerConfig {
27    fn default() -> Self {
28        Self {
29            decay_half_life_hours: 72.0,
30            reinforcement_boost: 0.1,
31        }
32    }
33}
34
35/// Optimizes context assembly with temporal decay and insights-first priority.
36///
37/// Implements FR-020: decayed importance formula with configurable half-life
38/// and reinforcement boost. Context is assembled in priority order:
39/// 1. Insights (consolidated knowledge from clusters)
40/// 2. High-importance experiences (after decay)
41/// 3. Recent experiences (by timestamp)
42pub struct ContextOptimizer {
43    config: ContextOptimizerConfig,
44}
45
46impl ContextOptimizer {
47    /// Create with the given configuration.
48    pub fn new(config: ContextOptimizerConfig) -> Self {
49        Self { config }
50    }
51
52    /// Create with default configuration.
53    pub fn with_defaults() -> Self {
54        Self::new(ContextOptimizerConfig::default())
55    }
56
57    /// Access the configuration.
58    pub fn config(&self) -> &ContextOptimizerConfig {
59        &self.config
60    }
61
62    /// Compute decayed importance for an experience.
63    ///
64    /// Formula (FR-020):
65    /// `importance * 0.5^(elapsed_hours / half_life) * (1 + applications * reinforcement_boost)`
66    pub fn compute_decayed_importance(&self, experience: &Experience, now: Timestamp) -> f32 {
67        let age_hours = (now.0 - experience.timestamp.0) as f32 / (1000.0 * 3600.0);
68        let age_hours = age_hours.max(0.0);
69
70        let decay = 0.5_f32.powf(age_hours / self.config.decay_half_life_hours);
71        let reinforcement =
72            1.0 + (experience.applications as f32 * self.config.reinforcement_boost);
73
74        experience.importance * decay * reinforcement
75    }
76
77    /// Assemble context with insights-first priority ordering.
78    ///
79    /// Priority: insights > high-importance experiences > recent.
80    /// Packs within the given budget and formats as intrinsic knowledge.
81    pub fn assemble_prioritized(
82        &self,
83        experiences: Vec<Experience>,
84        insights: Vec<DerivedInsight>,
85        activities: Vec<Activity>,
86        budget: &ContextBudget,
87        now: Timestamp,
88    ) -> Vec<Message> {
89        let mut parts = Vec::new();
90        let mut token_count: u32 = 0;
91
92        // 1. INSIGHTS FIRST (always prioritized)
93        if !insights.is_empty() {
94            let insight_limit = budget.max_insights.min(insights.len());
95            let mut insight_lines = Vec::new();
96            for insight in insights.iter().take(insight_limit) {
97                let tokens = estimate_tokens(&insight.content);
98                if token_count + tokens > budget.max_tokens {
99                    break;
100                }
101                insight_lines.push(format!("- {}", insight.content));
102                token_count += tokens;
103            }
104            if !insight_lines.is_empty() {
105                parts.push(format!(
106                    "Key insights you've synthesized:\n{}",
107                    insight_lines.join("\n")
108                ));
109            }
110        }
111
112        // 2. EXPERIENCES sorted by decayed importance
113        if !experiences.is_empty() {
114            let mut scored: Vec<(Experience, f32)> = experiences
115                .into_iter()
116                .map(|exp| {
117                    let score = self.compute_decayed_importance(&exp, now);
118                    (exp, score)
119                })
120                .collect();
121            scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
122
123            let mut exp_lines = Vec::new();
124            let exp_limit = budget.max_experiences;
125            for (exp, _score) in scored.into_iter().take(exp_limit) {
126                let tokens = estimate_tokens(&exp.content);
127                if token_count + tokens > budget.max_tokens {
128                    break;
129                }
130                exp_lines.push(format!("- You understand that {}", exp.content));
131                token_count += tokens;
132            }
133            if !exp_lines.is_empty() {
134                parts.push(format!(
135                    "Based on your experience and knowledge:\n{}",
136                    exp_lines.join("\n")
137                ));
138            }
139        }
140
141        // 3. ACTIVITY AWARENESS
142        if !activities.is_empty() {
143            let activity_lines: Vec<String> = activities
144                .iter()
145                .filter_map(|a| {
146                    a.current_task.as_ref().map(|task| {
147                        format!(
148                            "- You're aware that agent {} is working on: {}",
149                            a.agent_id, task
150                        )
151                    })
152                })
153                .collect();
154            if !activity_lines.is_empty() {
155                parts.push(format!(
156                    "Current team activity:\n{}",
157                    activity_lines.join("\n")
158                ));
159            }
160        }
161
162        if parts.is_empty() {
163            return vec![];
164        }
165
166        vec![Message::system(parts.join("\n\n"))]
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn make_experience(importance: f32, age_hours: f32, applications: u32) -> Experience {
175        let now_ms = 1_700_000_000_000_i64;
176        let age_ms = (age_hours * 3600.0 * 1000.0) as i64;
177        Experience {
178            id: pulsedb::ExperienceId::new(),
179            collective_id: pulsedb::CollectiveId::new(),
180            content: format!("Experience with importance {importance}"),
181            embedding: vec![],
182            experience_type: pulsedb::ExperienceType::Generic { category: None },
183            importance,
184            confidence: 0.8,
185            applications,
186            domain: vec![],
187            related_files: vec![],
188            source_agent: pulsedb::AgentId("test".into()),
189            source_task: None,
190            timestamp: Timestamp(now_ms - age_ms),
191            archived: false,
192        }
193    }
194
195    #[test]
196    fn test_72h_decay_to_50_percent() {
197        let opt = ContextOptimizer::with_defaults();
198        let now = Timestamp(1_700_000_000_000);
199        let exp = make_experience(1.0, 72.0, 0);
200        let decayed = opt.compute_decayed_importance(&exp, now);
201        assert!(
202            (decayed - 0.5).abs() < 0.01,
203            "72h decay should be ~0.5, got {decayed}"
204        );
205    }
206
207    #[test]
208    fn test_zero_age_full_importance() {
209        let opt = ContextOptimizer::with_defaults();
210        let now = Timestamp(1_700_000_000_000);
211        let exp = make_experience(0.8, 0.0, 0);
212        let decayed = opt.compute_decayed_importance(&exp, now);
213        assert!(
214            (decayed - 0.8).abs() < 0.01,
215            "Zero age should be full importance, got {decayed}"
216        );
217    }
218
219    #[test]
220    fn test_reinforcement_boost() {
221        let opt = ContextOptimizer::with_defaults();
222        let now = Timestamp(1_700_000_000_000);
223        let exp = make_experience(1.0, 0.0, 5); // 5 applications → 1.5x
224        let decayed = opt.compute_decayed_importance(&exp, now);
225        assert!(
226            (decayed - 1.5).abs() < 0.01,
227            "5 applications should give 1.5x, got {decayed}"
228        );
229    }
230
231    #[test]
232    fn test_config_defaults() {
233        let config = ContextOptimizerConfig::default();
234        assert!((config.decay_half_life_hours - 72.0).abs() < f32::EPSILON);
235        assert!((config.reinforcement_boost - 0.1).abs() < f32::EPSILON);
236    }
237}