Skip to main content

paper_gap/
analyse.rs

1use crate::pwc::CodeLink;
2use crate::repos::Repository;
3use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub struct RepositoryAnalysis {
8    pub has_license: bool,
9    pub is_stale: bool,
10    pub is_archived: bool,
11    pub primary_language: Option<String>,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct GapAssessment {
16    pub implementation_gap_score: u32,
17    pub components: ScoreComponents,
18    pub gaps: Vec<Gap>,
19}
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ScoreComponents {
23    pub no_code_score: u32,
24    pub prototype_only_score: u32,
25    pub stale_repo_score: u32,
26    pub packaging_gap_score: u32,
27    pub docs_gap_score: u32,
28    pub tests_gap_score: u32,
29    pub license_gap_score: u32,
30    pub reproducibility_gap_score: u32,
31    pub ecosystem_gap_score: u32,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct Gap {
36    pub gap_type: GapType,
37    pub points: u32,
38    pub explanation: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum GapType {
44    NoPublicImplementationFound,
45    NoKnownPapersWithCodeEntry,
46    StaleRepository,
47    ArchivedRepository,
48    UnclearOrRestrictiveLicense,
49}
50
51pub fn analyze_repository(repo: &Repository) -> RepositoryAnalysis {
52    RepositoryAnalysis {
53        has_license: repo
54            .license
55            .as_deref()
56            .is_some_and(|license| !license.trim().is_empty() && license != "NOASSERTION"),
57        is_stale: repo.last_commit_date.as_deref().is_some_and(is_stale_date),
58        is_archived: repo.archived.unwrap_or(false),
59        primary_language: repo.primary_language.clone(),
60    }
61}
62
63pub fn assess(code_links: &[CodeLink], repositories: &[Repository]) -> GapAssessment {
64    let repositories = dedup_repositories(repositories.to_vec());
65    let analyses: Vec<_> = repositories.iter().map(analyze_repository).collect();
66    let mut components = ScoreComponents::default();
67    let mut gaps = Vec::new();
68
69    if code_links.is_empty() {
70        components.no_code_score += 10;
71        gaps.push(Gap {
72            gap_type: GapType::NoKnownPapersWithCodeEntry,
73            points: 10,
74            explanation: "Papers with Code returned no known code links.".into(),
75        });
76    }
77    if code_links.is_empty() && repositories.is_empty() {
78        components.no_code_score += 40;
79        gaps.push(Gap {
80            gap_type: GapType::NoPublicImplementationFound,
81            points: 40,
82            explanation:
83                "No public implementation was found from Papers with Code or repository search."
84                    .into(),
85        });
86    }
87    if analyses.iter().any(|a| a.is_archived) {
88        components.stale_repo_score += 15;
89        gaps.push(Gap {
90            gap_type: GapType::ArchivedRepository,
91            points: 15,
92            explanation: "At least one discovered repository is archived.".into(),
93        });
94    }
95    if !analyses.is_empty() && analyses.iter().all(|a| a.is_stale) {
96        components.stale_repo_score += 15;
97        gaps.push(Gap {
98            gap_type: GapType::StaleRepository,
99            points: 15,
100            explanation: "All discovered repositories with commit dates look stale.".into(),
101        });
102    }
103    if !analyses.is_empty() && analyses.iter().all(|a| !a.has_license) {
104        components.license_gap_score += 10;
105        gaps.push(Gap {
106            gap_type: GapType::UnclearOrRestrictiveLicense,
107            points: 10,
108            explanation: "No discovered repository has a clear license signal.".into(),
109        });
110    }
111
112    let implementation_gap_score = components.no_code_score
113        + components.prototype_only_score
114        + components.stale_repo_score
115        + components.packaging_gap_score
116        + components.docs_gap_score
117        + components.tests_gap_score
118        + components.license_gap_score
119        + components.reproducibility_gap_score
120        + components.ecosystem_gap_score;
121
122    GapAssessment {
123        implementation_gap_score,
124        components,
125        gaps,
126    }
127}
128
129pub fn dedup_repositories(repositories: Vec<Repository>) -> Vec<Repository> {
130    let mut out = Vec::new();
131    for repo in repositories {
132        let key = normalized_url(&repo.url);
133        if !out
134            .iter()
135            .any(|existing: &Repository| normalized_url(&existing.url) == key)
136        {
137            out.push(repo);
138        }
139    }
140    out
141}
142
143fn normalized_url(url: &str) -> String {
144    url.trim_end_matches('/')
145        .trim_end_matches(".git")
146        .to_ascii_lowercase()
147}
148
149fn is_stale_date(date: &str) -> bool {
150    DateTime::parse_from_rfc3339(date)
151        .map(|date| {
152            Utc::now().signed_duration_since(date.with_timezone(&Utc)) > Duration::days(365)
153        })
154        .unwrap_or(false)
155}