feedsignal/crates/core/src/scoring.rs

37 lines
1.5 KiB
Rust
Raw Normal View History

use crate::affinity::TopicAffinities;
/// Inputs to the final blended relevance score for one article.
pub struct RelevanceInputs<'a> {
/// Cosine similarity (0.0-1.0, already renormalized from [-1,1] if
/// needed) between article and preference-profile embeddings.
pub embedding_score: f32,
/// LLM judgment (0.0-1.0), `None` if the article didn't clear the
/// embedding shortlist threshold and so was never sent to the LLM.
pub llm_score: Option<f32>,
pub topics: &'a [String],
pub affinities: &'a TopicAffinities,
}
/// Weights are deliberately conservative: the LLM judgment dominates when
/// present (it has read the actual content), the embedding score is a
/// fallback when the LLM stage was skipped, and topic affinity acts as a
/// bounded nudge rather than a veto — a strong LLM match should still
/// surface even for a topic the user historically skims.
const W_LLM: f32 = 0.6;
const W_EMBEDDING: f32 = 0.25;
const W_AFFINITY: f32 = 0.15;
pub fn score_article(inputs: RelevanceInputs) -> f32 {
let affinity = inputs.affinities.score_topics(inputs.topics) as f32; // [-1, 1]
let affinity_component = (affinity + 1.0) / 2.0; // renormalize to [0, 1]
match inputs.llm_score {
Some(llm) => {
W_LLM * llm + W_EMBEDDING * inputs.embedding_score + W_AFFINITY * affinity_component
}
// No LLM score yet: redistribute its weight onto the embedding score.
None => (W_LLM + W_EMBEDDING) * inputs.embedding_score + W_AFFINITY * affinity_component,
}
.clamp(0.0, 1.0)
}