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, 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) }