use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const MAX_SIGNAL_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_SIGNALS_PER_FILE: usize = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Success,
PartialSuccess,
Failure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HumanVerdict {
Approved,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualitySignal {
pub id: String,
pub task_description: String,
pub outcome: Outcome,
pub quality_score: f32,
#[serde(default)]
pub human_verdict: Option<HumanVerdict>,
#[serde(default)]
pub quality_factors: Option<QualityFactors>,
pub completed_at: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QualityFactors {
pub acceptance_criteria_met: Option<f32>,
pub tests_passing: Option<f32>,
pub no_regressions: Option<f32>,
pub lint_clean: Option<f32>,
pub type_check_clean: Option<f32>,
pub follows_patterns: Option<f32>,
pub context_relevance: Option<f32>,
pub reasoning_coherence: Option<f32>,
pub execution_efficiency: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderQualityWeights {
pub task_completion: f32,
pub code_quality: f32,
pub process: f32,
}
impl Default for ProviderQualityWeights {
fn default() -> Self {
Self {
task_completion: 0.5,
code_quality: 0.3,
process: 0.2,
}
}
}
pub trait IntelligenceProvider: Send + Sync {
fn name(&self) -> &str;
fn load_signals(&self) -> Result<Vec<QualitySignal>>;
fn quality_weights(&self) -> Option<ProviderQualityWeights> {
None
}
}
pub struct FileSignalProvider {
path: PathBuf,
}
impl FileSignalProvider {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl IntelligenceProvider for FileSignalProvider {
fn name(&self) -> &str {
"file-signals"
}
fn load_signals(&self) -> Result<Vec<QualitySignal>> {
if !self.path.exists() {
return Ok(vec![]); }
let metadata = std::fs::metadata(&self.path)?;
if metadata.len() > MAX_SIGNAL_FILE_SIZE {
return Err(crate::error::RuvLLMError::InvalidOperation(format!(
"Signal file {} exceeds max size ({} bytes, limit {})",
self.path.display(),
metadata.len(),
MAX_SIGNAL_FILE_SIZE
)));
}
let file = std::fs::File::open(&self.path)?;
let reader = std::io::BufReader::new(file);
let signals: Vec<QualitySignal> = serde_json::from_reader(reader).map_err(|e| {
crate::error::RuvLLMError::Serialization(format!(
"Failed to parse signal file {}: {}",
self.path.display(),
e
))
})?;
if signals.len() > MAX_SIGNALS_PER_FILE {
return Err(crate::error::RuvLLMError::InvalidOperation(format!(
"Signal file contains {} signals, max is {}",
signals.len(),
MAX_SIGNALS_PER_FILE
)));
}
for signal in &signals {
if !signal.quality_score.is_finite()
|| signal.quality_score < 0.0
|| signal.quality_score > 1.0
{
return Err(crate::error::RuvLLMError::InvalidOperation(format!(
"Signal '{}' has invalid quality_score: {}",
signal.id, signal.quality_score
)));
}
}
Ok(signals)
}
fn quality_weights(&self) -> Option<ProviderQualityWeights> {
let config_path = self
.path
.parent()
.unwrap_or(Path::new("."))
.join("quality-weights.json");
if !config_path.exists() {
return None;
}
let contents = std::fs::read_to_string(&config_path).ok()?;
serde_json::from_str(&contents).ok()
}
}
pub struct IntelligenceLoader {
providers: Vec<Box<dyn IntelligenceProvider>>,
}
impl IntelligenceLoader {
pub fn new() -> Self {
Self {
providers: Vec::new(),
}
}
pub fn register_provider(&mut self, provider: Box<dyn IntelligenceProvider>) {
self.providers.push(provider);
}
pub fn provider_count(&self) -> usize {
self.providers.len()
}
pub fn provider_names(&self) -> Vec<&str> {
self.providers.iter().map(|p| p.name()).collect()
}
pub fn load_all_signals(&self) -> (Vec<QualitySignal>, Vec<ProviderError>) {
let mut all_signals = Vec::new();
let mut errors = Vec::new();
for provider in &self.providers {
match provider.load_signals() {
Ok(signals) => {
all_signals.extend(signals);
}
Err(e) => {
errors.push(ProviderError {
provider_name: provider.name().to_string(),
message: e.to_string(),
});
}
}
}
(all_signals, errors)
}
pub fn load_grouped(&self) -> Vec<ProviderResult> {
self.providers
.iter()
.map(|provider| {
let signals = provider.load_signals().unwrap_or_default();
let weights = provider.quality_weights();
ProviderResult {
provider_name: provider.name().to_string(),
signals,
weights,
}
})
.collect()
}
}
impl Default for IntelligenceLoader {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ProviderError {
pub provider_name: String,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ProviderResult {
pub provider_name: String,
pub signals: Vec<QualitySignal>,
pub weights: Option<ProviderQualityWeights>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
struct MockProvider {
signals: Vec<QualitySignal>,
}
impl IntelligenceProvider for MockProvider {
fn name(&self) -> &str {
"mock"
}
fn load_signals(&self) -> Result<Vec<QualitySignal>> {
Ok(self.signals.clone())
}
fn quality_weights(&self) -> Option<ProviderQualityWeights> {
Some(ProviderQualityWeights {
task_completion: 0.6,
code_quality: 0.3,
process: 0.1,
})
}
}
struct FailingProvider;
impl IntelligenceProvider for FailingProvider {
fn name(&self) -> &str {
"failing"
}
fn load_signals(&self) -> Result<Vec<QualitySignal>> {
Err(crate::error::RuvLLMError::Serialization(
"simulated failure".into(),
))
}
}
fn make_signal(id: &str, score: f32) -> QualitySignal {
QualitySignal {
id: id.to_string(),
task_description: format!("Task {}", id),
outcome: Outcome::Success,
quality_score: score,
human_verdict: None,
quality_factors: None,
completed_at: "2025-02-21T00:00:00Z".to_string(),
}
}
#[test]
fn empty_loader_returns_no_signals() {
let loader = IntelligenceLoader::new();
let (signals, errors) = loader.load_all_signals();
assert!(signals.is_empty());
assert!(errors.is_empty());
assert_eq!(loader.provider_count(), 0);
}
#[test]
fn mock_provider_returns_signals() {
let mut loader = IntelligenceLoader::new();
loader.register_provider(Box::new(MockProvider {
signals: vec![make_signal("t1", 0.9), make_signal("t2", 0.7)],
}));
let (signals, errors) = loader.load_all_signals();
assert_eq!(signals.len(), 2);
assert!(errors.is_empty());
assert_eq!(signals[0].id, "t1");
assert!((signals[0].quality_score - 0.9).abs() < f32::EPSILON);
}
#[test]
fn failing_provider_non_fatal() {
let mut loader = IntelligenceLoader::new();
loader.register_provider(Box::new(FailingProvider));
loader.register_provider(Box::new(MockProvider {
signals: vec![make_signal("t3", 0.8)],
}));
let (signals, errors) = loader.load_all_signals();
assert_eq!(signals.len(), 1); assert_eq!(errors.len(), 1); assert_eq!(errors[0].provider_name, "failing");
}
#[test]
fn multiple_providers_aggregate() {
let mut loader = IntelligenceLoader::new();
loader.register_provider(Box::new(MockProvider {
signals: vec![make_signal("a1", 0.9)],
}));
loader.register_provider(Box::new(MockProvider {
signals: vec![make_signal("b1", 0.8), make_signal("b2", 0.6)],
}));
let (signals, _) = loader.load_all_signals();
assert_eq!(signals.len(), 3);
assert_eq!(loader.provider_count(), 2);
}
#[test]
fn grouped_loading() {
let mut loader = IntelligenceLoader::new();
loader.register_provider(Box::new(MockProvider {
signals: vec![make_signal("g1", 0.85)],
}));
let results = loader.load_grouped();
assert_eq!(results.len(), 1);
assert_eq!(results[0].provider_name, "mock");
assert_eq!(results[0].signals.len(), 1);
assert!(results[0].weights.is_some());
}
#[test]
fn provider_names() {
let mut loader = IntelligenceLoader::new();
loader.register_provider(Box::new(MockProvider { signals: vec![] }));
loader.register_provider(Box::new(FailingProvider));
assert_eq!(loader.provider_names(), vec!["mock", "failing"]);
}
#[test]
fn file_provider_missing_file() {
let provider = FileSignalProvider::new(PathBuf::from("/nonexistent/signals.json"));
let signals = provider.load_signals().unwrap();
assert!(signals.is_empty());
}
#[test]
fn file_provider_reads_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-signals.json");
let mut f = std::fs::File::create(&path).unwrap();
write!(
f,
r#"[
{{
"id": "f1",
"task_description": "Fix login bug",
"outcome": "success",
"quality_score": 0.95,
"completed_at": "2025-02-21T10:00:00Z"
}}
]"#
)
.unwrap();
let provider = FileSignalProvider::new(path);
let signals = provider.load_signals().unwrap();
assert_eq!(signals.len(), 1);
assert_eq!(signals[0].id, "f1");
assert!((signals[0].quality_score - 0.95).abs() < f32::EPSILON);
}
#[test]
fn file_provider_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not json").unwrap();
let provider = FileSignalProvider::new(path);
assert!(provider.load_signals().is_err());
}
#[test]
fn quality_factors_default() {
let factors = QualityFactors::default();
assert!(factors.acceptance_criteria_met.is_none());
assert!(factors.tests_passing.is_none());
}
#[test]
fn provider_quality_weights_default() {
let w = ProviderQualityWeights::default();
let sum = w.task_completion + w.code_quality + w.process;
assert!((sum - 1.0).abs() < f32::EPSILON);
}
#[test]
fn quality_signal_serde_roundtrip() {
let signal = QualitySignal {
id: "rt1".to_string(),
task_description: "Test roundtrip".to_string(),
outcome: Outcome::Success,
quality_score: 0.88,
human_verdict: Some(HumanVerdict::Approved),
quality_factors: Some(QualityFactors {
tests_passing: Some(1.0),
lint_clean: Some(0.9),
..Default::default()
}),
completed_at: "2025-02-21T12:00:00Z".to_string(),
};
let json = serde_json::to_string(&signal).unwrap();
let parsed: QualitySignal = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "rt1");
assert!((parsed.quality_score - 0.88).abs() < f32::EPSILON);
assert!(parsed.quality_factors.is_some());
let factors = parsed.quality_factors.unwrap();
assert!((factors.tests_passing.unwrap() - 1.0).abs() < f32::EPSILON);
}
}