Skip to main content

paper_gap/
report.rs

1use crate::{Result, analyse, arxiv, provider, pwc, repos};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Serialize, Deserialize)]
5pub struct ScanReport {
6    #[serde(default)]
7    pub paper_provider_outcomes: Vec<provider::ProviderOutcome>,
8    pub paper_results: Vec<PaperResult>,
9}
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct PaperResult {
13    pub paper: arxiv::Paper,
14    pub code_links: Vec<pwc::CodeLink>,
15    pub repositories: Vec<repos::Repository>,
16    #[serde(default)]
17    pub repository_matches: Vec<repos::RepositoryMatch>,
18    pub repository_analyses: Vec<analyse::RepositoryAnalysis>,
19    #[serde(default = "provider::default_success")]
20    pub pwc_outcome: provider::ProviderOutcome,
21    #[serde(default = "provider::default_success")]
22    pub repository_search_outcome: provider::ProviderOutcome,
23    #[serde(default)]
24    pub repository_inspection_outcomes: Vec<provider::ProviderOutcome>,
25    pub gap_assessment: analyse::GapAssessment,
26}
27
28#[derive(Clone, Copy)]
29pub enum OutputFormat {
30    Markdown,
31    Json,
32}
33
34pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
35    Ok(match format {
36        OutputFormat::Json => serde_json::to_string_pretty(report)?,
37        OutputFormat::Markdown => markdown(report),
38    })
39}
40
41fn markdown(report: &ScanReport) -> String {
42    let mut results: Vec<_> = report.paper_results.iter().collect();
43    results.sort_by_key(|result| std::cmp::Reverse(result.gap_assessment.implementation_gap_score));
44
45    let mut out = format!(
46        "# paper-gap report\n\n{} papers analysed. Ranked by implementation gap score.\n\n",
47        results.len()
48    );
49    for outcome in report
50        .paper_provider_outcomes
51        .iter()
52        .filter(|outcome| !outcome.succeeded())
53    {
54        out.push_str(&format!(
55            "- Paper provider warning: {} {}{}\n",
56            outcome.provider,
57            outcome.status,
58            outcome
59                .message
60                .as_deref()
61                .map(|message| format!(" — {message}"))
62                .unwrap_or_default()
63        ));
64    }
65    if report
66        .paper_provider_outcomes
67        .iter()
68        .any(|outcome| !outcome.succeeded())
69    {
70        out.push('\n');
71    }
72
73    for (index, result) in results.iter().enumerate() {
74        let p = &result.paper;
75        out.push_str(&format!(
76            "## {}. {}\n\n- Implementation gap score: {}\n- arXiv: {}\n- Published: {}\n- Authors: {}\n- Categories: {}\n",
77            index + 1,
78            p.title,
79            result.gap_assessment.implementation_gap_score,
80            p.arxiv_id.as_deref().unwrap_or(""),
81            p.published_date.as_deref().unwrap_or("unknown"),
82            p.authors.join(", "),
83            p.categories.join(", ")
84        ));
85
86        push_code_links(&mut out, &result.code_links);
87        push_repositories(
88            &mut out,
89            &result.repositories,
90            &result.repository_matches,
91            &result.repository_analyses,
92        );
93        push_provider_warnings(&mut out, result);
94        push_score(&mut out, &result.gap_assessment);
95
96        out.push_str(&format!("\n{}\n\n", p.abstract_text));
97    }
98
99    out
100}
101
102fn push_code_links(out: &mut String, links: &[pwc::CodeLink]) {
103    if links.is_empty() {
104        out.push_str("- Papers with Code links: none known\n");
105        return;
106    }
107    out.push_str("- Papers with Code links:\n");
108    for link in links {
109        out.push_str(&format!("  - {}\n", link.repository_url));
110    }
111}
112
113fn push_repositories(
114    out: &mut String,
115    repositories: &[repos::Repository],
116    matches: &[repos::RepositoryMatch],
117    analyses: &[analyse::RepositoryAnalysis],
118) {
119    if repositories.is_empty() {
120        out.push_str(
121            "- Repository search results: none found
122",
123        );
124        return;
125    }
126    out.push_str(
127        "- Repository search results:
128",
129    );
130    for (index, repo) in repositories.iter().enumerate() {
131        let signals = analyses
132            .get(index)
133            .map(quality_signals)
134            .unwrap_or_else(|| vec!["signals: none".to_string()]);
135        let match_summary = matches
136            .iter()
137            .find(|matched| matched.repository.url == repo.url)
138            .map(|matched| {
139                let evidence = matched
140                    .evidence
141                    .iter()
142                    .map(|item| format!("{}={} (+{})", item.kind, item.value, item.points))
143                    .collect::<Vec<_>>()
144                    .join("; ");
145                format!("match {} [{}]", matched.confidence_score, evidence)
146            })
147            .unwrap_or_else(|| "match unknown".into());
148        out.push_str(&format!(
149            "  - {} ({}) — {} — {}
150",
151            repo.url,
152            repo.forge,
153            match_summary,
154            signals.join(", ")
155        ));
156    }
157}
158
159fn quality_signals(analysis: &analyse::RepositoryAnalysis) -> Vec<String> {
160    let mut signals = Vec::new();
161    if analysis.has_readme {
162        signals.push("README");
163    }
164    if analysis.has_license {
165        signals.push("license");
166    }
167    if analysis.has_tests {
168        signals.push("tests");
169    }
170    if analysis.has_ci {
171        signals.push("CI");
172    }
173    if analysis.has_docs {
174        signals.push("docs");
175    }
176    if analysis.has_examples {
177        signals.push("examples");
178    }
179    if analysis.has_package_manifest {
180        signals.push("package");
181    }
182    if analysis.has_reproducibility_scripts {
183        signals.push("repro");
184    }
185    if analysis.has_benchmarks {
186        signals.push("benchmarks");
187    }
188    if analysis.is_notebook_only {
189        signals.push("notebook-only");
190    }
191    if analysis.is_stale {
192        signals.push("stale");
193    }
194    if analysis.is_archived {
195        signals.push("archived");
196    }
197    if signals.is_empty() {
198        vec!["signals: none".to_string()]
199    } else {
200        signals.into_iter().map(str::to_string).collect()
201    }
202}
203
204fn push_provider_warnings(out: &mut String, result: &PaperResult) {
205    let outcomes = std::iter::once(&result.pwc_outcome)
206        .chain(std::iter::once(&result.repository_search_outcome))
207        .chain(result.repository_inspection_outcomes.iter());
208    for outcome in outcomes.filter(|outcome| !outcome.succeeded()) {
209        out.push_str(&format!(
210            "- Provider warning: {} {}{}\n",
211            outcome.provider,
212            outcome.status,
213            outcome
214                .message
215                .as_deref()
216                .map(|message| format!(" — {message}"))
217                .unwrap_or_default()
218        ));
219    }
220}
221
222fn push_score(out: &mut String, assessment: &analyse::GapAssessment) {
223    let c = &assessment.components;
224    out.push_str("- Score components:\n");
225    out.push_str(&format!("  - no_code_score: {}\n", c.no_code_score));
226    out.push_str(&format!(
227        "  - prototype_only_score: {}\n",
228        c.prototype_only_score
229    ));
230    out.push_str(&format!("  - stale_repo_score: {}\n", c.stale_repo_score));
231    out.push_str(&format!(
232        "  - packaging_gap_score: {}\n",
233        c.packaging_gap_score
234    ));
235    out.push_str(&format!("  - docs_gap_score: {}\n", c.docs_gap_score));
236    out.push_str(&format!("  - tests_gap_score: {}\n", c.tests_gap_score));
237    out.push_str(&format!("  - license_gap_score: {}\n", c.license_gap_score));
238    out.push_str(&format!(
239        "  - reproducibility_gap_score: {}\n",
240        c.reproducibility_gap_score
241    ));
242    out.push_str(&format!(
243        "  - ecosystem_gap_score: {}\n",
244        c.ecosystem_gap_score
245    ));
246
247    if assessment.gaps.is_empty() {
248        out.push_str("- Gap reasons: none\n");
249        return;
250    }
251    out.push_str("- Gap reasons:\n");
252    for gap in &assessment.gaps {
253        out.push_str(&format!(
254            "  - {:?}: +{} — {}\n",
255            gap.gap_type, gap.points, gap.explanation
256        ));
257    }
258}