//! "doubles" scoring — how similar two projects are. //! //! Deliberately dumb and transparent: weighted overlap of manifest tags. //! Easy to swap for embeddings / fts later; the output shape is what //! matters (score + which tags are shared, so the ui can show them). use crate::models::Project; use serde::{Deserialize, Serialize}; use std::collections::HashSet; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Similarity { /// keywords x3, code_keywords x2, languages x1 (case-insensitive) pub score: i64, pub shared_keywords: Vec, pub shared_code_keywords: Vec, pub shared_languages: Vec, } pub fn similarity(a: &Project, b: &Project) -> Similarity { let kw = inter(&a.keywords, &b.keywords); let code = inter(&a.code_keywords, &b.code_keywords); let lang = inter(&a.languages, &b.languages); Similarity { score: kw * 3 + code * 2 + lang, shared_keywords: kw_list(&a.keywords, &b.keywords), shared_code_keywords: kw_list(&a.code_keywords, &b.code_keywords), shared_languages: kw_list(&a.languages, &b.languages), } } /// Projects most similar to `target`, best first, `limit` items, score > 0. pub fn find_similar(all: &[Project], target: &Project, limit: usize) -> Vec<(Project, Similarity)> { let mut v: Vec<(Project, Similarity)> = all .iter() .filter(|p| p.slug != target.slug) .map(|p| (p.clone(), similarity(target, p))) .filter(|(_, s)| s.score > 0) .collect(); v.sort_by(|x, y| y.1.score.cmp(&x.1.score).then_with(|| x.0.slug.cmp(&y.0.slug))); v.truncate(limit); v } fn set_of(v: &[String]) -> HashSet { v.iter() .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .collect() } fn inter(a: &[String], b: &[String]) -> i64 { let (sa, sb) = (set_of(a), set_of(b)); sa.intersection(&sb).count() as i64 } /// The actually-shared tags, in a's casing, sorted + deduped. fn kw_list(a: &[String], b: &[String]) -> Vec { let sb = set_of(b); let mut out: Vec = a .iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty() && sb.contains(&s.to_lowercase())) .collect(); out.sort(); out.dedup(); out } #[cfg(test)] mod tests { use super::*; fn p(slug: &str, keywords: &[&str], code: &[&str], langs: &[&str]) -> Project { Project { slug: slug.into(), keywords: keywords.iter().map(|s| s.to_string()).collect(), code_keywords: code.iter().map(|s| s.to_string()).collect(), languages: langs.iter().map(|s| s.to_string()).collect(), ..Default::default() } } #[test] fn scores_and_shared() { let a = p("a", &["todo", "cli"], &["rust"], &["rust"]); let b = p("b", &["TODO", "game"], &["rust", "axum"], &["rust"]); let s = similarity(&a, &b); assert_eq!(s.score, 3 + 2 + 1); assert_eq!(s.shared_keywords, vec!["todo"]); assert_eq!(s.shared_code_keywords, vec!["rust"]); assert_eq!(s.shared_languages, vec!["rust"]); let c = p("c", &["nothing"], &[], &[]); assert_eq!(similarity(&a, &c).score, 0); } #[test] fn finds_best() { let a = p("a", &["x"], &[], &[]); let b = p("b", &["x", "y"], &[], &[]); let c = p("c", &["z"], &[], &[]); let all = vec![a.clone(), b.clone(), c]; let hits = find_similar(&all, &a, 5); assert_eq!(hits.len(), 1); assert_eq!(hits[0].0.slug, "b"); } }