Files
poimen-memory/crates/mem-cli/src/handlers/ranking_handler.rs
T
rock d8c3b06cb0
CI / CI (push) Successful in 15m14s
fix: resolve 75 mem-cli compilation errors
All errors were API mismatches — handler code calling wrong method
   names, wrong argument types, or missing imports/derives. No logic
   changes. Build now passes with SQLX_OFFLINE=true.

   Key fixes:
   - embed_text -> embed_one, Vector -> Vec<f32> conversion
   - extract_token: extract auth header from HttpRequest first
   - AuthError variants aligned to actual enum definition
   - recursive async fns boxed (dfs_paths in inference + path_finder)
   - missing derives (Default, Serialize), imports (sqlx::Row, Timelike)
   - borrow-after-move: compute .len() before struct field move
   - streaming_body -> streaming with Result<Bytes> for SSE
   - CI: add SQLX_OFFLINE=true for offline builds without DB

   25 files changed, 99 insertions(+), 81 deletions(-)

Co-authored-by: rock <[email protected]>
2026-09-08 01:11:14 +00:00

330 lines
9.7 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.headers().get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("")) {
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);
}
}