use actix_web::{web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use serde_json::json; use sqlx::PgPool; use std::collections::HashMap; use crate::auth::AuthGuard; /// Ranking profile with configurable signal weights #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RankingProfile { pub name: String, pub description: String, pub weights: RankingWeights, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RankingWeights { pub semantic: f64, pub lexical: f64, pub recency: f64, pub frequency: f64, pub confidence: f64, pub community: f64, pub contradiction: f64, } impl RankingWeights { /// Default balanced profile pub fn default() -> Self { Self { semantic: 0.40, lexical: 0.25, recency: 0.15, frequency: 0.10, confidence: 0.05, community: 0.03, contradiction: 0.02, } } /// Recency-focused profile pub fn recency_focused() -> Self { Self { semantic: 0.30, lexical: 0.15, recency: 0.35, frequency: 0.10, confidence: 0.05, community: 0.03, contradiction: 0.02, } } /// Accuracy-focused profile (high confidence, no contradictions) pub fn accuracy_focused() -> Self { Self { semantic: 0.35, lexical: 0.20, recency: 0.10, frequency: 0.05, confidence: 0.20, community: 0.05, contradiction: 0.05, } } /// Normalize weights to sum to 1.0 pub fn normalize(&mut self) { let sum = self.semantic + self.lexical + self.recency + self.frequency + self.confidence + self.community + self.contradiction.abs(); if sum > 0.0 { self.semantic /= sum; self.lexical /= sum; self.recency /= sum; self.frequency /= sum; self.confidence /= sum; self.community /= sum; self.contradiction /= sum; } } } /// Ranking signals for an entity #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RankingSignals { pub semantic: f64, pub lexical: f64, pub recency: f64, pub frequency: f64, pub confidence: f64, pub community: f64, pub contradiction: f64, } impl RankingSignals { /// Compute recency signal from last update time pub fn compute_recency(updated_at: DateTime) -> f64 { let age_days = (Utc::now() - updated_at).num_days() as f64; // Exponential decay with 30-day half-life (-age_days / 30.0).exp() } /// Compute frequency signal from access count pub fn compute_frequency(access_count: u64, max_access: u64) -> f64 { if max_access == 0 { return 0.0; } (access_count as f64 + 1.0).ln() / (max_access as f64 + 1.0).ln() } /// Compute confidence with decay (no recent confirmation = lower) pub fn compute_confidence(base_confidence: f64, last_confirmed: DateTime) -> f64 { let staleness = (Utc::now() - last_confirmed).num_days() as f64; let decay = (-staleness / 90.0).exp(); // 90-day half-life base_confidence * decay } /// Compute community activity score pub fn compute_community(community_size: usize, recent_edges: usize) -> f64 { let size_factor = (community_size as f64).min(100.0) / 100.0; let activity_factor = (recent_edges as f64).min(50.0) / 50.0; (size_factor + activity_factor) / 2.0 } /// Compute contradiction penalty pub fn compute_contradiction(unresolved_count: usize, total_edges: usize) -> f64 { if total_edges == 0 { return 0.0; } let ratio = unresolved_count as f64 / total_edges as f64; // Max penalty -0.3 -(ratio * 0.3).min(0.3) } } /// Signal breakdown with contributions #[derive(Debug, serde::Serialize)] pub struct SignalBreakdown { pub semantic: SignalDetail, pub lexical: SignalDetail, pub recency: SignalDetail, pub frequency: SignalDetail, pub confidence: SignalDetail, pub community: SignalDetail, pub contradiction: SignalDetail, } #[derive(Debug, serde::Serialize)] pub struct SignalDetail { pub raw: f64, pub weight: f64, pub contribution: f64, } /// Ranked result with signal breakdown #[derive(Debug, serde::Serialize)] pub struct RankedResult { pub id: String, pub name: String, pub final_score: f64, pub signal_breakdown: Option, } /// GET /memory/ranking/profiles pub async fn get_ranking_profiles(req: HttpRequest) -> HttpResponse { // Verify auth if let Err(e) = AuthGuard::extract_token(&req) { return HttpResponse::Unauthorized().json(json!({ "error": e.to_string() })); } let profiles = vec![ RankingProfile { name: "default".to_string(), description: "Balanced multi-signal ranking".to_string(), weights: RankingWeights::default(), }, RankingProfile { name: "recency_focused".to_string(), description: "Prioritize recent updates".to_string(), weights: RankingWeights::recency_focused(), }, RankingProfile { name: "accuracy_focused".to_string(), description: "Prioritize high-confidence, no contradictions".to_string(), weights: RankingWeights::accuracy_focused(), }, ]; HttpResponse::Ok().json(json!({ "profiles": profiles })) } /// Helper to compute multi-signal score pub fn compute_final_score(signals: &RankingSignals, weights: &RankingWeights) -> f64 { let raw = signals.semantic * weights.semantic + signals.lexical * weights.lexical + signals.recency * weights.recency + signals.frequency * weights.frequency + signals.confidence * weights.confidence + signals.community * weights.community + signals.contradiction * weights.contradiction; raw.clamp(0.0, 1.0) } /// Helper to generate signal breakdown pub fn generate_breakdown(signals: &RankingSignals, weights: &RankingWeights) -> SignalBreakdown { SignalBreakdown { semantic: SignalDetail { raw: signals.semantic, weight: weights.semantic, contribution: signals.semantic * weights.semantic, }, lexical: SignalDetail { raw: signals.lexical, weight: weights.lexical, contribution: signals.lexical * weights.lexical, }, recency: SignalDetail { raw: signals.recency, weight: weights.recency, contribution: signals.recency * weights.recency, }, frequency: SignalDetail { raw: signals.frequency, weight: weights.frequency, contribution: signals.frequency * weights.frequency, }, confidence: SignalDetail { raw: signals.confidence, weight: weights.confidence, contribution: signals.confidence * weights.confidence, }, community: SignalDetail { raw: signals.community, weight: weights.community, contribution: signals.community * weights.community, }, contradiction: SignalDetail { raw: signals.contradiction, weight: weights.contradiction, contribution: signals.contradiction * weights.contradiction, }, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_recency_signal_recent() { let now = Utc::now(); let score = RankingSignals::compute_recency(now); assert!(score > 0.99); // Just now = ~1.0 } #[test] fn test_recency_signal_30_days() { let thirty_days_ago = Utc::now() - chrono::Duration::days(30); let score = RankingSignals::compute_recency(thirty_days_ago); assert!((score - 0.368).abs() < 0.01); // e^-1 ≈ 0.368 } #[test] fn test_frequency_signal_normalized() { let score = RankingSignals::compute_frequency(50, 100); assert!(score > 0.0 && score < 1.0); } #[test] fn test_contradiction_penalty_capped() { let penalty = RankingSignals::compute_contradiction(100, 10); assert_eq!(penalty, -0.3); // Max penalty } #[test] fn test_weights_normalize() { let mut weights = RankingWeights { semantic: 2.0, lexical: 2.0, recency: 2.0, frequency: 2.0, confidence: 2.0, community: 0.0, contradiction: 0.0, }; weights.normalize(); let sum = weights.semantic + weights.lexical + weights.recency + weights.frequency + weights.confidence; assert!((sum - 1.0).abs() < 0.001); } #[test] fn test_compute_score_clamped() { let signals = RankingSignals { semantic: 1.0, lexical: 1.0, recency: 1.0, frequency: 1.0, confidence: 1.0, community: 1.0, contradiction: 0.0, }; let weights = RankingWeights::default(); let score = compute_final_score(&signals, &weights); assert!(score <= 1.0); } #[test] fn test_profile_presets() { let default = RankingWeights::default(); let recency = RankingWeights::recency_focused(); let accuracy = RankingWeights::accuracy_focused(); assert!(recency.recency > default.recency); assert!(accuracy.confidence > default.confidence); } }