pulsehive_runtime/intelligence/
context.rs1use pulsedb::{Activity, DerivedInsight, Experience, Timestamp};
8use pulsehive_core::context::{estimate_tokens, ContextBudget};
9use pulsehive_core::llm::Message;
10
11#[derive(Debug, Clone)]
13pub struct ContextOptimizerConfig {
14 pub decay_half_life_hours: f32,
18
19 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
35pub struct ContextOptimizer {
43 config: ContextOptimizerConfig,
44}
45
46impl ContextOptimizer {
47 pub fn new(config: ContextOptimizerConfig) -> Self {
49 Self { config }
50 }
51
52 pub fn with_defaults() -> Self {
54 Self::new(ContextOptimizerConfig::default())
55 }
56
57 pub fn config(&self) -> &ContextOptimizerConfig {
59 &self.config
60 }
61
62 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 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 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 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 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); 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}