use pulsedb::{Activity, DerivedInsight, Experience, Timestamp};
use pulsehive_core::context::{estimate_tokens, ContextBudget};
use pulsehive_core::llm::Message;
#[derive(Debug, Clone)]
pub struct ContextOptimizerConfig {
pub decay_half_life_hours: f32,
pub reinforcement_boost: f32,
}
impl Default for ContextOptimizerConfig {
fn default() -> Self {
Self {
decay_half_life_hours: 72.0,
reinforcement_boost: 0.1,
}
}
}
pub struct ContextOptimizer {
config: ContextOptimizerConfig,
}
impl ContextOptimizer {
pub fn new(config: ContextOptimizerConfig) -> Self {
Self { config }
}
pub fn with_defaults() -> Self {
Self::new(ContextOptimizerConfig::default())
}
pub fn config(&self) -> &ContextOptimizerConfig {
&self.config
}
pub fn compute_decayed_importance(&self, experience: &Experience, now: Timestamp) -> f32 {
let age_hours = (now.0 - experience.timestamp.0) as f32 / (1000.0 * 3600.0);
let age_hours = age_hours.max(0.0);
let decay = 0.5_f32.powf(age_hours / self.config.decay_half_life_hours);
let reinforcement =
1.0 + (experience.applications as f32 * self.config.reinforcement_boost);
experience.importance * decay * reinforcement
}
pub fn assemble_prioritized(
&self,
experiences: Vec<Experience>,
insights: Vec<DerivedInsight>,
activities: Vec<Activity>,
budget: &ContextBudget,
now: Timestamp,
) -> Vec<Message> {
let mut parts = Vec::new();
let mut token_count: u32 = 0;
if !insights.is_empty() {
let insight_limit = budget.max_insights.min(insights.len());
let mut insight_lines = Vec::new();
for insight in insights.iter().take(insight_limit) {
let tokens = estimate_tokens(&insight.content);
if token_count + tokens > budget.max_tokens {
break;
}
insight_lines.push(format!("- {}", insight.content));
token_count += tokens;
}
if !insight_lines.is_empty() {
parts.push(format!(
"Key insights you've synthesized:\n{}",
insight_lines.join("\n")
));
}
}
if !experiences.is_empty() {
let mut scored: Vec<(Experience, f32)> = experiences
.into_iter()
.map(|exp| {
let score = self.compute_decayed_importance(&exp, now);
(exp, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut exp_lines = Vec::new();
let exp_limit = budget.max_experiences;
for (exp, _score) in scored.into_iter().take(exp_limit) {
let tokens = estimate_tokens(&exp.content);
if token_count + tokens > budget.max_tokens {
break;
}
exp_lines.push(format!("- You understand that {}", exp.content));
token_count += tokens;
}
if !exp_lines.is_empty() {
parts.push(format!(
"Based on your experience and knowledge:\n{}",
exp_lines.join("\n")
));
}
}
if !activities.is_empty() {
let activity_lines: Vec<String> = activities
.iter()
.filter_map(|a| {
a.current_task.as_ref().map(|task| {
format!(
"- You're aware that agent {} is working on: {}",
a.agent_id, task
)
})
})
.collect();
if !activity_lines.is_empty() {
parts.push(format!(
"Current team activity:\n{}",
activity_lines.join("\n")
));
}
}
if parts.is_empty() {
return vec![];
}
vec![Message::system(parts.join("\n\n"))]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_experience(importance: f32, age_hours: f32, applications: u32) -> Experience {
let now_ms = 1_700_000_000_000_i64;
let age_ms = (age_hours * 3600.0 * 1000.0) as i64;
Experience {
id: pulsedb::ExperienceId::new(),
collective_id: pulsedb::CollectiveId::new(),
content: format!("Experience with importance {importance}"),
embedding: vec![],
experience_type: pulsedb::ExperienceType::Generic { category: None },
importance,
confidence: 0.8,
applications,
domain: vec![],
related_files: vec![],
source_agent: pulsedb::AgentId("test".into()),
source_task: None,
timestamp: Timestamp(now_ms - age_ms),
archived: false,
}
}
#[test]
fn test_72h_decay_to_50_percent() {
let opt = ContextOptimizer::with_defaults();
let now = Timestamp(1_700_000_000_000);
let exp = make_experience(1.0, 72.0, 0);
let decayed = opt.compute_decayed_importance(&exp, now);
assert!(
(decayed - 0.5).abs() < 0.01,
"72h decay should be ~0.5, got {decayed}"
);
}
#[test]
fn test_zero_age_full_importance() {
let opt = ContextOptimizer::with_defaults();
let now = Timestamp(1_700_000_000_000);
let exp = make_experience(0.8, 0.0, 0);
let decayed = opt.compute_decayed_importance(&exp, now);
assert!(
(decayed - 0.8).abs() < 0.01,
"Zero age should be full importance, got {decayed}"
);
}
#[test]
fn test_reinforcement_boost() {
let opt = ContextOptimizer::with_defaults();
let now = Timestamp(1_700_000_000_000);
let exp = make_experience(1.0, 0.0, 5); let decayed = opt.compute_decayed_importance(&exp, now);
assert!(
(decayed - 1.5).abs() < 0.01,
"5 applications should give 1.5x, got {decayed}"
);
}
#[test]
fn test_config_defaults() {
let config = ContextOptimizerConfig::default();
assert!((config.decay_half_life_hours - 72.0).abs() < f32::EPSILON);
assert!((config.reinforcement_boost - 0.1).abs() < f32::EPSILON);
}
}