330 lines
9.6 KiB
Rust
330 lines
9.6 KiB
Rust
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<Utc>) -> 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<Utc>) -> 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<SignalBreakdown>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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);
|
||
|
|
}
|
||
|
|
}
|