nd / nd-core/src/similar.rs

raw file 3624 bytes
//! "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<String>,
    pub shared_code_keywords: Vec<String>,
    pub shared_languages: Vec<String>,
}

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<String> {
    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<String> {
    let sb = set_of(b);
    let mut out: Vec<String> = 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");
    }
}