use std::collections::VecDeque;
use std::sync::Mutex;
use super::traits::{LearningBackend, LearningSignal, Trajectory};
pub struct NoopLearner;
impl NoopLearner {
pub fn new() -> Self {
Self
}
}
impl Default for NoopLearner {
fn default() -> Self {
Self::new()
}
}
impl LearningBackend for NoopLearner {
fn record(&self, _trajectory: &Trajectory) {
}
fn adapt(&self, _signal: &LearningSignal) {
}
}
#[derive(Debug, Clone)]
pub struct TrajectoryLearnerConfig {
pub max_trajectories: usize,
pub poor_threshold: f32,
pub evolution_trigger_count: usize,
pub check_interval: u64,
}
impl Default for TrajectoryLearnerConfig {
fn default() -> Self {
Self {
max_trajectories: 1000,
poor_threshold: 0.6,
evolution_trigger_count: 10,
check_interval: 50,
}
}
}
#[derive(Debug, Clone)]
pub struct ScoredTrajectory {
pub trajectory: Trajectory,
pub feedback: String,
pub recorded_at: u64,
}
struct LearnerState {
trajectories: VecDeque<ScoredTrajectory>,
total_recorded: u64,
poor_count: usize,
evolution_ready: bool,
}
pub struct TrajectoryLearner {
config: TrajectoryLearnerConfig,
state: Mutex<LearnerState>,
}
impl TrajectoryLearner {
pub fn new(config: TrajectoryLearnerConfig) -> Self {
Self {
config,
state: Mutex::new(LearnerState {
trajectories: VecDeque::new(),
total_recorded: 0,
poor_count: 0,
evolution_ready: false,
}),
}
}
pub fn trajectory_count(&self) -> usize {
self.state.lock().unwrap().trajectories.len()
}
pub fn total_recorded(&self) -> u64 {
self.state.lock().unwrap().total_recorded
}
pub fn is_evolution_ready(&self) -> bool {
self.state.lock().unwrap().evolution_ready
}
pub fn clear_evolution_ready(&self) {
self.state.lock().unwrap().evolution_ready = false;
}
pub fn get_poor_trajectories(&self, n: usize) -> Vec<ScoredTrajectory> {
let state = self.state.lock().unwrap();
let mut poor: Vec<_> = state
.trajectories
.iter()
.filter(|t| t.trajectory.quality.overall < self.config.poor_threshold)
.cloned()
.collect();
poor.sort_by(|a, b| {
a.trajectory
.quality
.overall
.partial_cmp(&b.trajectory.quality.overall)
.unwrap_or(std::cmp::Ordering::Equal)
});
poor.truncate(n);
poor
}
pub fn get_best_trajectories(&self, n: usize) -> Vec<ScoredTrajectory> {
let state = self.state.lock().unwrap();
let mut best: Vec<_> = state.trajectories.iter().cloned().collect();
best.sort_by(|a, b| {
b.trajectory
.quality
.overall
.partial_cmp(&a.trajectory.quality.overall)
.unwrap_or(std::cmp::Ordering::Equal)
});
best.truncate(n);
best
}
pub fn extract_successful_patterns(&self) -> Vec<String> {
let state = self.state.lock().unwrap();
state
.trajectories
.iter()
.filter(|t| t.trajectory.quality.overall >= 0.8)
.filter_map(|t| {
t.trajectory
.request
.messages
.first()
.map(|m| m.content.clone())
})
.collect()
}
fn should_evolve(state: &LearnerState, config: &TrajectoryLearnerConfig) -> bool {
state.poor_count >= config.evolution_trigger_count
&& !state.evolution_ready
&& state.total_recorded > 0
&& state.total_recorded % config.check_interval == 0
}
fn generate_feedback(trajectory: &Trajectory, poor_threshold: f32) -> String {
let q = &trajectory.quality;
let assessment = if q.overall < poor_threshold {
"Below quality threshold -- candidate for reflection."
} else if q.overall >= 0.9 {
"Excellent quality -- use as positive example."
} else {
"Acceptable quality."
};
let mut parts = Vec::new();
if q.relevance < 0.5 {
parts.push("Low relevance: response may not address the request.");
}
if q.coherence < 0.5 {
parts.push("Low coherence: response may be unclear or disjointed.");
}
format!(
"Overall: {:.2}, Relevance: {:.2}, Coherence: {:.2}. {}{}",
q.overall,
q.relevance,
q.coherence,
assessment,
if parts.is_empty() {
String::new()
} else {
format!(" {}", parts.join(" "))
}
)
}
}
impl LearningBackend for TrajectoryLearner {
fn record(&self, trajectory: &Trajectory) {
let mut state = self.state.lock().unwrap();
let feedback =
Self::generate_feedback(trajectory, self.config.poor_threshold);
let scored = ScoredTrajectory {
trajectory: trajectory.clone(),
feedback,
recorded_at: state.total_recorded,
};
if trajectory.quality.overall < self.config.poor_threshold {
state.poor_count += 1;
}
state.trajectories.push_back(scored);
if state.trajectories.len() > self.config.max_trajectories {
if let Some(removed) = state.trajectories.pop_front() {
if removed.trajectory.quality.overall < self.config.poor_threshold {
state.poor_count = state.poor_count.saturating_sub(1);
}
}
}
state.total_recorded += 1;
if Self::should_evolve(&state, &self.config) {
state.evolution_ready = true;
}
}
fn adapt(&self, signal: &LearningSignal) {
let mut state = self.state.lock().unwrap();
if signal.value < 0.0 {
state.poor_count += 2;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::traits::{ChatRequest, LlmMessage, QualityScore, RoutingDecision};
use clawft_types::provider::{ContentBlock, LlmResponse, StopReason, Usage};
use std::collections::HashMap;
fn make_trajectory_with_quality(overall: f32, relevance: f32, coherence: f32) -> Trajectory {
Trajectory {
request: ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
},
routing: RoutingDecision {
provider: "openai".into(),
model: "gpt-4o".into(),
reason: "test".into(),
..Default::default()
},
response: LlmResponse {
id: "resp-1".into(),
content: vec![ContentBlock::Text { text: "Hi!".into() }],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 2,
total_tokens: 0,
},
metadata: HashMap::new(),
},
quality: QualityScore {
overall,
relevance,
coherence,
},
}
}
fn make_trajectory() -> Trajectory {
make_trajectory_with_quality(0.9, 0.95, 0.85)
}
#[test]
fn noop_record_does_not_panic() {
let learner = NoopLearner::new();
learner.record(&make_trajectory());
}
#[test]
fn noop_adapt_does_not_panic() {
let learner = NoopLearner::new();
learner.adapt(&LearningSignal {
feedback_type: "thumbs_up".into(),
value: 1.0,
});
}
#[test]
fn noop_adapt_negative_signal_does_not_panic() {
let learner = NoopLearner::new();
learner.adapt(&LearningSignal {
feedback_type: "thumbs_down".into(),
value: -1.0,
});
}
#[test]
fn noop_multiple_records_do_not_panic() {
let learner = NoopLearner::new();
for _ in 0..100 {
learner.record(&make_trajectory());
}
}
#[test]
fn noop_multiple_adapts_do_not_panic() {
let learner = NoopLearner::new();
for i in 0..50 {
learner.adapt(&LearningSignal {
feedback_type: format!("signal_{i}"),
value: i as f32 / 50.0,
});
}
}
#[test]
fn noop_default_trait_impl() {
let learner = NoopLearner;
learner.record(&make_trajectory());
learner.adapt(&LearningSignal {
feedback_type: "test".into(),
value: 0.0,
});
}
#[test]
fn trajectory_learner_records_and_counts() {
let learner = TrajectoryLearner::new(TrajectoryLearnerConfig::default());
assert_eq!(learner.trajectory_count(), 0);
assert_eq!(learner.total_recorded(), 0);
learner.record(&make_trajectory());
assert_eq!(learner.trajectory_count(), 1);
assert_eq!(learner.total_recorded(), 1);
learner.record(&make_trajectory());
assert_eq!(learner.trajectory_count(), 2);
assert_eq!(learner.total_recorded(), 2);
}
#[test]
fn trajectory_learner_ring_buffer_eviction() {
let config = TrajectoryLearnerConfig {
max_trajectories: 3,
..Default::default()
};
let learner = TrajectoryLearner::new(config);
for _ in 0..5 {
learner.record(&make_trajectory());
}
assert_eq!(learner.trajectory_count(), 3);
assert_eq!(learner.total_recorded(), 5);
}
#[test]
fn trajectory_learner_poor_trajectory_tracking() {
let config = TrajectoryLearnerConfig {
poor_threshold: 0.6,
..Default::default()
};
let learner = TrajectoryLearner::new(config);
learner.record(&make_trajectory_with_quality(0.3, 0.2, 0.4));
learner.record(&make_trajectory_with_quality(0.9, 0.95, 0.85));
let poor = learner.get_poor_trajectories(10);
assert_eq!(poor.len(), 1);
assert!((poor[0].trajectory.quality.overall - 0.3).abs() < f32::EPSILON);
}
#[test]
fn trajectory_learner_get_best_trajectories() {
let learner = TrajectoryLearner::new(TrajectoryLearnerConfig::default());
learner.record(&make_trajectory_with_quality(0.3, 0.2, 0.4));
learner.record(&make_trajectory_with_quality(0.95, 0.9, 0.95));
learner.record(&make_trajectory_with_quality(0.7, 0.8, 0.6));
let best = learner.get_best_trajectories(2);
assert_eq!(best.len(), 2);
assert!(best[0].trajectory.quality.overall > best[1].trajectory.quality.overall);
}
#[test]
fn trajectory_learner_extract_successful_patterns() {
let learner = TrajectoryLearner::new(TrajectoryLearnerConfig::default());
learner.record(&make_trajectory_with_quality(0.3, 0.2, 0.4));
learner.record(&make_trajectory_with_quality(0.9, 0.9, 0.9));
learner.record(&make_trajectory_with_quality(0.85, 0.8, 0.9));
let patterns = learner.extract_successful_patterns();
assert_eq!(patterns.len(), 2);
}
#[test]
fn trajectory_learner_evolution_trigger() {
let config = TrajectoryLearnerConfig {
max_trajectories: 1000,
poor_threshold: 0.6,
evolution_trigger_count: 3,
check_interval: 5,
};
let learner = TrajectoryLearner::new(config);
assert!(!learner.is_evolution_ready());
for _ in 0..5 {
learner.record(&make_trajectory_with_quality(0.2, 0.1, 0.3));
}
assert!(learner.is_evolution_ready());
learner.clear_evolution_ready();
assert!(!learner.is_evolution_ready());
}
#[test]
fn trajectory_learner_negative_adapt_accelerates_trigger() {
let config = TrajectoryLearnerConfig {
max_trajectories: 1000,
poor_threshold: 0.6,
evolution_trigger_count: 5,
check_interval: 3,
};
let learner = TrajectoryLearner::new(config);
learner.record(&make_trajectory_with_quality(0.3, 0.2, 0.4));
learner.adapt(&LearningSignal {
feedback_type: "thumbs_down".into(),
value: -1.0,
});
learner.record(&make_trajectory_with_quality(0.3, 0.2, 0.4));
learner.record(&make_trajectory_with_quality(0.3, 0.2, 0.4));
assert!(learner.is_evolution_ready());
}
#[test]
fn trajectory_learner_feedback_text_generation() {
let poor = make_trajectory_with_quality(0.3, 0.4, 0.3);
let feedback = TrajectoryLearner::generate_feedback(&poor, 0.6);
assert!(feedback.contains("Below quality threshold"));
assert!(feedback.contains("Low coherence"));
let good = make_trajectory_with_quality(0.95, 0.9, 0.9);
let feedback = TrajectoryLearner::generate_feedback(&good, 0.6);
assert!(feedback.contains("Excellent quality"));
let ok = make_trajectory_with_quality(0.7, 0.8, 0.7);
let feedback = TrajectoryLearner::generate_feedback(&ok, 0.6);
assert!(feedback.contains("Acceptable quality"));
}
#[test]
fn trajectory_learner_poor_eviction_decrements_count() {
let config = TrajectoryLearnerConfig {
max_trajectories: 2,
poor_threshold: 0.6,
evolution_trigger_count: 100, check_interval: 100,
};
let learner = TrajectoryLearner::new(config);
learner.record(&make_trajectory_with_quality(0.3, 0.2, 0.3));
learner.record(&make_trajectory_with_quality(0.4, 0.3, 0.3));
learner.record(&make_trajectory_with_quality(0.9, 0.9, 0.9));
let poor = learner.get_poor_trajectories(10);
assert_eq!(poor.len(), 1);
}
}